Skip to content

id-375 research — fixQueries

Both unshipped PRODUCT invariants are implementable with existing machinery and one already-declared dependency each. (A) callees (inv 2) is a body-descendant walk: resolveSymbol() (resolve.ts:72) finds the declaration, getBody().getDescendantsOfKind(CallExpression|NewExpression) enumerates calls (nested closures included for free), and the callee is resolved via expr.getSymbol()?.getAliasedSymbol() ?? getSymbol() exactly as flow-trace’s descendIntoCallee already does (flow-trace.ts:409-421); the existing CallResolution union (types.ts:3-9) already covers direct/aliased/destructured/computed-property/indirect. The one real design decision is out-of-corpus callees (console.log, Array.map, supabase .d.ts methods) which collide with inv 16’s “never emit paths outside the corpus” — recommend excluding by default behind --include-external with callee.file: null. (B) fixture-uses (inv 11) is a three-mode scanner outside the ts-morph project (root tsconfig.json:66-74 excludes scripts, supabase, and the tool’s own fixtures, so a project walk cannot work): a hand-rolled JSON string-token lexer for key-vs-value with offset→line mapping (no jsonc-parser in node_modules; stream-json also absent), ad-hoc single-file ts-morph parses for fixture-flagged TS + database.types.ts (PropertySignature names = kind ‘key’), and the yaml package (already a devDependency, already imported by scripts/sync-intent-notes.ts:68) via parseDocument for md frontmatter with real node ranges. File discovery via tinyglobby (declared devDependency, package.json:197). Three spec ambiguities need owner decisions, the biggest being that docs/ontology/*.md no longer exists in the canonical repo — the ontology docs moved to the private docs-site, which inv 30 forbids scanning.

  • PRODUCT.md inv 2 (callees) and inv 11 (fixture-uses) are the two unshipped query invariants: ROADMAP.md:41-45 and the DEFERRED table at ROADMAP.md:73-74 confirm both are deferred with no load-bearing trigger. (ROADMAP.md:75 also lists inv 12, but enum-uses shipped as the covering ROADMAP extension — ROADMAP.md:66.)
  • TECH.md already sketches both: TECH.md:261 (callees = getBody()?.getDescendantsOfKind(SyntaxKind.CallExpression)getExpression().getSymbol(), resolution: 'indirect' when no symbol resolves) and TECH.md:270 (fixture-uses = two-mode: TS AST walk + streaming JSON parser, “stream-json or hand-rolled”, limited to the fixture globs). Test-plan rows: TECH.md:774 (callees) and TECH.md:783 (fixture-uses).
  • Governing result-shape invariants: inv 13 (stable JSON schema, 1-based positions), inv 14 (200-row cap + truncated/total_estimated), inv 15 (confidence tag; fixture grep = indirect), inv 16 (repo-root-relative POSIX paths only, never outside the corpus), inv 19 (callees is resolution-class: 5 s P95 warm; fixture-uses heuristic-class: 10 s P95), inv 23 (ts-morph parity for callees), inv 24 (no exact-tier false negatives for fixture-uses), inv 29 (structured errors), inv 30 (no writes, no network, worktree-scoped reads).
  • tools/ast-dataflow/resolve.ts:72-190resolveSymbol(project, '<file>:<name>', repoRoot) resolves functions, methods, classes, variables; prefers FunctionDeclaration/MethodDeclaration on name collision; throws typed AstResolverError with kinds from types.ts:43-50. Directly reusable as callees’ entry point.
  • tools/ast-dataflow/queries/flow-trace.ts:395-548descendIntoCallee already performs exactly the callee-resolution callees needs: callExpr.getExpression().getType()getSymbol() ?? getAliasSymbol()getDeclarations()[0] (lines 409-421), then function-like kind check (426-431) and Node.isBodyable body check (437-443). This is the proven type-checker path for method calls on inferred types.
  • tools/ast-dataflow/queries/callers.ts:18-44classifyResolution (import-alias detection) and callers.ts:115-154findCallExpression (walks up through PropertyAccessExpression/NonNullExpression, treats NewExpression as a call). Both patterns transfer to callees (inverted direction).
  • tools/ast-dataflow/resolve.ts:226-315findEnclosing gives the enclosing string for call sites inside nested closures (handles arrow-in-callback, property-assignment methods, constructors).
  • tools/ast-dataflow/types.ts:3-9 — existing CallResolution union = 'direct' | 'reexport' | 'aliased' | 'destructured' | 'computed-property' | 'indirect' — a strict superset of what inv 2 demands; reuse rather than mint a new union.
  • tools/ast-dataflow/queries/string-literal-uses.ts:121-214 — corpus-fanout iteration + exact getLiteralValue() matching + totalEstimated/cap idiom. NOT directly reusable for fixture-uses matching (its classifier deliberately drops object-literal values and non-call contexts, string-literal-uses.ts:116-119), but the response-envelope/cap idiom is the model.
  • tools/ast-dataflow/cli.ts:30-47 (flag parser), 107-253 (catalogue), 375-841 (per-query switch cases), parseLimit (65-81), emitResponse (94-105) — the wiring template. index.ts:46-53 createProject.

Corpus constraints that shape fixture-uses (critical)

Section titled “Corpus constraints that shape fixture-uses (critical)”
  • Root tsconfig.json:51-74: include is **/*.ts/**/*.tsx only; exclude lists scripts, supabase, mcp-apps, tools/ast-dataflow/__tests__/fixtures. Consequences: (a) supabase/types/database.types.ts and scripts/tests/fixtures/**/*.ts are NOT in the ts-morph project — fixture-uses must load them ad hoc; (b) JSON files are never in the project regardless of resolveJsonModule: true (tsconfig.json:31) — JSON needs raw-text scanning; (c) __tests__/** and e2e/** TS are in the project.
  • Real targets verified: __tests__/**/*.json = 11 files (e.g. __tests__/fixtures/keyword-normalisation-cases.json, __tests__/fixtures/eval-baselines/*.baseline.json); e2e/fixtures/ = mixed TS (change-reports-fixture.ts, test-data-fixture.ts), JSON (embeddings.json), and a files/ subdir (non-text assets — must be extension-filtered); scripts/tests/fixtures/ = JSON snapshots + subdirs (extraction/, form-extraction/).
  • docs/ontology/ does not exist in the canonical repo (repo docs/ contains only extend-registry-provenance.md, reference/, reports/, testing/). The ontology markdown moved to the PRIVATE docs-site: ${KH_PRIVATE_DOCS_DIR}/src/content/docs/ontology/*.md (10+ files, e.g. 03-layer-vocabulary.md with rich YAML frontmatter: cv_name: layer_vocabulary, baseline_values: [{ key: sales_brief, ... }]). Scanning that path would violate inv 30 (reads scoped to the worktree) and inv 16 (no paths outside the corpus). Spec drift — decision needed.
  • supabase/types/database.types.ts exists (referenced as schema-canonical in CLAUDE.md; the directory is read-restricted in this session but the path is confirmed). Column needles there appear as PropertySignature identifiers (project_id: string) and union string literals — a plain string-literal walk would miss the keys, so TS-mode must match property-name identifiers too.
  • package.json:197 tinyglobby: ^0.2.16 and package.json:200 yaml: ^2.9.0 are declared devDependencies (dev-tool usage is fine — the tool itself runs via bun run ast-dataflow, package.json:50).
  • jsonc-parser: NOT in node_modules. stream-json (TECH.md’s suggestion): not declared. fast-glob: present only transitively — should not be imported directly (repo convention: direct file imports of declared deps only).
  • yaml is already imported directly in-repo: scripts/sync-intent-notes.ts:68 (import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'). yaml@2’s parseDocument exposes node range offsets, giving real line numbers for frontmatter matches without regex.
  • No direct tinyglobby importer exists yet; it is nevertheless a declared dependency and safe to import.
  • Fixture dirs are numbered per query under tools/ast-dataflow/__tests__/fixtures/ (01-callers … 18-latent-fixes); next free slots are 19-callees and 20-fixture-uses. Each dir carries its own minimal tsconfig.json (e.g. 01-callers/tsconfig.json: NodeNext, strict, include: ["./*.ts"]).
  • Test files instantiate the tool against the fixture dir as its own repo root: string-literal-uses.test.ts:21-28 (createProject({ tsConfigFilePath: resolve(FIXTURE_DIR, 'tsconfig.json'), repoRoot: FIXTURE_DIR })) — this pattern lets fixture-uses tests replicate the real repo layout (__tests__/, e2e/fixtures/, supabase/types/) inside the fixture dir.
  • Vitest picks up tools/**/*.test.{ts,tsx} (vitest.config.ts:23). Test doctrine: exact toHaveLength counts, toMatchObject, product-spec titles (string-literal-uses.test.ts:5-19; docs/reference/testing/test-philosophy.md).

Spec ambiguities in inv 11 needing decisions

Section titled “Spec ambiguities in inv 11 needing decisions”
  1. __tests__/**/*.ts flagged as fixture by path or convention” — “convention” is undefined. Observed repo conventions: /fixtures/ path segment (__tests__/fixtures/**, e2e/fixtures/**, scripts/tests/fixtures/**) and -fixture.ts basename suffix (e2e/fixtures/change-reports-fixture.ts, test-data-fixture.ts). No .fixture.ts dotted variant found.
  2. docs/ontology/*.md — target no longer exists in-repo (moved to private docs-site). Options: (a) keep the glob (matches nothing today, future-proof), (b) generalise to docs/**/*.md frontmatter, (c) env-gated scan of KH_PRIVATE_DOCS_DIR — (c) conflicts with inv 30/16.
  3. supabase/types/database.types.ts match semantics — inv 11 frames key/value in JSON terms; in a generated TS types file the “keys” are PropertySignature identifiers, not string literals. Needs the kind mapping made explicit (proposed: property names → key, string-literal union members → value).
  4. Whether test code string literals belong in fixture-uses — inv 11 says fixtures only; string-literal-uses already covers test code (OQ5, PRODUCT.md:418-425). Recommend strict fixture scoping to keep the two queries complementary, per the OQ5 rationale.

A) callees (PRODUCT inv 2) — implementation design

Section titled “A) callees (PRODUCT inv 2) — implementation design”
  • New: tools/ast-dataflow/queries/callees.ts
  • Edit: tools/ast-dataflow/types.ts (args + row types), tools/ast-dataflow/index.ts (exports), tools/ast-dataflow/cli.ts (case + catalogue entry)
  • New: tools/ast-dataflow/__tests__/callees.test.ts, tools/ast-dataflow/__tests__/fixtures/19-callees/
/** How the call is spelled at the call site. */
export type CalleeCallKind = 'call' | 'new' | 'super' | 'thisMethod';
export interface CalleesArgs {
symbol: string; // '<file>:<name>' — same shape as callers
limit?: number; // default 200
includeExternal?: boolean; // default false — see External callees below
}
/** BaseResult position = the CALL SITE (caller side). */
export interface CalleeResult extends BaseResult {
enclosing: string; // findEnclosing(callExpr) — names the nested closure host
calleeName: string; // rightmost identifier ('c' for a.b.c()), '<computed>' for obj[k](), '<anonymous>' for IIFEs
callKind: CalleeCallKind;
resolution: CallResolution; // reuse existing union (types.ts:3) — direct | aliased | destructured | computed-property | indirect (reexport unused here)
importAlias?: string; // when resolution === 'aliased'
/** Declared-side context. null file/line when unresolved or external. */
callee: { file: string | null; line: number | null };
external?: true; // declaration resolves outside the tsconfig corpus (node_modules / lib.d.ts)
}

confidence (inherited): 'exact' when the checker resolved a declaration (even for indirect resolution — the variable/parameter declaration is exactly resolved); 'indirect' only when no symbol resolves at all (matches TECH.md:261’s contract “resolution: ‘indirect’ when no symbol resolves” while keeping inv 15 semantics — resolution describes the call mechanism, confidence describes checker certainty).

  1. Resolve the subject. resolveSymbol(project, args.symbol, repoRoot) (resolve.ts:72). Map AstResolverError via buildErrorResponse exactly as callers.ts:54-69.
  2. Locate the body/bodies.
    • FunctionDeclaration / MethodDeclaration / FunctionExpression / ArrowFunctiongetBody().
    • VariableDeclaration → unwrap initializer through AsExpression / SatisfiesExpression / ParenthesizedExpression (same unwrap loop as resolve.ts:199-208) to an Arrow/FunctionExpression → its body.
    • ClassDeclaration → union of all method bodies + constructor + static blocks (each row’s enclosing disambiguates).
    • Anything else (interface, plain const, type alias) → structured error. Add ErrorKind 'not_callable' (additive enum change, allowed by inv 13) with hint “callees requires a function, method, arrow-function variable, or class; use references for non-callable symbols”.
    • Bodyless overload signatures / ambient declarations → 'not_callable' with an overload-specific hint.
  3. Enumerate call sites. body.getDescendantsOfKind(SyntaxKind.CallExpression) + getDescendantsOfKind(SyntaxKind.NewExpression). Descendants inherently include calls inside nested closures — no special casing (the PRODUCT requirement “include — they’re in the body” is free). Sort merged list by getStart() for stable output. Do NOT include TaggedTemplateExpression in V1 (not in inv 2’s wording; note as follow-up).
  4. Resolve each callee (model on flow-trace.ts:409-421, but symbol-first rather than type-first):
    const expr = callExpr.getExpression(); // unwrap NonNullExpression/ParenthesizedExpression first
    const nameNode = rightmostName(expr); // Identifier | PropertyAccess name | ElementAccess | Super/This handling
    let sym = nameNode?.getSymbol();
    const aliased = sym?.getAliasedSymbol(); // import bindings resolve to the original via alias symbol
    const decl = (aliased ?? sym)?.getDeclarations()[0];
    Fallback when sym is undefined: expr.getType().getSymbol() ?? getAliasSymbol() (the flow-trace path) — catches some inferred-callable cases the name-symbol path misses. Still nothing → confidence: 'indirect', callee: { file: null, line: null }, resolution: 'indirect'reported, never dropped (inv 2).
  5. Classify per callee-expression shape:
    • Identifier → declaration kind decides: FunctionDeclaration/MethodDeclaration → direct; ImportSpecifier with alias node → aliased + importAlias (reuse/extract callers.ts classifyResolution, generalised: move to resolve.ts so both queries share it); BindingElement (const { fn } = mod) → destructured; VariableDeclaration whose initializer is an identifier/fn-ref (not an inline arrow) → indirect (PRODUCT’s “variable holding fn ref”), callee = the variable declaration site; inline const f = () => {}direct (it IS the function); ParameterDeclaration (arrow params, callbacks) → indirect, callee = the parameter declaration site.
    • PropertyAccessExpression (a.b(), a.b.c(), namespace ns.fn()) → resolve the name node’s symbol (the checker resolves through inferred receiver types automatically — this answers “method calls on inferred types”: const s = makeSvc(); s.doThing() resolves doThing to the MethodDeclaration with no extra work). Chains only need the rightmost name; the receiver chain is irrelevant to resolution. Declaration = MethodDeclaration/PropertyDeclaration(arrow)/FunctionDeclaration → direct; function-typed PropertySignature on an interface → direct with callee = the signature site (or external if in .d.ts outside corpus).
    • super.m() → expression of the PropertyAccess is SuperKeyword; resolves to the base-class method → resolution: 'direct', callKind: 'super'.
    • this.m() → resolves to the containing class’s member → direct, callKind: 'thisMethod'.
    • ElementAccessExpression (obj[key]()): if the argument is a string/enum literal the checker may still resolve a symbol → classify computed-property with the resolved callee; unresolved (true dynamic) → computed-property + confidence: 'indirect' + calleeName: '<computed>', callee nulls. Reported, not dropped.
    • NewExpression → resolve the class symbol → callKind: 'new', resolution per the identifier rules above; callee line = the ClassDeclaration (or ConstructorDeclaration when present — prefer the constructor since that is what runs).
    • IIFE ((() => {})()) → callee is the inline function itself: calleeName: '<anonymous>', direct, callee = the arrow’s own position.
  6. External callees (design decision, flag to owner): declarations in node_modules/** or lib .d.ts (e.g. console.log, arr.map, supabase client methods) collide with inv 16 (“never emits paths outside the indexed corpus”). Default: exclude external rows (count them in totalEstimated? No — keep totalEstimated = matched rows; report an externalCount: N top-level field instead so nothing is silently invisible). With --include-external: emit rows with external: true, callee: { file: null, line: null }, and calleeName; never emit the node_modules path. This keeps the default output rename-sweep-sized while honouring “reported not dropped” via the count + opt-in flag.
  7. Emit rows with the callers.ts cap idiom (totalEstimated++ then if (rows.length >= limit) continue), file/line/column from callExpr.getStart() via sf.getLineAndColumnAtPos, enclosing = findEnclosing(callExpr), callee-side via decl.getSourceFile() + toRepoRelative + getLineAndColumnAtPos(decl.getStart()).

Case block modelled on callers (cli.ts:376-391): require --symbol <file:name> else exit 2 with example; parseLimit; --include-external boolean; emitResponse(response, pretty). Catalogue entry after callers: { name: 'callees', args: ['--symbol <file:name>', '--include-external', '--limit N', '--pretty'], example: 'bun run ast-dataflow callees --symbol lib/procurement/procurement-queries.ts:getQuestions' }. Update the default-case valid-query list (cli.ts:837) and the catalogue notes line (cli.ts:247). index.ts: export { callees } from './queries/callees' + type exports.

Fixture corpus — __tests__/fixtures/19-callees/

Section titled “Fixture corpus — __tests__/fixtures/19-callees/”

tsconfig.json (copy of 01-callers’), plus:

  • target.tsexport function subject() whose body contains: local helper(); imported util() (from lib.ts); aliased import { util2 as u2 } then u2() (from lib2.ts); const fnRef = helper; fnRef(); a callback param function subject(cb: () => void) with cb(); handlers[name]() dynamic; arr.map((x) => helper2(x)) (nested-closure inclusion + external .map); new Widget().
  • service.tsclass Service { doThing() {} } + makeSvc(): Service; target calls both svcTyped.doThing() (annotated receiver) and makeSvc().doThing() / const s = makeSvc(); s.doThing() (inferred receiver).
  • chain.tsapi.client.get() property chain where client is a typed object property.
  • class-fixture.tsclass Base { m() {} } class Sub extends Base { m() { super.m(); this.own(); } own() {} }.
  • destructured.tsconst { fn } = mod; export function usesDestructured() { fn(); }.
  • non-callable.tsexport const CONFIG = { a: 1 } (error-path fixture).

Test list (callees.test.ts — exact-count style per test-philosophy)

Section titled “Test list (callees.test.ts — exact-count style per test-philosophy)”
  1. Direct local + imported calls: exact row set with caller file/line/column, callee.file/line, resolution: 'direct', confidence: 'exact' (inv 23 hand-labelled equality).
  2. Aliased import call → resolution: 'aliased', importAlias: 'u2' (inv 25 analogue).
  3. Method on annotated receiver AND on inferred receiver both resolve to service.ts MethodDeclaration → direct.
  4. Property chain api.client.get() resolves rightmost name; callee = get’s declaration.
  5. fnRef()resolution: 'indirect', callee = the fnRef VariableDeclaration site, present in output (inv 2 “reported not dropped”).
  6. Callback param cb()indirect, callee = parameter declaration.
  7. handlers[name]()computed-property, confidence: 'indirect', calleeName: '<computed>'.
  8. Nested closure: helper2 row exists with enclosing: 'fn:subject' (arrow-in-callback resolution via findEnclosing).
  9. new Widget()callKind: 'new'.
  10. super.m() → callee = Base.m; this.own() → callee = Sub.own; callKinds super/thisMethod.
  11. External default-off: arr.map absent from rows, surfaced in externalCount; with includeExternal: true a row with external: true, callee.file: null appears and no absolute/node_modules path exists anywhere in output (inv 16 assertion).
  12. Class symbol as subject → rows from every method body.
  13. Non-callable symbol → structured error not_callable, exit 0 via CLI (inv 29); unknown file → unknown_file.
  14. Limit 2 on a ≥3-call body → truncated: true, totalEstimated correct (inv 14).

B) fixture-uses (PRODUCT inv 11) — implementation design

Section titled “B) fixture-uses (PRODUCT inv 11) — implementation design”
  • New: tools/ast-dataflow/queries/fixture-uses.ts
  • Edit: types.ts, index.ts, cli.ts
  • New: tools/ast-dataflow/__tests__/fixture-uses.test.ts, tools/ast-dataflow/__tests__/fixtures/20-fixture-uses/
export type FixtureUseKind = 'key' | 'value';
export type FixtureFileType = 'json' | 'ts' | 'md-frontmatter';
export interface FixtureUsesArgs {
needle: string; // exact string to find (column/table/magic literal)
kinds?: FixtureUseKind[]; // filter; default both
scope?: string; // optional comma-separated glob override of the default target set
limit?: number; // default 200
}
export interface FixtureUseResult extends BaseResult {
confidence: 'indirect'; // inv 15: fixture grep is heuristic — always indirect
kind: FixtureUseKind; // JSON/YAML/TS object-or-type KEY vs string VALUE (inv 11's split)
fileType: FixtureFileType;
/** Where in the structure: JSON path ('rows[2].project_id'), YAML path
* ('baseline_values[0].key'), or TS enclosing via findEnclosing. */
context: string;
}

Use tinyglobby (declared devDependency, package.json:197 — do NOT import fast-glob, which is only transitive). Default target set, rooted at repoRoot, evaluated fresh per invocation (no cache needed at this corpus size — dozens of files, well inside the 10 s heuristic budget of inv 19):

const DEFAULT_TARGETS = [
'__tests__/**/*.json',
'__tests__/**/fixtures/**/*.{ts,tsx}', // "fixture by path"
'__tests__/**/*-fixture.{ts,tsx}', // "fixture by convention" (see decision D1)
'e2e/fixtures/**/*.{ts,tsx,json,md}',
'scripts/tests/fixtures/**/*.{ts,tsx,json,md}',
'docs/ontology/*.md', // currently matches nothing — see decision D2
'supabase/types/database.types.ts',
];

ignore: ['**/node_modules/**']. Extension routing: .json → JSON mode; .ts/.tsx → TS mode; .md → frontmatter mode; anything else (e.g. e2e/fixtures/files/* binaries pulled in by a user --scope) silently skipped. --scope replaces the target list but keeps the same extension routing.

JSON mode — hand-rolled string-token lexer (no new dependency)

Section titled “JSON mode — hand-rolled string-token lexer (no new dependency)”

jsonc-parser and stream-json are not available; TECH.md:270 explicitly blesses “hand-rolled”. A full parser is unnecessary — key-vs-value is a lexical property in JSON:

  1. Precompute a line-offset table (indexOf('\n') sweep) for O(log n) offset→(line, column) conversion, 1-based (inv 13).
  2. Scan for " tokens; consume the string respecting \\ escapes; decode via JSON.parse(rawToken) so "project_id" matches needle project_id; require exact equality with the needle (substring hits excluded, mirroring string-literal-uses semantics).
  3. Classify: skip whitespace after the closing quote — next char :kind: 'key'; otherwise 'value'.
  4. Maintain a structural stack for context: push on {/[ (objects track last-seen key, arrays an index counter incremented on , at that depth) → emit rows[2].project_id-style paths. This is ~40 lines; if it proves fiddly, context degrades gracefully to '' without breaking the row schema — but the stack version is recommended since tests will pin it.
  5. Malformed JSON: do not throw — emit rows found up to the failure point; malformed fixture files are still greppable text. (Alternative: per-file parse_error error row; rejected because inv 29’s error envelope is per-query, not per-file, and partial results are more useful mid-refactor.)

TS mode — ad-hoc ts-morph parses, NOT the main project

Section titled “TS mode — ad-hoc ts-morph parses, NOT the main project”

Root tsconfig excludes scripts and supabase (tsconfig.json:66-74), so the passed-in project cannot see two of the six targets. Create one throwaway new Project({ skipAddingFilesFromTsConfig: true, compilerOptions: { allowJs: true, jsx: ts.JsxEmit.ReactJSX } }) and addSourceFileAtPath each discovered TS file. No type-checking is needed (everything is indirect confidence) — this is a pure parse, fast even for the large database.types.ts. Matching rules per file:

  • StringLiteral / NoSubstitutionTemplateLiteral with getLiteralValue() === needle: kind: 'value' — unless the literal is the name of a PropertyAssignment / PropertySignature / EnumMember (quoted keys), then 'key'.
  • PropertyAssignment name Identifier === needle (object-literal fixture rows: { project_id: '...' }) → 'key'.
  • PropertySignature name === needle (the database.types.ts case — project_id: string inside Tables<...> Row/Insert/Update type literals) → 'key'.
  • context = findEnclosing(node) (resolve.ts:226) — works unchanged on ad-hoc-parsed files.
  • Do NOT reuse string-literal-uses’ classifier: it intentionally drops object-literal values and non-call contexts (string-literal-uses.ts:116-119), which are precisely fixture-uses’ bread and butter. The two queries stay complementary (OQ5 rationale, PRODUCT.md:418-425).

Markdown frontmatter mode — yaml package (already a dep, already used in-repo)

Section titled “Markdown frontmatter mode — yaml package (already a dep, already used in-repo)”
  1. Read the file; frontmatter = content between a leading ---\n and the next \n--- (byte-offset tracked so positions map back to the md file).
  2. parseDocument(frontmatterText) from yaml (v2.9, same import style as scripts/sync-intent-notes.ts:68), then walk with the visit utility: Pair key scalars with value === needle'key'; scalar values (map values and sequence items) === needle'value'. Node range[0] + frontmatter start offset + the line-offset table → 1-based line/column. context = dotted YAML path from the visit ancestry (baseline_values[0].key).
  3. Body text is never scanned (inv 11 says frontmatter; body search remains cocoindex/grep territory per PRODUCT Non-goals).
  4. No frontmatter or YAML parse failure → skip the file silently (same partial-tolerance rationale as JSON mode).

Standard envelope (types.ts:52-65): sort rows file-then-line, apply kinds filter before the cap, cap at limit (default 200) with totalEstimated. Inv 14’s “distinct files first” truncation preference: iterate targets file-by-file and only thin multi-hit files after every file has contributed at least one row — implement as a two-pass emit (first hit per file, then remainder) — cheap here because the whole match set is in memory. Empty needleparse_error structured error (mirror string-literal-uses.ts:130-139). Invalid kinds entry → parse_error listing valid kinds.

bun run ast-dataflow fixture-uses --needle project_id [--kinds key,value] [--scope GLOB[,GLOB...]] [--limit N] [--pretty]

Case block modelled on string-literal-uses (cli.ts:619-640): require --needle (exit 2 + example otherwise); split/validate --kinds against ['key','value'] with the references-style invalid-value error (cli.ts:427-432); catalogue entry + valid-query list + notes update; index.ts exports.

Fixture corpus — __tests__/fixtures/20-fixture-uses/ (repoRoot = fixture dir, replicating real layout)

Section titled “Fixture corpus — __tests__/fixtures/20-fixture-uses/ (repoRoot = fixture dir, replicating real layout)”
tsconfig.json (unused by the query but keeps makeProject uniform)
__tests__/fixtures/rows.json needle 'project_id' as key (×2, one nested in an array of rows)
and as value (×1); decoy 'project_id_old' value; escaped "project_id" key
__tests__/fixtures/typed-rows.ts object literal with project_id key + 'project_id' string value + template literal
__tests__/unit/not-a-fixture.test.ts contains the needle — must NOT be scanned (convention gate)
e2e/fixtures/seed-fixture.ts needle as object key
e2e/fixtures/payload.json needle as value
e2e/fixtures/files/blob.bin skipped extension, no error
scripts/tests/fixtures/snapshot.json needle as key
docs/ontology/01-taxonomy.md frontmatter: needle as YAML key and as YAML value; needle also in the md BODY (must not match)
supabase/types/database.types.ts hand-written miniature: Row/Insert type literals with project_id PropertySignature + a string-literal union containing the needle
  1. JSON key vs value separation: exact counts per kind for rows.json; the value row and key rows carry correct kind (inv 11’s core split).
  2. Exact-match discipline: project_id_old decoy absent; escaped e key matches (decode-then-compare).
  3. Position fidelity: assert exact line/column (1-based) for one known key and one known value (inv 13).
  4. context paths: nested JSON row reports rows[1].project_id-style path; YAML row reports baseline_values[0].key-style path.
  5. kinds: ['key'] filter returns only key rows; invalid kind string → parse_error.
  6. Convention gate: needle in __tests__/unit/not-a-fixture.test.ts produces no row; -fixture.ts and /fixtures/ files do.
  7. database.types.ts: PropertySignature match → kind: 'key', fileType: 'ts'; union string literal → 'value'.
  8. Frontmatter: key + value rows from 01-taxonomy.md with fileType: 'md-frontmatter'; body occurrence absent.
  9. All target roots contribute (e2e + scripts/tests/fixtures rows present); blob.bin causes no row and no error.
  10. Every row: confidence: 'indirect' (inv 15), repo-root-relative POSIX path, no absolute paths (inv 16).
  11. Truncation: needle with >limit hits across ≥2 files → truncated: true, totalEstimated, and both files represented in the capped rows (inv 14 spatial preference).
  12. Empty needle → parse_error structured error, CLI exit 0 (inv 29).
  13. Malformed JSON fixture: rows before the syntax error still returned, no crash.

Decisions to put to the owner before implementation (spec ambiguities)

Section titled “Decisions to put to the owner before implementation (spec ambiguities)”
  • D1 — “fixture by convention” definition. Proposed: /fixtures/ path segment OR *-fixture.ts basename (both attested in-repo: e2e/fixtures/change-reports-fixture.ts, test-data-fixture.ts). Anything looser (e.g. any file under __tests__/ containing only data) is undecidable statically.
  • D2 — docs/ontology/*.md target is dead in-repo. The ontology docs live in the private docs-site (${KH_PRIVATE_DOCS_DIR}/src/content/docs/ontology/); scanning there violates inv 30 (worktree-scoped reads) and inv 16 (in-corpus paths). Proposed: keep the glob (harmless no-op, self-heals if the docs return), document the drift in PRODUCT.md, and rely on --scope for ad-hoc frontmatter sweeps elsewhere in-repo. Rejected alternative: env-gated private-docs scan — needs a PRODUCT amendment first.
  • D3 — database.types.ts kind mapping. Proposed: PropertySignature names → key, string-literal union members (enum-ish values) → value; document in the row-type JSDoc since inv 11 phrases the split in JSON terms only.
  • (A-side) D4 — external callees. Proposed: excluded by default + externalCount field + --include-external emitting callee.file: null rows; never emit node_modules paths (inv 16). Needs a one-line PRODUCT/TECH note since inv 2 says “every function/method called”.
  • (A-side) D5 — new ErrorKind not_callable. Additive enum extension to types.ts:43-50; consistent with the flow-trace precedent of query-specific kinds (ORIGIN_NOT_RESOLVABLE).

The two queries are independent (no shared new code beyond an optional extraction of classifyResolution from callers.ts into resolve.ts for callees) and can be built in parallel worktrees. Both should update ROADMAP.md’s DEFERRED table (lines 73-74) and the CLI catalogue notes on landing.