/ src / browser / shape-filter.ts
shape-filter.ts
  1  /**
  2   * Shape-based field filter for `browser network --filter <fields>`.
  3   *
  4   * Agents know what fields a target request's body should contain
  5   * (e.g. "author, text, likes") but not which of the captured requests
  6   * carries that body. This module lets the network command filter
  7   * entries down to those whose inferred shape exposes every requested
  8   * field name as some path segment.
  9   *
 10   * Matching is "any-segment" (not last-segment-only): a field matches
 11   * if it equals any segment name of any path in the shape map. This
 12   * keeps nested-container fields (e.g. `legacy`, `author` used as an
 13   * object key with further nesting) findable.
 14   */
 15  import type { Shape } from './shape.js';
 16  
 17  export interface ParsedFilter {
 18      /** Deduped, order-preserving, trimmed non-empty field names. */
 19      fields: string[];
 20  }
 21  
 22  export interface FilterParseError {
 23      /** `invalid_filter` structured error reason for agents. */
 24      reason: string;
 25  }
 26  
 27  /**
 28   * Parse `--filter` argument value. Splits on `,`, trims, drops empties,
 29   * and dedupes (first-seen wins). Returns `FilterParseError` when the
 30   * result is empty after cleaning — which means the caller passed only
 31   * whitespace, commas, or an empty string.
 32   */
 33  export function parseFilter(raw: string): ParsedFilter | FilterParseError {
 34      if (typeof raw !== 'string') {
 35          return { reason: `--filter value must be a non-empty comma-separated field list` };
 36      }
 37      const parts = raw.split(',').map((p) => p.trim()).filter((p) => p.length > 0);
 38      if (parts.length === 0) {
 39          return { reason: `--filter value must be a non-empty comma-separated field list (got "${raw}")` };
 40      }
 41      const seen = new Set<string>();
 42      const fields: string[] = [];
 43      for (const p of parts) {
 44          if (!seen.has(p)) { seen.add(p); fields.push(p); }
 45      }
 46      return { fields };
 47  }
 48  
 49  /**
 50   * Extract named segments from a shape path. Drops the leading `$`,
 51   * strips `[N]` array indices, and unwraps `["key"]` bracket-quoted
 52   * keys back to their raw string.
 53   *
 54   * Examples:
 55   *   `$`                              → []
 56   *   `$.data.items[0].author`         → ['data','items','author']
 57   *   `$.data.user["nick name"]`       → ['data','user','nick name']
 58   *   `$.rows[0][1]`                   → ['rows']
 59   */
 60  export function extractSegments(path: string): string[] {
 61      if (!path || path === '$') return [];
 62      const out: string[] = [];
 63      // Start past the leading `$`; if path doesn't start with `$` treat
 64      // it as a raw segment list (keeps us robust to unexpected input).
 65      let i = path.startsWith('$') ? 1 : 0;
 66      while (i < path.length) {
 67          const c = path[i];
 68          if (c === '.') { i++; continue; }
 69          if (c === '[') {
 70              // Either `[N]` (numeric) or `["key"]` (quoted key). Handle both.
 71              const end = path.indexOf(']', i);
 72              if (end === -1) break;
 73              const inner = path.slice(i + 1, end);
 74              i = end + 1;
 75              if (inner.length >= 2 && inner.startsWith('"') && inner.endsWith('"')) {
 76                  try { out.push(JSON.parse(inner) as string); }
 77                  catch { out.push(inner.slice(1, -1)); }
 78              }
 79              // numeric index: drop
 80              continue;
 81          }
 82          // Bare identifier: read up to next `.` or `[`
 83          let j = i;
 84          while (j < path.length && path[j] !== '.' && path[j] !== '[') j++;
 85          out.push(path.slice(i, j));
 86          i = j;
 87      }
 88      return out;
 89  }
 90  
 91  /**
 92   * Collect the set of segment names used anywhere in a shape map.
 93   * The returned set is what we test field membership against.
 94   */
 95  export function collectShapeSegments(shape: Shape): Set<string> {
 96      const acc = new Set<string>();
 97      for (const p of Object.keys(shape)) {
 98          for (const seg of extractSegments(p)) acc.add(seg);
 99      }
100      return acc;
101  }
102  
103  /**
104   * True iff every field in `fields` equals some segment name in `shape`.
105   * AND semantics: all fields must be present.
106   */
107  export function shapeMatchesFilter(shape: Shape, fields: string[]): boolean {
108      if (fields.length === 0) return true;
109      const segs = collectShapeSegments(shape);
110      for (const f of fields) {
111          if (!segs.has(f)) return false;
112      }
113      return true;
114  }