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 }