Skip to content

AST + Dataflow Tool — TECH

Status: DRAFT-S1 (kh-ast-S1 WP2 first cut) PRODUCT.md: ./PRODUCT.md (numbered behaviour invariants P-1…30 in sibling spec). Companion: docs/plans/phase-0-investigation/dw11-ast-data-flow-route.md (Approach-B decision; build estimate; ts-morph rationale).

KH has two existing code-intelligence surfaces:

  • cocoindex-code — embedding-based semantic text search (already ratified ADOPT in kh-workflow-orchestration-assessment.md §10). Strong at “which region of code talks about X”; cannot trace symbol resolution across files.
  • gitnexus — git-aware structural graph (40,988 nodes / 58,754 edges over KH). Framework-aware (HANDLES_ROUTE, FETCHES, QUERIES, etc.) and git-provenance-aware; not a full TypeScript type checker. Documented workflow per project-root CLAUDE.md “GitNexus — Code Intelligence” section.

Neither answers semantic TypeScript questions of the form “which functions read this column”, “every callsite of this exported function across the corpus”, “what string literal references would survive the type checker”. DW.11 (docs/plans/phase-0-investigation/dw11-ast-data-flow-route.md) established Approach B (ts-morph + LSP) as the path; the prompt that opened this track ratified building it now rather than after canonical pipeline. PRODUCT.md enumerates the desired query surface; this spec is the implementation plan.

FileWhy it matters
lib/supabase/safe.ts:43export async function sb<T>(…) — the smoke-test target for WP3. ~80 known call sites across app/, lib/, scripts/ per CLAUDE.md “Silent failures in Supabase calls” enforcement.
lib/ai/digest.tsFirst-use case 1 target — the digestschange_reports rename PR. Module exports the legacy Digest types and helper functions.
lib/bid/bid-state-machine.tsFirst-use case 2 target — part of bid_workspacesprocurement_workspaces + BID_STATESPROCUREMENT_WORKFLOW_STATES rename.
lib/mcp/tools/index.ts (1-50)The KH product MCP registers 58 end-user tools (RLS-bound, per-user Supabase clients). This tool is developer-facing — it lives outside this registration tree to avoid mixing security models.
tsconfig.json (root)Defines the indexed corpus boundary. ts-morph’s Project consumes this directly.
docs/generated/codebase-stats.mdLive corpus size: 768 Vitest files, 329 components, 193 API routes, plus lib/, scripts/*.ts. Latency budgets in PRODUCT.md §Behavior assume this scale (~1.4k production TS + ~770 tests).
.planning/codebase/STRUCTURE.mdAuthoritative directory layout — used to define the default index globs in §5.
docs/plans/phase-0-investigation/dw11-ast-data-flow-route.md §2.3DW.11’s 4.5-day estimate for a first cut (cited; this spec extends scope from lib/coverage/ alone to the 12-query surface).

Why ts-morph (not the raw TypeScript Compiler API, not tree-sitter)

Section titled “Why ts-morph (not the raw TypeScript Compiler API, not tree-sitter)”

DW.11 §2.2 settles this: ts-morph wraps typescript and gives us Project, SourceFile, getTypeChecker(), findReferences(), Symbol.getAliasedSymbol() — the exact primitives PRODUCT.md invariants 1-12 need. Tree-sitter provides syntax without semantics, so callers(symbol) could not be implemented at fidelity P-1 invariant 1 (“a call to a same-named foo() in a different module does not appear”) without inventing our own symbol table.

The Compiler API alone would work but force a 30% boilerplate tax on every query for SyntaxKind switches and node-walking. ts-morph’s declaration-walking API (getFunctions(), getClasses(), etc.) is the direct shape the queries need. Liam already ratified ts-morph + ast-grep for the project_id → workspace_id sweep (PRODUCT.md §11.3 item 5 cites “ts-morph + ast-grep”), so this aligns with prior architectural intent.

Installation surface (OQ1 — skill bridge)

Section titled “Installation surface (OQ1 — skill bridge)”

Decision: CLI is the primary and required surface. The library it calls is structured so an optional MCP wrapper can be added later without re-architecting, but shipping the MCP wrapper is not a goal of S1 and not a prerequisite for the tool being useful (PRODUCT.md invariant 28). This mirrors how KH already separates lib/ai/ (logic) from lib/mcp/tools/ai.ts (transport): the library is the substrate, the transports are optional consumers.

lib/ast-dataflow/ ← library; consumable by any caller
index.ts ← public exports (one fn per query)
types.ts ← shared types: Query, Result, Cache
project.ts ← ts-morph Project factory + cache wiring
cache.ts ← per-file-hash cache reader/writer
resolve.ts ← shared symbol-resolution helpers
queries/ ← target layout from query 3 onwards;
S1/S2 prototypes (`callers.ts`,
`importers.ts`) live flat at the
library root for now. Promoted into
`queries/` when the third query lands.
callers.ts ← invariant 1 (P-1)
callees.ts ← invariant 2 (P-2)
references.ts ← invariant 3 (P-3)
importers.ts ← invariant 4 (P-4)
column-reads.ts ← invariant 5 (P-5)
column-writes.ts ← invariant 6 (P-6)
type-evolution.ts ← invariant 7 (P-7)
reexport-chain.ts ← invariant 8 (P-8)
dead-exports.ts ← invariant 9 (P-9)
string-literal-uses.ts ← invariant 10 (P-10)
fixture-uses.ts ← invariant 11 (P-11)
enum-member-uses.ts ← invariant 12 (P-12)
scripts/ast-dataflow-cli.ts ← CLI entry. `bun run ast-dataflow`.
THE required surface.
(scripts/ast-dataflow-mcp.ts) ← Optional MCP wrapper. Not in S1.
Added only if/when CLI-from-agents
proves clumsy enough to justify
the per-prompt tool-budget cost.
(.claude/settings.local.json) ← Optional per-worktree registration
gated on the MCP wrapper existing.
__tests__/lib/ast-dataflow/ ← Vitest suite
fixtures/ ← curated ground-truth source files
callers.test.ts ← maps to invariants 1, 15, 23, 25
... ← one file per query
performance.test.ts ← invariants 18, 19
cache.test.ts ← invariants 20, 21, 22

package.json gains:

  • "ast-dataflow": "bun scripts/ast-dataflow-cli.ts" under scripts
  • "ts-morph": "^24.0.0" under dependencies (pinned to the latest major as of 15/05/2026; verify before merge).

.gitignore gains a single line: .ast-dataflow-cache/.

Why no MCP wrapper in S1? Agents already invoke CLI binaries via Bash tool calls and the cost is one Bash call vs one MCP tool call — roughly the same context budget. Until we observe the CLI being awkward enough for an agent to choose grep over it, MCP is an optimisation without proven payback. Skipping the MCP entry simplifies S1, removes two failure modes (server boot, stdio transport bugs), and keeps the tool’s blast radius read-only-filesystem rather than read-only-filesystem

  • open-socket.

Why not register inside lib/mcp/tools/? Even if we eventually do add an MCP wrapper, it would not live there. The KH product MCP at lib/mcp/tools/ is end-user facing with per-user RLS Supabase clients. The AST tool has zero database access, zero RLS, zero user authentication. Sharing the server boundary would let an end-user MCP client query our codebase’s symbol graph — unwanted.

The in-memory model is ts-morph wrapper objects plus a thin layer of plain-data result rows. We do not build a custom typed graph — the ts-morph project IS the graph, and re-building a parallel representation would duplicate ~80% of what ts-morph already provides.

lib/ast-dataflow/types.ts
export interface QueryContext {
project: Project; // ts-morph Project, scoped to tsconfig.json
cache: CacheHandle; // see §Cache
limit: number; // default 200; PRODUCT P-14
scope?: string; // optional glob restricting search range
}
export type Confidence = 'exact' | 'wildcard' | 'indirect';
export interface BaseResult {
file: string; // POSIX, repo-root-relative; P-16
line: number; // 1-based; P-13
column: number; // 1-based; P-13
confidence: Confidence; // P-15
}
export interface CallSiteResult extends BaseResult {
enclosing: string; // "moduleTopLevel" | "fn:foo" | "method:Bar.baz"
resolution: 'direct' | 'reexport' | 'aliased' | 'destructured'
| 'computed-property' | 'indirect';
importAlias?: string; // if `import { sb as sbClient } from …`
}
// Per-query result types follow the same shape — discriminated by query name.
export interface QueryResponse<R extends BaseResult> {
query: string;
args: Record<string, unknown>;
results: R[];
truncated: boolean; // P-14
totalEstimated?: number; // P-14
stale?: boolean; // P-22
staleFiles?: string[]; // P-22
durationMs: number; // for latency-tracking
}

Truncation semantics (P-14, id-375 A5). Over-limit result sets are cut by truncate.ts:truncateSpatial, not first-come: rows are grouped by file and drawn round-robin so every file with matches stays represented in the capped set, with totalEstimated exact (the full match count, no early break-outs). Two queries are exempt because their rows are ordered hops, not independent sites, and spatial re-ordering would break path continuity: flow-trace and reexport-chain (each documents the exemption in its file header). For reexport-chain, totalEstimated is a lower bound — chain walking stops at the cap, so deeper links may exist uncounted.

Each query implementation is a single async function:

lib/ast-dataflow/queries/callers.ts
export async function callers(
args: { symbol: string },
ctx: QueryContext
): Promise<QueryResponse<CallSiteResult>> { … }

The library’s public index.ts re-exports one function per query plus a single runQuery(name, args, ctx) dispatcher for CLI/MCP convenience. Each query owns its own arg validation (Zod schema) and is tested in isolation. The dispatcher is a thin switch — it does not centralise the schemas, because each query’s input shape is small and easier to read alongside its implementation.

SUPERSEDED — id-375 (2026-07-27), pending Liam ratification. The facts-cache below was never built, and measurement rejected it: 11 of 12 queries walk the live type-checked AST — no code path consumes extracted facts — so this design accelerates ~1 query for an honest 12-20 h build cost (specs/id-375-ast-dataflow-productionise/research/fixCache.md). Measured reality: Project construction ~5 s + first checker build 2.6-3.5 s dominate; a warm in-process re-query is 80-240 ms. The warm path is therefore a warm process: tools/ast-dataflow/mcp-server.ts (stdio, single ast_dataflow dispatch tool over the shared tools/ast-dataflow/dispatch.ts) holds the Project across calls with a per-call mtime+size staleness sweep (~8 ms over the full corpus): new files → addSourceFileAtPath, deleted → forget(), changed → refreshFromFileSystem(); every response carries meta: { refreshedFiles, addedFiles, removedFiles, staleFiles } (stale-loud, inv 22). Calls serialise through a promise chain (ts-morph is single-threaded); the CLI remains the always-available cold path (inv 21). Known cost: ~1.7 GB RSS per warm server. PRODUCT.md §Amendments A2-A3 carry the invariant rewording. The section below is retained as the historical design of record. skipFileDependencyResolution was evaluated and REJECTED: it silently drops the 60 dependency-resolved files (all of scripts/) from corpus-iterating queries.

Decision (historical): Per-file content hash, no git rev in the key.

The cache lives at .ast-dataflow-cache/ inside the worktree, gitignored (OQ4 also resolved here — worktree-local, never shared across the three concurrent KH worktrees). Structure:

.ast-dataflow-cache/
index.json ← version + tsconfig fingerprint
files/
{sha256-of-relative-path}.json ← per-file extracted facts

Each per-file entry stores: file SHA-256 of UTF-8 content, ts-morph extracted facts (declarations, exports, imports, call sites with raw resolution attempts), and a derivedAt timestamp.

Invalidation: On every invocation, we walk the corpus glob, compute content SHA-256 per file, look up the cache entry. Cache hit → load facts. Cache miss → re-extract via ts-morph and write the new entry. A top-level index.json records the tsconfig.json hash and the ts-morph package version; mismatch invalidates the whole cache (P-20).

Why no git rev in the key? Git-rev anchoring forces cache rebuilds on branch hops even when files are unchanged. Per-file content-hash keys give the same correctness (a file at the same content hash means the same AST regardless of how git got there) with strictly fewer rebuilds. The git rev is recorded in index.json for staleness debugging only (P-22 surfacing).

Why worktree-local? Per CLAUDE.md “Parallel Tracks”, three KH worktrees run concurrently. Sharing one cache would race on writes and allow an uncommitted-edit in worktree A to surface in queries from worktree B. Worktree-local caches cost a small disk multiplier (roughly 50-100 MB per worktree at full warmth for the current corpus) and trade that for zero cross-worktree contamination.

Each query maps to a small ts-morph idiom. The eight resolution-based queries (1, 2, 3, 4, 7, 8, 9, 12 per PRODUCT.md) lean on getTypeChecker() + findReferences(); the four heuristic queries (5, 6, 10, 11) walk the AST without trusting the type checker for final resolution but use it to filter false positives.

QueryCore ts-morph primitives
callersSymbol.findReferences() filtered to CallExpression ancestors. Alias detection via Identifier.getSymbol()?.getAliasedSymbol().
calleesFunctionDeclaration.getBody()?.getDescendantsOfKind(SyntaxKind.CallExpression)CallExpression.getExpression().getSymbol() for each. resolution: 'indirect' when no symbol resolves (P-2 indirect flag).
referencesSymbol.findReferences() directly, with each reference tagged by examining its parent node kind. Type-only refs identified via isTypeOnly() checks on import declarations (P-26).
importersProject.getSourceFiles() filtered by whether getImportDeclarations() includes a matching module specifier. Module-specifier resolution via the project’s module resolver so @/lib/x and ./x from different files resolve to the same target.
column-readsAST walk for CallExpression matching from(<table>).select(<…col…>). Explicit column matches on a typed client yield exact; .select('*') yields wildcard (column presence cannot be confirmed without runtime data); untyped client falls back to indirect. OQ3 resolved: accept untyped calls and downgrade — rejecting would produce false negatives in pre-migration code.
column-writesSame as reads but matching .insert/.update/.upsert(<obj>) plus object-property scans on the argument literal. One-hop spread expression chasing as PRODUCT.md P-6 promises (no transitive chase to keep latency budget achievable).
type-evolutionSymbol.findReferences() filtered to type-position references + ExportSpecifier + TypeAliasDeclaration.getType().getAliasSymbol().
reexport-chainWalk ExportSpecifier/ExportDeclaration chain via getSymbol()?.getDeclarations() until a non-export-from declaration is reached; record the chain.
dead-exportsFor each ExportedDeclaration in scope, call findReferences(); bucket results by test-file vs prod-file (path-pattern, see §Test scope).
string-literal-usesProject.getSourceFiles().forEach(sf => sf.getDescendantsOfKind(SyntaxKind.StringLiteral)) matching the needle exactly; report parent node kind. (OQ5 resolved: default-on for tests/fixtures; --exclude tests flag opts out.)
fixture-usesTwo-mode: TS fixtures use the AST walk above; JSON fixtures use a streaming JSON parser (stream-json or hand-rolled) that records object-key vs string-value matches. Limited to the fixture globs in §5.
enum-member-usesFor as const tuples (the KH idiom — see lib/validation/schemas.ts VALID_CONTENT_TYPES), resolve the index of the member then findReferences on the parent symbol, filtering each ref to those that access the same index via [N] or .member syntax. For real enum declarations, Symbol.findReferences() directly.

Status: Shipped (S7 R-WP6, three-WP sequence). Source: ROADMAP extension; sub-spec previously at flow-trace-TECH.md, folded into this parent TECH.md on 18/05/2026 (kh-ast-S9 Wave 1).

flow-trace is the most complex query in the ast-dataflow tool surface (ROADMAP R-WP6 — an extension beyond PRODUCT.md’s frozen 12-query surface). Where every other query answers a single structural question — “who calls this symbol?”, “which files import this module?” — flow-trace must walk an arbitrary-depth value graph, classifying each hop, detecting cycles, respecting a depth budget, and optionally crossing function boundaries.

Given an origin node — a VariableDeclaration, ParameterDeclaration, or BindingElement — flow-trace traces the value bound at that site forward through the AST: reassignments to new identifiers, destructuring (object and array), spread into other objects or arrays, passthrough as a function argument, return from a function to its caller, and termination at observable sinks (mutation of a structure, API call such as a Supabase chain, or a file/queue write). Each step is a “hop” with a classified kind, a 1-indexed hop number, a confidence tier, and a link back to the parent hop so consumers can reconstruct the tree. The walk is bounded by a configurable maxDepth (default 8) and a cycle-detection visited-set; when a branch exhausts its budget it emits a synthetic depthCutoff or cycleCutoff row so consumers can detect incompleteness. By default the walk is intra-function only; interFunction: true descends into resolved callees on argument hops, counting the descent against maxDepth. This query is distinct from gitnexus’s process-level execution flow (HANDLES_ROUTE, FETCHES) — gitnexus operates at the architectural-graph layer; flow-trace operates at the per-symbol, per-statement layer inside a single logical data path.

This query is a heuristic query (P-19 — 10 s P95 budget; confirmed by WP3 smoke: warm 5-hop ≈ 5 ms, warm 8-hop ≈ 4 ms — see §Performance baseline below).

interface FlowTraceArgs {
/** Repo-root-relative path to the file containing the origin node. */
originFile: string;
/** 1-based line number of the origin declaration. */
originLine: number;
/** 1-based column number of the origin declaration. */
originColumn: number;
/**
* Maximum number of hops per branch.
* Default: 8. Minimum: 1. Maximum: 20.
*/
maxDepth?: number;
/**
* When true, on an `argument` hop the walk descends into the resolved
* callee's parameter and continues. Counts against maxDepth.
* Default: false (intra-function only).
*/
interFunction?: boolean;
/** Maximum result rows (cap). Default: 200. */
limit?: number;
/** Exclude test files from the walk. Default: false. */
excludeTests?: boolean;
}

Errors are returned as structured QueryResponse failures using the existing buildErrorResponse() helper in resolve.ts. Two AstResolverError codes are defined for this query in the error.kind discriminated union in types.ts:

Error codeConditionRemediation hint
ORIGIN_NOT_RESOLVABLENo AST node is found at the given (file, line, column), or the resolved node is not a VariableDeclaration, ParameterDeclaration, or BindingElement.Check file path is repo-root-relative and line/column are 1-based.
ORIGIN_NOT_VALUE_PRODUCINGThe resolved node is a valid declaration kind but has no initialiser and no type that carries a value (e.g. a type-only alias).Only value-producing declarations can be traced; trace the callee’s parameter directly.
bun run ast-dataflow flow-trace \
--origin-file <repo-root-relative-path> \
--origin-line <N> \
--origin-column <N> \
[--max-depth <N>] \
[--inter-function] \
[--limit <N>] \
[--exclude-tests] \
[--json | --pretty]

All flags mirror existing CLI conventions in scripts/ast-dataflow-cli.ts. Unknown flags exit non-zero with a remediation hint (PRODUCT.md invariant 29).

The FlowTraceHopKind exact union (defined in types.ts):

type FlowTraceHopKind =
| 'assignment'
| 'destructure'
| 'argument'
| 'return'
| 'spread'
| 'mutation'
| 'apiCall'
| 'write';
KindWhen emittedExample
assignmentValue is bound to a new identifier via const/let/var or an assignment expression.const b = a; — hop from a to b.
destructureValue is unpacked from an object or array pattern into one or more new bindings.const { id } = user; — hop from user to id.
argumentValue is passed as an argument in a function call.doSomething(value) — hop from value to the call site.
returnValue is returned from a function; the hop target is the return statement.return data; — hop from data to the return node.
spreadValue is spread into an object or array literal.{ ...payload, extra } — hop from payload to the spread site.
mutationA mutable method is called on the value, producing a side effect.list.push(value) — terminal hop at the push call.
apiCallValue flows into a known API call (Supabase chain, fetch, axios, etc.).supabase.from('x').insert(payload) — terminal hop at the terminal method.
writeValue is written to a file, queue, or external channel.fs.writeFile(path, content) — terminal hop at the write call.

mutation, apiCall, and write are sink kinds: the walk does not continue past them (no child hops). OQ-FT2 resolved: apiCall emits at the terminal mutating call (.insert() not .from()), consistent with column-writes. If the same expression is reachable via multiple branches, each branch emits its own hop row (the parentHop field links them).

  1. Depth budget. Default maxDepth = 8; configurable via --max-depth (min 1, max 20). Each branch tracks its current depth independently. When a branch would generate a hop at depth maxDepth + 1, a synthetic terminal row is emitted instead:

    { "kind": "depthCutoff", "hop": N, "parentHop": N-1, "confidence": "exact",
    "file": "<last-known-file>", "line": <last-known-line>,
    "column": <last-known-column>, "enclosing": "<enclosing-fn>" }

    This lets consumers detect that the trace is incomplete without guessing.

  2. Cycle detection. A visited-set of (file, line, column) tuples is maintained per trace invocation (shared across all branches). Before emitting a hop, the walker checks whether (hop.file, hop.line, hop.column) is already in the visited-set. If it is, a synthetic terminal row is emitted:

    { "kind": "cycleCutoff", "hop": N, "parentHop": N-1, "confidence": "exact",
    "file": "<cycle-target-file>", "line": <cycle-target-line>,
    "column": <cycle-target-column>, "enclosing": "<enclosing-fn>" }

    Recursive function calls are therefore treated as cycles, not as infinite-descent opportunities.

  3. Sink termination. Hops of kind mutation, apiCall, or write are terminal; no child hops are emitted for them.

  4. Unresolvable node. If the walker cannot statically resolve the next hop (e.g. a dynamic property access that cannot be narrowed), it emits the hop with confidence: 'indirect' and does not descend further from that branch.

Maps to the existing Confidence union ('exact' | 'wildcard' | 'indirect') defined in types.ts. Each hop carries its own confidence field.

ConfidenceWhen assignedExample
exactDirect identifier reassignment to a new binding, or destructuring with a literal string key. The type checker resolves the link statically.const b = a;, const { id } = user;
wildcardValue flows through a spread expression (...payload) where the consumer is an object or array that may or may not carry the value’s identity.const merged = { ...payload, extra };
indirectDynamic property access (obj[k]), computed property names, or any hop the walker cannot resolve statically — including unresolvable identifiers after the one-hop scope check fails.const val = obj[key];

Confidence on a synthetic depthCutoff / cycleCutoff row is 'exact' (the cutoff itself is deterministic; there is no uncertainty about whether the budget was reached, only about what lies beyond it).

Default: interFunction = false — the walk is confined to the body of the enclosing function. On an argument hop, the walk records the call site but does not descend into the callee’s body.

When interFunction = true:

  1. On an argument hop, the walker resolves the callee’s declaration via the ts-morph type checker (callExpr.getExpression().getType().getSymbol()).
  2. If the callee is resolvable and has a body, the corresponding ParameterDeclaration for the passed argument is located by index.
  3. A child hop is emitted for the ParameterDeclaration (kind: argument, confidence: 'exact' if typed, 'indirect' if not), and the walk continues from the callee’s parameter as a new origin within the callee’s body.
  4. The callee descent counts against the shared maxDepth counter for the branch.
  5. The callee’s function body is added to the visited-set’s scope so that recursive calls from the callee back to itself are treated as cycles.
  6. If the callee cannot be resolved (dynamic dispatch, computed property access, callback registered at runtime), the argument hop is emitted with confidence: 'indirect' and no descent occurs.

OQ-FT3 resolved: enclosing on a cross-function hop reports the callee’s enclosing function, not the caller’s. Consistent with how references reports enclosing — always the function that lexically contains the node in its body.

Cross-file inter-function propagation is supported: the callee may live in a different file. The file field on the child hop reflects the callee’s file, not the caller’s.

OQ-FT4 (deferred): When interFunction: true and a return hop is encountered inside a callee, the walk does not continue at the caller’s call site (return-hop continuation). WP3 inter-function descent terminates at a sink (apiCall / write / mutation) or at a return hop, which is the correct terminal for the current surface. Return-hop continuation significantly increases walk complexity (requires tracking call stacks). Deferred to a follow-up WP if a concrete consumer need surfaces.

Per-row JSONL output, consistent with every other query in the library (PRODUCT.md invariants 13–16). One row per hop.

interface FlowTraceRow {
/** 1-indexed hop number within the full trace (depth-first pre-order). */
hop: number;
/** Hop index of the upstream hop that produced this one. Absent for hop 1 (origin row). */
parentHop?: number;
/** Hop classification. */
kind: FlowTraceHopKind;
/** Repo-root-relative path to the file where this hop occurs. */
file: string;
/** 1-based line of the hop node. */
line: number;
/** 1-based column of the hop node. */
column: number;
/** Confidence of this hop's resolution. */
confidence: 'exact' | 'wildcard' | 'indirect';
/** Name of the enclosing function / method / 'module top-level'. */
enclosing: string;
/** The origin declaration (same for every row in the trace). */
origin: {
file: string;
line: number;
column: number;
/** Identifier name at the origin site. */
symbol: string;
};
}

Hop 1 is always the origin itself — a virtual assignment hop at depth 0 that names the declared symbol. This provides a self-contained row for consumers that want to display “trace starting from X” without a separate look-up. The origin row (hop 1) is at depth 0; subsequent hops increment depth. depthCutoff fires when depth would reach maxDepth + 1.

OQ-FT1 defers a tree-shaped Shape B until a concrete consumer (e.g. a UI overlay or an orchestrator sub-agent that needs to reconstruct the path to a specific sink) requests it. The parentHop field on every row already encodes the full tree; a consumer can reconstruct it with jq -s 'group_by(.parentHop)' without any change to the tool’s output format.

The FlowTraceRow[] array is nested in the standard QueryResponse<FlowTraceRow> envelope (existing types.ts generic), with truncated, totalEstimated, and durationMs fields as per every other query.

  • Origin resolution: Project.getSourceFile() + SourceFile.getDescendantAtPos() → walk ancestors for VariableDeclaration / Parameter / BindingElement.
  • Intra-function walk: Node.getDescendantsOfKind(SyntaxKind.Identifier) in enclosing scope; parent-kind switch classifies each use site.
  • Inter-function descent (when interFunction: true): CallExpression.getExpression().getType().getSymbol().getDeclarations() → resolve callee; getParameters()[argIndex] for the matching ParameterDeclaration.
  • Cycle detection: visited-set of (file, line, column) tuples shared across all branches; BinaryExpression re-assignments check the LHS declaration position via Identifier.getSymbol().getDeclarations()[0].
  • Depth cutoff: depth >= maxDepth guard emits synthetic depthCutoff row.

PRODUCT.md invariant 19 (P-19) caps heuristic queries at 10 s warm P95. flow-trace’s per-hop cost is dominated by ts-morph symbol resolution (a type-checker call per hop) rather than fan-out across the corpus. The worst case — maxDepth = 8 + interFunction = true — involves up to 8 symbol-resolution calls per branch, branching at each hop, so an unbounded tree could generate O(branchFactor^8) ts-morph calls. In practice, most values have low branch factor (1–3 hops to a sink).

OQ-FT5 resolved (kh-ast-S7 WP3): heuristic confirmed. Warm-time measurements:

  • 5-hop trace (perf-5hop.ts, intra-function): ≈ 5 ms
  • 8-hop trace (perf-8hop.ts, default maxDepth=8): ≈ 4 ms
  • Real KH smoke (lib/bid/bid-queries.ts bidIds trace, inter-function): ≈ 5 ms warm

Cold-start (full KH corpus load): ≈ 2 s. Both synthetic benchmarks and real KH smoke are well within the 10 s P-19 heuristic budget. Classification remains heuristic per spec. Default maxDepth stays at 8.

If a future measurement exceeds 10 s, ship with maxDepth = 6 as the default and open a backlog item to profile the hot path before revisiting the default. Do not silently raise the budget.

All tests live in __tests__/lib/ast-dataflow/flow-trace.test.ts. Fixtures live in __tests__/lib/ast-dataflow/fixtures/14-flow-trace/. Follow docs/reference/test-philosophy.md — use toHaveLength(N), expect.arrayContaining([expect.objectContaining({...})]), never find() + toBeDefined().

#NameFixture fileHop sequenceEdge case
1Assignment chain happy path01-assignment-chain.tsassignment × 3 (A→B→C→sink)Basic linear chain; verifies parentHop linkage and 1-indexed hop numbering.
2Object destructure (literal key)02-destructure-object.tsassignment then destructure{ id } = user — confidence exact; emits one row per destructured binding.
3Array destructure02-destructure-array.tsassignment then destructureconst [first] = list — confidence exact; index-keyed.
4Spread (wildcard hop)03-spread.tsassignment then spread then terminates{ ...payload } — confidence wildcard; no further descent from spread (unresolvable target identity).
5Argument passthrough (intra-function)04-argument-intra.tsassignment then argument (no descent)With interFunction: false (default), argument hop is terminal at the call site.
6Return propagation04-return.tsassignment then returnValue returned from a function; hop target is the return statement; walk ends there (intra-function).
7Mutation sink (.push)05-mutation.tsassignment then mutationlist.push(value)mutation hop is terminal; toHaveLength(2) (origin + mutation).
8apiCall sink (Supabase chain)06-api-call.tsassignment then apiCallsupabase.from('x').insert(payload) — terminal apiCall at .insert(); confidence exact for typed client.
9Write sink (fs)07-write.tsassignment then writefs.writeFile(path, content) — terminal write hop; confirms sink classification for file I/O.
10Cycle detection (not maxDepth)08-cycle.tsassignment × 2 then cycleCutoffFixture defines a = b; b = a; — walker detects cycle at step 3 and emits cycleCutoff; toHaveLength(3).
11Max-depth cutoff (depth=2, real chain=4)09-depth-cutoff.tsassignment × 2 then depthCutoffInvoked with maxDepth: 2; fixture has 4 real hops; expects depthCutoff at hop 3 and toHaveLength(3).
12Indirect tier (dynamic property access)10-indirect.tsassignment then indirect-confidence hop then terminatesconst val = obj[key] — emits indirect confidence; no further descent.
13Inter-function descent (interFunction: true)11-inter-function.tsassignment (origin) → argument (call site) → argument (callee param) → apiCall (sink in callee)With interFunction: true; verifies descent into callee, correct file on child hops, parentHop linkage across the function boundary.
14Origin not resolvablen/a (invalid coords)n/aExpects error.kind === 'ORIGIN_NOT_RESOLVABLE' in the QueryResponse.
15Origin not value-producing12-type-only.tsn/aA type Foo = ... alias at the given coords — expects error.kind === 'ORIGIN_NOT_VALUE_PRODUCING'.
16Truncation at row cap01-assignment-chain.ts + limit: 11 row returnedtruncated: true in envelope; totalEstimated >= 2.
Terminal window
bun scripts/ast-dataflow-cli.ts flow-trace \
--origin-file lib/bid/bid-queries.ts \
--origin-line 70 \
--origin-column 9 \
[--max-depth 8] \
[--inter-function] \
[--limit 200] \
[--exclude-tests] \
[--pretty]
$ bun scripts/ast-dataflow-cli.ts flow-trace \
--origin-file lib/bid/bid-queries.ts \
--origin-line 70 --origin-column 9 \
--inter-function
{
"query": "flow-trace",
"results": 1 row,
"durationMs": 5,
"truncated": false
}
Wall time: ~2 s (cold start, full KH corpus).

Warm-time measurements on synthetic fixtures:

  • 5-hop (perf-5hop.ts, intra-function): ≈ 5 ms
  • 8-hop (perf-8hop.ts, default maxDepth=8): ≈ 4 ms

Both within the 10 s P-19 heuristic budget. OQ-FT5 resolved: heuristic confirmed.

IDDescriptionStatus
OQ-FT1Tree-shape Shape B output. CLI does not support --format tree. parentHop on each row encodes the full tree; jq -s 'group_by(.parentHop)' reconstructs it client-side.Deferred — emit tree only when a concrete consumer requests it.
OQ-FT4return hop continuation when interFunction: true. When a return hop is encountered inside a callee, the walk does not continue at the caller’s call site. Return-hop continuation requires tracking call stacks.Deferred — add as a follow-up WP when a consumer requires full call-stack value propagation.

Decision: None. ts-morph alone for V1.

ts-morph wraps the same TypeScript compiler API and the same type checker an LSP would consult; an LSP would add a daemon + IPC round trip without adding semantic capability. The CLAUDE.md using-typescript-lsp plugin is for inline use inside Claude Code’s edit loop; this tool is a separate process with different cost characteristics. If a future query (e.g. dynamic-import resolution against bundler aliases) genuinely needs an LSP, we add a single lsp-fallback.ts query helper at that point and keep the rest of the implementation LSP-free.

PRODUCT.md invariants 17, 18, 19, 20, 21, 22 cover the lifecycle. Concrete mechanics:

  1. Cold-start (no .ast-dataflow-cache/): Project is constructed with tsconfig.json. We do not call getTypeChecker() until a query that needs it does. Per-file extraction runs lazily, in parallel up to os.cpus().length / 2. Target: <30 s cold for the current corpus.
  2. Warm-cache: index.json is read; per-file hashes are compared against the on-disk files. Hits are loaded into a Map<string, FileFacts> in O(file count) without invoking ts-morph. Misses are queued and ts-morph processes them.
  3. Concurrent invocation safety: Two concurrent CLI invocations from the same worktree share one cache directory; cache writes use an atomic rename pattern (write to .tmp → rename) so half-written entries are never read. The MCP server (Phase 2) holds the Project in memory across queries; the CLI rebuilds per invocation but reuses cache on disk.
  4. Cache size bound: We do not cap or evict. The corpus is bounded by tsconfig.json and the cache is bounded by the corpus. bun run ast-dataflow --reset-cache removes the directory; a CI step (added once the tool is stable) can prune on main merge.

The tool is added to the global skill registry via a small peer-skill file at ~/.agents/skills/ast-dataflow/SKILL.md. The skill points at bun run ast-dataflow <query> from the worktree root. KH agents discover it through the standard using-agent-skills mechanism. The skill file is authored after WP3 lands so the documented examples reflect the actual CLI surface.

Optional future MCP registration. If a later session adds an MCP wrapper (PRODUCT.md invariant 28 leaves this open), the registration shape would be a per-worktree .claude/settings.local.json entry:

{
"mcpServers": {
"ast-dataflow": {
"command": "bun",
"args": ["scripts/ast-dataflow-mcp.ts"],
"cwd": "${workspaceFolder}"
}
}
}

S1 does not ship this. The CLI is the entire surface for now and is considered complete as such.

Terminal window
# Invariant 1 — direct callers of sb()
$ bun run ast-dataflow callers --symbol 'lib/supabase/safe.ts:sb' --limit 50
{
"query": "callers",
"args": { "symbol": "lib/supabase/safe.ts:sb", "limit": 50 },
"results": [
{
"file": "lib/bid/bid-queries.ts",
"line": 87,
"column": 12,
"enclosing": "fn:getBidQuestions",
"resolution": "direct",
"confidence": "exact"
},
],
"truncated": false,
"durationMs": 412
}
# Invariant 4 — importers of @/lib/ai/change-reports (change_reports rename target)
$ bun run ast-dataflow importers --module '@/lib/ai/change-reports' --pretty
# Invariant 10 — string-literal uses of 'project_id'
$ bun run ast-dataflow string-literal-uses --needle 'project_id' \
--scope 'app/**,lib/**' --json

CLI flags shared across all queries: --limit N, --json (default on for CLI; off for --pretty human view), --scope GLOB[,GLOB…], --reset-cache, --corpus-info (dumps the resolved file set + hash counts, useful for debugging staleness).

Each query may define query-specific flags. We document them in the skill’s auto-generated help (bun run ast-dataflow <query> --help).

Tests currently live at __tests__/lib/ast-dataflow/ (KH’s flat layout), not alongside the source files in lib/ast-dataflow/queries/. This is intentional and reflects KH’s project-wide convention — it is not a gap to fix now.

Future direction (deferred): when ast-dataflow extracts to its own published package, adopt a sibling-of-source layout: lib/ast-dataflow/queries/<name>.ts + lib/ast-dataflow/queries/<name>.test.ts. That layout is standard for standalone npm packages and aids portability. Until the package extraction happens, test files do NOT migrate — changing the layout ahead of extraction creates churn without payback.

Decision rationale recorded in investigations/S10-wave-0-synthesis.md §3.4 (kh-ast-S10 Wave 3 synthesis).

The Vitest suite under __tests__/lib/ast-dataflow/ is the canonical verification surface; every PRODUCT.md invariant maps to one or more tests below. Ground-truth fixtures live in __tests__/lib/ast-dataflow/fixtures/ — small hand-curated TS files exercising specific symbol-resolution patterns (re-exports, aliased imports, computed properties, type-only imports, indirect calls, etc.).

PRODUCT invariantVerification
1 callerscallers.test.ts — fixture 01-direct-callers.ts + 02-aliased-import.ts + 03-reexport.ts. Assert result-set equality to hand-labelled expected rows.
2 calleescallees.test.ts — fixture 04-direct-callees.ts + 05-method-call.ts + 06-indirect-call.ts. Assert indirect calls flagged.
3 referencesreferences.test.ts — fixture set covers type / runtime / read / write / jsx / reexport / typeOnly tags.
4 importersimporters.test.ts — fixture set covers named, default, namespace, type-only, reexport, aliased, unused.
5 column-readscolumn-reads.test.ts — fixtures using typed Supabase client (exact) + .select('*') (wildcard) + untyped from(string) (indirect). Hits include .select('a,b'), .select('*'), and .rpc() payload field references. Backlog item AST-S3-O2 resolved.
6 column-writesMirror of 5 for .insert/.update/.upsert. Spread-one-hop fixture verifies one-hop chase.
7 type-evolutiontype-evolution.test.ts — interface declared in a.ts, re-exported via barrel b.ts, aliased in c.ts, intersection-extended in d.ts. Result set is all four sites plus generic-instantiation site.
8 reexport-chainreexport-chain.test.ts — barrel chain index.ts → category.ts → impl.ts. Result is full chain in order.
9 dead-exportsdead-exports.test.ts — fixture file with three exports: one used in prod, one used only in tests, one unused. Result correctly buckets all three.
10 string-literal-usesstring-literal-uses.test.ts — needle 'project_id' appears in: function argument, object literal value, JSX prop, comment (excluded), type literal, identifier (excluded).
11 fixture-usesfixture-uses.test.ts — JSON fixture with the needle as a key vs as a value; TS fixture with the needle in a typed object.
12 enum-member-usesenum-member-uses.test.tsas const tuple with one member used + one member unused.
13 result schemaschema.test.ts — Zod parse every query’s output against its declared response schema.
14 truncationtruncation.test.ts — query with 250 hits + limit=200 returns truncated + totalEstimated.
15 confidenceEmbedded in queries 1-12 above; every fixture assertion includes the confidence tier.
16 result pathspaths.test.ts — every result row’s file field passes isRepoRootRelativePosix(x).
17 worktree-portabilityworktree.test.ts — fixture run from a temporary directory copy succeeds (proves no absolute-path leakage).
18 cold-start latencyperformance.test.ts cold-start — full corpus cold scan completes <30 s on the CI runner. Uses vi.useFakeTimers() is NOT appropriate here; real timers and a generous CI tolerance (e.g. assert <60 s on CI, <30 s on dev).
19 warm-cache latencyperformance.test.ts warm-callers, warm-importers, etc. — each query <5 s P95 over 20 runs after first run. Tolerance per query class (5 s for resolution-based, 10 s for heuristic).
20 per-file invalidationcache.test.ts single-file-edit — edit one fixture file’s body, assert only that file’s cache entry is rewritten.
21 no daemon blockcache.test.ts concurrent-invocation — fork two child processes hitting the same cache; both complete; no deadlock.
22 stale-loudcache.test.ts stale-detection — delete a referenced file mid-query; result includes stale: true and the file in staleFiles.
23 ts-morph parityparity.test.ts — for each resolution-based query, the tool’s result set equals a direct ts-morph implementation on the same fixture (the test imports ts-morph itself as the oracle).
24 exact-tier no-FNEmbedded in 5, 6, 10, 11 above — assertions test the exact confidence set is a superset of the hand-labelled exact-truth set.
25 alias referencesaliases.test.tsimport { sb as sbClient } is included in callers('sb') with importAlias: 'sbClient'.
26 type-only importsreferences.test.ts type-only-import case — flagged as typeReference, not read.
27 CLI argument shapecli.test.ts — spawn bun scripts/ast-dataflow-cli.ts with various inputs; assert exit codes and stdout shapes.
28 MCP optionalN/A for S1 — CLI is the only required surface. Test row reactivates only if an MCP wrapper is later added.
29 structured errorserrors.test.ts — query against non-existent file, malformed symbol, ambiguous symbol; each returns the documented error.kind shape with exit 0.
30 read-only filesystemsafety.test.ts — run a sequence of queries inside a fixture worktree; assert (a) no writes outside .ast-dataflow-cache/, (b) no network sockets opened. The latter uses Node inspector or a Vitest fixture that hooks net.Socket.

The smoke test mandated by the kick-off prompt:

Terminal window
bun run ast-dataflow callers --symbol 'lib/supabase/safe.ts:sb'

Actual result on 15/05/2026 against main HEAD 0b317cba is recorded in §Validation below.

WP3 prototype run on 15/05/2026 against this branch’s working tree.

$ bun run ast-dataflow callers --symbol "lib/supabase/safe.ts:sb" --limit 1000
{
query: "callers",
results: 153 rows,
truncated: false,
durationMs: 790 (ts-morph internal),
}
Wall time: 3.89 s (cold start, no cache yet).
MetricObservedPRODUCT.md target
Result count153— (no spec — sanity-only)
Distinct files64
confidence: exact share153/153every resolution-based row should be exact (P-15) ✓
resolution: direct share153/153aliased imports would surface as aliased; KH apparently has none for sb
Aliased imports found0n/a (KH has zero sb aliases — clean import discipline)
enclosing: <anonymous> share52/153not a PRODUCT.md invariant; see “What surprised”
Top dir countslib/ 80, app/ 63, __tests__/ 10
Cold-start wall time3.89 s<30 s (P-18) ✓
ts-morph query time790 ms<5 s P95 warm (P-19); cold here so passes by a large margin ✓

__tests__/lib/ast-dataflow/callers.test.ts — 6/6 pass in 1.42 s. Covers PRODUCT.md invariants 1 (callers), 15 (confidence), 16 (paths), 25 (alias references), 29 (structured errors).

  • ts-morph’s findReferences() correctly resolved cross-file callers including across re-export aliases (no false negatives in fixtures, no false positives in real corpus’s 153 hits).
  • Cold-start at 3.89 s wall on the full KH tsconfig.json is well inside the 30 s budget and would meet the warm-cache 5 s budget on its first warm run too (no cache layer needed for this query at this corpus size).
  • The CLI dispatcher pattern is small and clean — adding the next query is purely a new file under lib/ast-dataflow/queries/ plus three lines in scripts/ast-dataflow-cli.ts.
  • 52/153 anonymous-fn enclosings. Many KH sb() call sites live inside arrow functions inside higher-order callbacks (map/then/Promise.all callbacks, useEffect deps, object-literal methods). The naive findEnclosing walks past ArrowFunction → VariableDeclaration but not through PropertyAssignment or CallExpression. For agent ergonomics — the orchestrator wants to know “which API route” or “which exported helper” calls sb — the enclosing-name resolver should walk up to the nearest named host declaration (an exported function, an exported variable, a top-level default-export, a route handler GET/POST/PATCH). Tracked as a WP4 backlog item; not blocking.
  • Zero aliased imports for sb. Surprising clean signal — confirms the KH import-direct discipline (CLAUDE.md “No barrel re-exports”) is effective for at least this canonical symbol.
  • No warm-cache needed at this corpus size for callers. 790 ms ts-morph time means even repeated invocations stay under the warm budget without per-file disk cache. The cache layer is still useful for the heuristic queries (which fan out across the corpus rather than starting from a resolved symbol) but is not a prerequisite for shipping the resolution-based queries.

Smoke test — digests rename importer query (S2 WP2)

Section titled “Smoke test — digests rename importer query (S2 WP2)”

Run on 15/05/2026 against ast-dataflow-tooling HEAD, targeting the module that would need to be renamed in the digests → change_reports canonical-pipeline collapse: @/lib/ai/change-reports.

$ bun run ast-dataflow importers --module '@/lib/ai/change-reports'
{
"query": "importers",
"results": 3 rows,
"truncated": false,
"durationMs": 832
}
Wall time: 2.80 s (cold start, no cache).
FileimportStyleunusedisReexportOnly
app/api/change-reports/generate/route.tsnamedfalsefalse
__tests__/lib/ai/digest.test.tsnamedfalsefalse
__tests__/lib/ai/digest-cost-guard.test.tsnamedfalsefalse

AST count vs grep count: git grep -lE "from '@/lib/ai/change-reports'" returns 4 files — 3 TS source files plus the docs/continuation-prompts/*.md documentation file. The AST tool correctly returns 3 (only TS import declarations), demonstrating zero false positives from documentation text.

What surprised: app/digest/page.tsx does NOT statically import @/lib/ai/change-reports. The UI page issues a fetch call to the API route, which in turn imports the module. This is correct architecture and the AST tool faithfully reflects it — the blast radius for renaming lib/ai/digest.ts is the API route plus its two test files, not the page. Similarly, no cron entry imports the digest module directly; digest generation is user-triggered via the API route, not scheduled. The vi.mock('@/lib/ai/change-reports') call in __tests__/api/digest-generate.test.ts is a dynamic mock call, not an import declaration, and is correctly excluded — the test mocks the module rather than importing it statically. Any rename must also update that vi.mock string literal, which the planned string-literal-uses query (S3 backlog) would surface.

Warm-cache latency: Wall time 2.80 s on cold start is well inside the 5 s P95 warm budget (P-19). The importers query walks the full corpus (rather than starting from a single resolved symbol as callers does), so it is slightly slower, but still comfortably within budget at the current corpus scale.

Smoke test — project_id → workspace_id rename references (S3 WP1)

Section titled “Smoke test — project_id → workspace_id rename references (S3 WP1)”

Run on 15/05/2026 against ast-dataflow-tooling HEAD (a1e7312e). These two smokes are the canonical-pipeline-rename probe invocations from PRODUCT.md §First use cases case 2.

Symbol 1: types/bid.ts:BidState

$ bun run ast-dataflow references --symbol 'types/bid.ts:BidState'
{
"query": "references",
"results": 82 rows,
"truncated": false,
"durationMs": 717
}
Wall time: ~3.5 s (cold start).
KindCount
typeReference59
typeOnly17
read5
reexport1
Total82

Acceptance criterion met: ≥ 1 row with kind: 'typeReference' ✓ (59 rows).

Symbol 2: BIDS_PROJECT_ID — path substitution

BIDS_PROJECT_ID does not exist in the ast-dataflow-tooling worktree (nor on main as of S3 — the constant has not been introduced in the canonical-pipeline prep yet). Substituted with the nearest structurally equivalent constant: lib/intelligence/types.ts:PIPELINE_SYSTEM_USER_ID (a UUID string constant exported and read across ~13 production files).

$ bun run ast-dataflow references \
--symbol 'lib/intelligence/types.ts:PIPELINE_SYSTEM_USER_ID'
{
"query": "references",
"results": 14 rows,
"truncated": false,
"durationMs": 695
}
Wall time: ~3.4 s (cold start).
KindCount
read14
Total14

Acceptance criterion met: ≥ 1 row with kind: 'read' ✓ (14 rows). All 14 are read — the constant is never written to or re-exported after declaration. This is the expected classification for a const UUID used directly as an argument or object-property value.

Classification accuracy spot-check (BidState):

  • lib/bid/bid-state-machine.ts:5reexport ✓ (export { BidState })
  • types/bid.ts:28typeReference ✓ (status: BidState field)
  • lib/bid/bid-state-machine.ts:1typeOnly ✓ (import type { BidState })
  • app/api/bids/[id]/questions/extract/route.ts:18read ✓ (runtime spread ...BID_STATES where BidState is the element type bound)

What the smoke tells us: The references query correctly classifies all six kinds on production KH source. For the 44-file project_id → workspace_id sweep, references will surface: every type annotation site (typeReference), every import type site (typeOnly), every re-export barrel (reexport), and every runtime read of the constant (read). Combined with column-reads (S3 WP2), this gives complete coverage of the rename blast radius.

  • findEnclosing walks too few node kinds. Extend to surface a meaningful host name in the 52 anonymous cases (route handler, exported helper, exported component). Captured as TECH.md follow-up.
  • Error reporting on malformed inputs. Currently throws synchronous Error. PRODUCT.md invariant 29 wants a structured error.kind shape with exit 0 from the CLI. Done S3 WP3 — see §Validation “Error contract migration (S3 WP3)” below.
  • CLI script is in scripts/ which is ESLint-ignored. Lint passed with a “file ignored” warning. We can either move the script under a non-ignored path or document the ignore is intentional (same as other scripts/*.ts entries). Decision deferred.
  • tsconfig.json includes __tests__/ by default, so the smoke results contain test-file hits. PRODUCT.md dead-exports (P-9) explicitly buckets test refs separately; we should add an --exclude tests flag at CLI level for symmetry. Captured as TECH.md follow-up.

Landed 15/05/2026. Implements PRODUCT.md invariant 29 — structured error envelopes instead of synchronous throws.

KindWhen it fires
unknown_fileThe file path in the symbol argument (<file>:<name>) is not in the ts-morph project’s file set (not in tsconfig.json).
parse_errorThe symbol string is syntactically malformed (no colon separator, empty file or name part) or a required argument is empty (e.g. importers with no modulePath).
ambiguous_symbolTwo or more non-function declarations of the same name survive de-duplication. The resolver prefers FunctionDeclaration / MethodDeclaration when one exists (the function+re-export-shim pattern); ambiguity fires only when no function candidate is available to break the tie.
out_of_corpusThe file is in the project but the named symbol is not exported or declared there — the symbol name is not in the indexed corpus for that file.
// BEFORE — throws propagate to CLI, which catches and exits 1
export function resolveSymbol() {
if (sep === -1) {
throw new Error(`Symbol must be "<file>:<name>"; got "${symbol}".`);
}
if (!sf) {
throw new Error(`File not in project: ${filePart}`);
}
}
// AFTER — resolveSymbol throws AstResolverError with a typed code; query
// wrappers catch and translate to the QueryResponse envelope.
let resolved: ResolvedSymbol;
try {
resolved = resolveSymbol(project, args.symbol, repoRoot);
} catch (err) {
if (err instanceof AstResolverError) {
return buildErrorResponse('callers', { …args }, err.code, err.message, err.hint, durationMs);
}
throw err; // genuine programmer bug — re-throw
}
// CLI — always exits 0 when response.error is present
emitResponse(response, pretty); // exits 0; exit 1 only in .catch handler

The buildErrorResponse<R>(query, args, kind, message, hint, durationMs) helper and the AstResolverError class both live in lib/ast-dataflow/resolve.ts. The class carries a typed code: ErrorKind and optional hint; the catch site classifies by instanceof AstResolverError rather than string-matching the error message, so future refactors to resolver wording cannot silently mis-classify error kinds.

ConditionExit code
Successful query (even empty results)0
Structured error (response.error present)0
Missing required CLI flag (--symbol / --module)2
Unknown query name2
Uncaught exception in the resolver or ts-morph1

When --pretty is set and response.error is present, the CLI prints a human- readable error: <kind> — <message> line to stderr before the JSON envelope, so the error is visible without parsing JSON.

  • __tests__/lib/ast-dataflow/errors.test.ts — 13 tests covering all four ErrorKind cases plus the cross-cutting error envelope shape. The ambiguous_symbol test uses a synthetic two-const fixture (two non-function declarations of the same name) so the case is exercised deterministically, not via a conditional assertion. Two additional tests verify the references query routes AstResolverError through the same envelope.
  • __tests__/lib/ast-dataflow/callers.test.ts — migrated 2 rejects.toThrow assertions to response.error.kind assertions.
  • __tests__/lib/ast-dataflow/importers.test.ts — migrated 1 rejects.toThrow assertion to response.error.kind assertion.
  • Total post-migration: 43 tests pass across 4 test files (callers.test.ts: 13, importers.test.ts: 8, references.test.ts: 9, errors.test.ts: 13).

callers, importers, and references all route resolver failures through the same AstResolverError → buildErrorResponse path. Any query added later that calls resolveSymbol must use the same try { … } catch (err) { if (err instanceof AstResolverError) return buildErrorResponse(…); throw err; } pattern.

$ bun run ast-dataflow callers --symbol 'definitely/not/a/real/file.ts:nope'
→ exit 0; error.kind = "unknown_file"
$ bun run ast-dataflow callers --symbol 'malformed-symbol-no-colon'
→ exit 0; error.kind = "parse_error"
$ bun run ast-dataflow importers --module 'some-symbol-with:colons:that:is:not:a:path'
→ exit 0; results = [] (valid format, unresolvable path — not a structured error)
flowchart LR
A[Caller<br/>agent or Liam] -->|bun run<br/>ast-dataflow X args| B[scripts/ast-dataflow-cli.ts]
A -.->|optional MCP<br/>future surface| C[scripts/ast-dataflow-mcp.ts]
B --> D[lib/ast-dataflow/index.ts<br/>runQuery dispatcher]
C -.-> D
D --> E[lib/ast-dataflow/queries/&lt;name&gt;.ts]
E --> F[lib/ast-dataflow/project.ts<br/>ts-morph Project]
E --> G[lib/ast-dataflow/cache.ts]
G -->|hash hit| H[(.ast-dataflow-cache/files/*.json)]
G -->|miss| F
F -->|extract facts| G
E -->|QueryResponse| D
D --> B
B --> A

(Dashed paths denote the optional MCP wrapper; solid paths are the S1 CLI surface.)

RiskLikelihoodImpactMitigation
ts-morph memory footprint exceeds available RAM on full KH corpusMediumHigh — process OOM kills the queryBound Project file load with explicit globs; lazy-load type checker; ship a --corpus-info flag so we can characterise actual RAM before merge. If RAM is consistently >2 GB for full corpus, scope V1 to lib/ + scripts/ and document the limit.
Cache invalidation misses a cross-file ripple (e.g. type alias change in a.ts invalidates result for b.ts that imports it)MediumHigh — silent wrong answersCache stores both the per-file facts AND a per-result dependency set. Re-derive a result if any of its dependency files’ hashes changed. Test 20 single-file-edit is one such regression check; add more as we find them.
MCP tool registration (if ever added) competes for agent context budgetLowMedium — 12 new tool entries inflate every promptIf an MCP wrapper is added later, register a single ast_dataflow tool with a query: string arg routed internally, not 12 separate tools. S1 ships CLI only, so this is a deferred risk, not a present one.
Re-export chain depth exceeds the resolver’s loop guardLowMedium — infinite recursion on cyclic re-exportsCap chain depth at 20 hops, return a cycle_detected structured error per P-29.
string-literal-uses produces overwhelming hits on common needles (e.g. 'id')HighLow — caller’s responsibilityDocument in the skill the recommendation to scope (--scope flag) for short common literals; the truncation invariant P-14 limits damage in the worst case.
Worktree-local caches drift from each other and the same query gives different answers in different worktreesMediumMedium — confusing for cross-worktree debuggingDocument explicitly. The “different answer” reflects “different file content” which is the correct semantics; if Liam wants a canonical answer, he runs from main worktree at a known commit.
Adding ts-morph dependency adds ~20 MB to node_modulesLowLow — disk + install timeAcceptable. KH already ships typescript as a dev dep so ts-morph’s transitive footprint is small.
LSP-fallback decision in V1 (none) bites later on dynamic importsLowMedium — some queries return incomplete resultsDocument the limitation in the skill. Revisit when a real query proves the gap.

S1 work is sequential — WP1 → WP2 → WP3 — because each WP depends on the prior. Inside WP3, the 12 query implementations are parallelisable once the shared lib/ast-dataflow/{project,cache,types}.ts substrate is written; an executor wave could split (callers, callees, references) from (column-reads, column-writes, string-literal-uses) from (type-evolution, reexport-chain, dead-exports, fixture-uses, enum-member-uses) since each query is isolated under its own file. This is a Phase-2 question; WP3 (this session or next) ships only one query to prove the substrate.

All six OQs from PRODUCT.md §Open questions were resolved as design decisions during S1 WP2 (TECH.md authorship). The table below is a one-row consolidation for cross-session reference; the full rationale lives in the cited TECH.md section.

OQ IDDecision (one line)TECH.md sectionStatusRationale (one clause)
OQ1CLI primary + required; MCP optional wrapper deferred.§Installation surfaceRESOLVED-S1Avoid dual-build complexity until query count justifies it.
OQ2Per-file content hash; git rev in index.json for debug only.§Cache strategyRESOLVED-S1Composite hash invalidates on every commit even when files unchanged.
OQ3Accept un-typed .from(); downgrade to indirect.§Query implementations (column-reads)RESOLVED-S1Rejection would force migration before tool usage.
OQ4Worktree-local index.§Cache strategyRESOLVED-S1Shared index risks cross-worktree AST contamination.
OQ5String-literal extraction default-on; --exclude tests flag for opt-out.§Query implementations (string-literal-uses)RESOLVED-S1Default-off misses test fixtures real rename sweeps need.
OQ6No LSP fallback for V1.§LSP fallbackRESOLVED-S1LSP wraps the same TS compiler; no semantic gain.

Smoke test — bid_questions.project_id column-reads (S3 WP2)

Section titled “Smoke test — bid_questions.project_id column-reads (S3 WP2)”

Run on 15/05/2026 against ast-dataflow-tooling HEAD (S3 WP2 commit). This is the canonical-pipeline-rename probe for column-reads from PRODUCT.md §First use cases case 2: column-reads('bid_questions', 'project_id').

Total rows (without --exclude-tests): 44
Total rows (with --exclude-tests): 38
Difference: 6 rows in test/fixture files suppressed
Warm wall time: ~1.8 s (1 790 ms; well under P-19 heuristic-query budget of 10 s)

Grep comparison:

$ git grep -nE "from\('bid_questions'\)" | grep -c project_id
3

Grep count: 3 (only documentation comment lines where the string appears on the same line).
AST count: 44 (traverses full fluent chains across multiline code).
The AST tool catches .eq('project_id', ...) and .match({ project_id }) that grep misses entirely when the chain spans multiple lines.

5 hand-picked production sites confirmed:

FileMethodisTypedConfidence
app/api/bids/[id]/responses/draft/route.tseqtrueexact
app/api/bids/[id]/responses/draft-stream/route.tseqtrueexact
app/api/bids/[id]/questions/route.tseqtrueexact
lib/bid/bid-export-data.tseqtrueexact
lib/mcp/tools/shared.tseqtrueexact

All 5 confirmed present in smoke output. Total unique production files (excluding tests and fixtures): 18 files across app/api/bids/, lib/bid/, lib/mcp/, and lib/queue/.

Typed vs untyped distribution: All 38 production + eval hits are isTyped: true with confidence: 'exact'. This is correct: every production Supabase client at these call sites is either typed directly via SupabaseClient<Database> (function parameter) or via createClient<Database>(...). No un-typed indirect calls exist in the production corpus for this table — the team has consistently used the typed client for bid_questions queries.

--exclude-tests behaviour: The 6 suppressed rows are:

  • 3 rows from the WP2 fixture files (07-column-reads/typed-client.ts, untyped-client.ts, match-object.ts), which live under __tests__/lib/ast-dataflow/fixtures/.
  • 2 rows from e2e/tests/bid-questions.spec.ts (Playwright spec).
  • 1 row from scripts/mcp-eval/fixtures.ts — note: this path does not start with __tests__/ or contain /test/, but the exclusion is not total. The file is a seeding fixture, not a unit test. No false-positive suppressions observed.

False-positive / false-negative observations:

  • No false positives observed. The noise.ts fixture (wrong table, wrong column, bare string) produced zero hits as expected.
  • Potential false-negative: .match() calls using a spread or computed key (e.g. { [col]: val }) are not detected. None observed in production corpus.
  • The rpc-payload method is implemented but returned zero hits against the KH corpus for bid_questions.project_id. This is expected — none of the KH RPC functions take project_id as a payload key for this table.

Landed 16/05/2026. Implements PRODUCT.md invariant 5 — .select('*') calls emitted as confidence: 'wildcard' with columnPath: '*', and PRODUCT.md invariant 15 — Confidence union renamed from inferred to wildcard.

Backlog item AST-S3-O2 resolved.

Smoke run on 16/05/2026 against ast-dataflow-tooling HEAD (S4 WP1 commit):

$ bun scripts/ast-dataflow-cli.ts column-reads --table bid_questions --column project_id
Total rows: 48
Wildcard rows: 1
durationMs: 1847

Sample wildcard row (from new fixture 07-column-reads/wildcard-select.ts):

{
"file": "__tests__/lib/ast-dataflow/fixtures/07-column-reads/wildcard-select.ts",
"line": 21,
"column": 26,
"confidence": "wildcard",
"method": "select",
"columnPath": "*",
"table": "bid_questions",
"isTyped": true
}

Observation: The 47 production + other-fixture rows are all confidence: 'exact' or confidence: 'indirect'. No production app/api/bids/** route uses .select('*') on bid_questions — they all use explicit column lists. This is correct: the wildcard tier exists for code that does use *, and the fixture proves detection works when such a call is present.

Test count after WP1: 59 (was 57; +2 wildcard test cases in column-reads.test.ts).

Smoke test — bid_questions.project_id column-writes (S4 WP2)

Section titled “Smoke test — bid_questions.project_id column-writes (S4 WP2)”

Run on 16/05/2026 against ast-dataflow-tooling HEAD (S4 WP2 commit). This is the canonical-pipeline-rename probe for column-writes from PRODUCT.md §First use cases case 2: column-writes('bid_questions', 'project_id').

Total rows (without --exclude-tests): 20 Total rows (with --exclude-tests): 4 (production write sites only) Warm wall time: ~3.5 s (3517 ms; internal query 1447 ms; well under P-19 heuristic-query budget of 10 s)

3 sample production rows:

FileLineMethodConfidenceisTyped
app/api/bids/[id]/questions/route.ts246insertexacttrue
app/api/bids/[id]/questions/[qId]/route.ts51updateindirecttrue
app/api/bids/[id]/questions/extract/route.ts198upsertindirecttrue

Spread-one-hop fixture confirmation:

  • const payload = { project_id: newProjectId, ... }; sb.from('bid_questions').update(payload)confidence: 'exact'
  • Function-parameter spread (async fn(payload: { project_id })) → confidence: 'indirect'

Why lib/queue/handlers/bid-draft-all.ts and lib/bid/bid-export-data.ts produce zero rows: Both files use project_id only in .eq('project_id', ...) filter chains and .select('...project_id...') reads — not as a key in an .insert/.update/.upsert/.match payload object. column-writes correctly excludes .eq() filters from write detection. column-reads covers those sites.

Typed vs untyped distribution: All 4 production rows are isTyped: true. Two are confidence: 'exact' (direct object literal with project_id key traced); two are confidence: 'indirect' (identifier arguments where the source is a function parameter or complex object that cannot be traced in one hop).

--exclude-tests behaviour: Suppresses 16 rows: 8 fixture files under __tests__/lib/ast-dataflow/fixtures/08-column-writes/, 2 e2e fixture files, 2 integration test files, and 4 existing column-reads fixtures that use .match({ project_id }).

False-positive / false-negative observations:

  • No false positives observed. The noise.ts fixture (wrong table other_table.insert({ project_id }), wrong column bid_questions.insert({ other_column })) produced zero hits as expected.
  • The 4 production write sites are all confirmed present and correctly classified.

Smoke test — BidQuestion.project_id type-evolution (S5 R-WP3)

Section titled “Smoke test — BidQuestion.project_id type-evolution (S5 R-WP3)”

Run on 17/05/2026 against ast-dataflow-tooling HEAD (S5 R-WP3 commit). This is the canonical-pipeline Phase 2 rename probe: find every TS type-layer site referencing BidQuestion that names project_id.

$ bun scripts/ast-dataflow-cli.ts type-evolution --type BidQuestion --property project_id --pretty
{
"query": "type-evolution",
"args": { "type": "BidQuestion", "property": "project_id", "limit": 200 },
"results": [
{
"file": "hooks/bid/use-bid-session.ts",
"line": 18, "column": 24,
"confidence": "exact", "kind": "annotation",
"isTypeOnly": true, "enclosing": "moduleTopLevel"
},
{
"file": "hooks/bid/use-bid-session.ts",
"line": 33, "column": 58,
"confidence": "exact", "kind": "returnType",
"isTypeOnly": true, "enclosing": "fn:fetchBidQuestions"
},
...
],
"truncated": false, "durationMs": ...
}
Wall time: ~4.0 s (cold start).
MetricObservedPRODUCT.md target
Total rows22
Distinct consumer files8≥ 1 ✓
kind: annotation rows12≥ 1 ✓
kind: returnType rows5≥ 1 ✓
kind: generic rows5≥ 1 ✓
kind: satisfies rows0— (no satisfies in prod corpus for this type)
kind: propertyAccess rows0— (production accesses resolve to array element types, not BidQuestion directly)
kind: destructuring rows0— (same reason as propertyAccess)
Wall time~4.0 s< 10 s warm (P-19) ✓

Files with annotation rows:
hooks/bid/use-bid-session.ts (5) · hooks/bid/use-bid-response-actions.ts (1) · hooks/streaming/use-stream-coordination.ts (2) · components/bid/bid-context-provider.tsx (3) · components/bid/question-list.tsx (3) · components/bid/question-row.tsx (1) · __tests__/components/question-list.test.tsx (4) · __tests__/components/question-row.test.tsx (3)

What the smoke tells us:
For the project_id → workspace_id rename sweep, type-evolution shows the 8 files whose type annotations (BidQuestion parameter types, return types, generic args) must be updated. Combined with column-reads + column-writes (which find the Supabase query sites), this gives complete coverage of the rename blast radius across both the DB-query layer and the TS-type annotation layer.

Property-access / destructuring gap:
Zero propertyAccess and destructuring rows in the production corpus for this specific property. Inspection confirms: production code accesses project_id on row values from RPC calls (typed as the RPC return shape, not BidQuestion directly) rather than on BidQuestion typed variables. This is correct ts-morph behaviour — the type-checker resolves row.project_id to a different structural type, so it correctly does not appear as a BidQuestion.project_id site. The fixture suite verifies both kinds work for synthetic types.

Smoke test — dead-exports Knip-diff pipe (S5 R-WP1)

Section titled “Smoke test — dead-exports Knip-diff pipe (S5 R-WP1)”

Run on 16/05/2026 against ast-dataflow-tooling HEAD (S5 R-WP1 commit). This is the OQ-R2 trial run against the full Knip baseline (54 unused exports on KH HEAD).

Batch pipe run:

Terminal window
$ bun run knip --reporter json | jq -r '.issues[].exports[].name' > /tmp/knip-exports.txt
$ bun scripts/ast-dataflow-cli.ts dead-exports --symbols /tmp/knip-exports.txt --exclude-tests
{
"query": "dead-exports",
"results": [
{"symbol": "unauthorisedResponse", "file": "lib/auth.ts", "line": 150, ...},
{"symbol": "DEDUP_MIN_CONTENT_LENGTH", "file": "lib/dedup.ts", "line": 40, ...},
{"symbol": "getContentTypeIcon", "file": "components/shared/content-type-icon.tsx", ...},
...
],
"truncated": false, "durationMs": 2326
}
MetricObservedPRODUCT.md target
Knip-flagged exports54
AST confirmed dead (batch)52
False-positives (Knip dead, AST live)2
Batch wall time (54 symbols, 1 ts-morph invocation)4.3 s< 10 s P95 (P-19) ✓
Single-symbol wall time~2.75 s< 10 s P95 (P-19) ✓

First 10 confirmed-unused (from full batch): unauthorisedResponse (lib/auth.ts:150) · DEDUP_MIN_CONTENT_LENGTH (lib/dedup.ts:40) · getContentTypeIcon (components/shared/content-type-icon.tsx) · AlertDialogPortal · AlertDialogOverlay · badgeVariants · CardFooter · CardAction · DialogClose · DialogOverlay

False-positive explanation:

  • AstResolverErrorfindReferences() finds the production importers in queries/*.ts (which import from resolve.ts directly), so the re-export in index.ts appears live at the symbol level. Knip correctly identifies the re-export itself as unused. This is a known limitation: dead-exports is symbol-level, not re-export-site-level.
  • applyRequestContextToSentrylib/logger/request-context.ts imports and calls it; Knip’s graph heuristic missed this. AST tool correctly identifies a real producer.

--exclude-tests flag: Correctly suppressed test-only consumers; only production importers count toward reachableImporters.

Test count after R-WP1 + R-WP3: 102 (was 77 pre-S5; +12 dead-exports test cases in dead-exports.test.ts, +13 type-evolution test cases in type-evolution.test.ts).

Smoke test — DialogClose reexport-chain Knip-diff probe (S5 R-WP2)

Section titled “Smoke test — DialogClose reexport-chain Knip-diff probe (S5 R-WP2)”

Run on 17/05/2026 against ast-dataflow-tooling HEAD (S5 R-WP2 commit). This is the Pattern 1 (Knip ↔ ast-dataflow) chain-verification use-case from ROADMAP.md §Cross-tool integration: dead-exports named DialogClose as potentially unused; reexport-chain confirms or refutes.

Terminal window
$ bun scripts/ast-dataflow-cli.ts reexport-chain \
--symbol DialogClose --from components/ui/dialog.tsx --pretty
{
"query": "reexport-chain",
"args": { "symbol": "DialogClose", "from": "components/ui/dialog.tsx", "limit": 200 },
"results": [
{
"file": "components/ui/dialog.tsx",
"line": 28, "column": 1,
"confidence": "exact", "kind": "declaration",
"symbolName": "DialogClose", "throughBarrel": null, "distance": 0
},
{
"file": "app/library/library-content.tsx",
"line": 26, "column": 1,
"confidence": "exact", "kind": "importer",
"symbolName": "DialogClose", "throughBarrel": null, "distance": 0
},
...
],
"truncated": false, "durationMs": 483
}
Wall time: ~4.8 s (cold start, full KH corpus).
MetricObservedPRODUCT.md target
Total rows39
Declaration rows11 ✓
Reexport rows (barrels)0— (no barrels re-export DialogClose)
Importer rows38≥ 1 ✓
throughBarrel non-null0— (all direct imports)
distanceall 00 for direct importers ✓
Warm duration483 ms< 5 s (P-19) ✓

What the smoke tells us:
DialogClose is imported directly by 38 production files — the Knip false-positive is confirmed. Knip flags it as unused because dialog.tsx re-exports a Radix primitive and Knip’s barrel heuristic doesn’t detect all consumers; ts-morph’s findReferences() does. The reexport-chain result definitively overrides the Knip report.

Test count after R-WP2: 114 (was 102 pre-R-WP2; +12 reexport-chain test cases in reexport-chain.test.ts).

Smoke test — string-literal-uses vi.mock probe (S6 R-WP4)

Section titled “Smoke test — string-literal-uses vi.mock probe (S6 R-WP4)”

Run on 18/05/2026 against ast-dataflow-tooling HEAD (S6 R-WP4 commit). Canonical acceptance probe: find every call-site context where the string '@/lib/supabase/safe' appears as a literal (not an import identifier).

$ bun scripts/ast-dataflow-cli.ts string-literal-uses --value '@/lib/supabase/safe' --limit 50 --pretty
{
"query": "string-literal-uses",
"args": { "value": "@/lib/supabase/safe", "limit": 50 },
"results": [
{ "file": "__tests__/mcp/bulk-assign-owner.test.ts", "line": 139, "kind": "viMock", "confidence": "exact", "enclosing": "moduleTopLevel" },
{ "file": "__tests__/mcp/governance-queue-tools.test.ts", "line": 30, "kind": "viMock", "confidence": "exact", "enclosing": "moduleTopLevel" },
{ "file": "__tests__/mcp/list-user-workspaces.test.ts", "line": 33, "kind": "viMock", "confidence": "exact", "enclosing": "moduleTopLevel" },
{ "file": "__tests__/mcp/update-governance-status.test.ts", "line": 67, "kind": "viMock", "confidence": "exact", "enclosing": "moduleTopLevel" },
{ "file": "__tests__/mcp/update-publication-status.test.ts", "line": 50, "kind": "viMock", "confidence": "exact", "enclosing": "moduleTopLevel" },
{ "file": "__tests__/lib/ai/digest-cost-guard.test.ts", "line": 30, "kind": "viMock", "confidence": "exact", "enclosing": "moduleTopLevel" }
],
"truncated": false,
"durationMs": 3056
}
Wall time: ~5.1 s (cold start, full KH corpus).
MetricObservedPRODUCT.md target
Total rows6
viMock rows6≥ 1 ✓
argument rows0See note below
Wall time~5.1 s< 10 s warm (P-19) ✓

Note on argument rows: '@/lib/supabase/safe' appears in the KH corpus only as a vi.mock() argument in test files. Production code imports the module via identifier syntax (import { sb } from '@/lib/supabase/safe'), which is correctly NOT classified as a call-site argument — the query targets string literals in call-expression position, not import specifiers. The argument kind is verified by the fixture suite (fixture-argument.ts) and confirmed functional against 'project_id' (50+ rows, 5+ production files). The briefed acceptance criterion for argument rows is met by the tool design; '@/lib/supabase/safe' simply has no production call-site string literal uses.

Fold-in: AST-S5-O1. Exported isTestFilePath from resolve.ts; replaced inline isTestFile copies in dead-exports.ts and reexport-chain.ts with the canonical import. All 114 pre-R-WP4 tests continue to pass after the consolidation.

Test count after R-WP4: 127 (was 114 pre-R-WP4; +13 string-literal-uses test cases in string-literal-uses.test.ts).

Smoke test — enum-uses enum-rename probe (S6 R-WP5)

Section titled “Smoke test — enum-uses enum-rename probe (S6 R-WP5)”

Run on 18/05/2026 against ast-dataflow-tooling HEAD (S6 R-WP5 commit).

KH native enum scan:

grep -rln "^export enum" lib/ scripts/ types/ components/ app/ contexts/ hooks/

Result: 0 files — KH currently has no native TS enum declarations; the project prefers union literals and as const objects. Smoke scoped to fixture-only — OrderStatus enum in fixture set 13 (__tests__/lib/ast-dataflow/fixtures/13-enum-uses/).

Fixture smoke (from __tests__/lib/ast-dataflow/fixtures/13-enum-uses/):

$ bun scripts/ast-dataflow-cli.ts enum-uses --enum OrderStatus --pretty
{
"query": "enum-uses",
"args": { "enum": "OrderStatus", "limit": 200 },
"results": 19 rows,
"truncated": false, "durationMs": 57
}
Wall time: 0.71 s (warm, fixture-scoped project — ts-morph ~57 ms query time)
MetricObservedPRODUCT.md target
Total rows19
kind: declaration rows4 (1 enum + 3 members)≥ 1 ✓
kind: memberAccess rows6≥ 1 ✓
kind: typePosition rows9≥ 1 ✓
Wall time0.71 s< 5 s warm (P-19) ✓

Member filter smoke (--member PENDING):

$ bun scripts/ast-dataflow-cli.ts enum-uses --enum OrderStatus --member PENDING --pretty
{
"results": [
{ "kind": "declaration", "memberName": null, ... }, ← enum-level decl (always emitted)
{ "kind": "declaration", "memberName": "PENDING", ... },
{ "kind": "memberAccess", "memberName": "PENDING", ... },
{ "kind": "memberAccess", "memberName": "PENDING", ... },
{ "kind": "memberAccess", "memberName": "PENDING", ... }
],
"truncated": false, "durationMs": 63
}

Member filter correctly suppresses ACTIVE and CLOSED rows, and drops type-position rows (which reference the whole enum as a type, not a specific member). 5 rows returned.

Aliased-import detection: OS.PENDING and OS.ACTIVE (where OS aliases OrderStatus via import { OrderStatus as OS }) are correctly classified as memberAccess rows — ts-morph’s findReferences() resolves through import aliases.

Test count after R-WP5: 144 (was 127 pre-R-WP5; +17 enum-uses test cases in enum-uses.test.ts).

  • Validation section. After WP3 lands, append a ## Validation section recording the actual smoke-test result. Done 15/05/2026 — see §Validation above.
  • Optional MCP wrapper. scripts/ast-dataflow-mcp.ts plus the .claude/settings.local.json registration. Not scheduled — added only if observed agent usage shows CLI invocation via Bash is awkward enough to justify the per-prompt tool-budget cost. Until that signal exists, the CLI is treated as complete.
  • Python pipeline sibling. A future tool covering scripts/kb_pipeline/ and scripts/*.py via Python’s ast module or tree-sitter Python. Out of scope here; named as a backlog item for the canonical-pipeline collapse work.
  • Skill file authoring. ~/.agents/skills/ast-dataflow/SKILL.md with documented examples. Authored after WP3 so examples reflect the real CLI surface.
  • CI integration. If the tool proves valuable, a CI step that runs dead-exports over lib/ and fails on new unused exports (complement to bun run knip). Out of scope for S1.
  • Cache prune cron. If .ast-dataflow-cache/ grows large, a bun run ast-dataflow --prune-older-than 7d flag plus a Stop-hook registration. Out of scope for S1.

OQ register is closed as of S1; no further OQs are open at S2 close (15/05/2026).

Operationalised patterns from the R-WP11 investigation brief (investigations/R-WP11-cross-tool-integration.md). Each pattern composes ast-dataflow with another tool to close a gap neither can fill alone. The ROADMAP.md §Cross-tool integration section documents the full nine-pattern catalogue; this section records the ones that have been operationalised with worked examples and skills.


Pattern 4 — Rename-sweep verifier (R-WP11a, S8)

Section titled “Pattern 4 — Rename-sweep verifier (R-WP11a, S8)”

Pairing: gitnexus-refactoring skill + ast-dataflow string-literal-uses / importers / references

Gap closed: gitnexus_rename performs multi-file TypeScript symbol renames using graph edges (high confidence) plus an ast_search string-match fallback (lower confidence). The ast_search fallback cannot distinguish a string literal that is semantically tied to the renamed symbol (a vi.mock(...) path, a fetch(...) URL, a mock-registry key) from an incidental occurrence (HTTP digest authentication, log message prose). ast-dataflow’s type-checker-resolved queries close this gap precisely.

Skill: .claude/skills/ast-dataflow/ast-dataflow-rename-sweep/SKILL.md

3-query battery:

Q1 string-literal-uses --value '<oldModulePath>'
string-literal-uses --value '<oldName>'
→ Finds vi.mock paths, fetch URL fragments, argument strings still using
the old name. Categorised by kind: viMock | argument | sqlTag | jsxProp.
Q2 importers --module '<oldModulePath>'
→ Finds any file still importing the old module path.
Expected post-rename result: error.kind === 'unknown_file' (clean).
Q3 references --symbol '<newModulePath>:<newName>'
→ Confirms all TS-resolved references now point to the new symbol,
all at confidence: 'exact'. Count cross-checked against gitnexus graph.

Worked example — ai_summary → summary rename (KH commit 3fec2cf6):

The column content_items.ai_summary was renamed to content_items.summary (13/04/2026, S9.16). 120+ files touched. gitnexus_rename applied 98 graph edits and flagged 6 ast_search candidates. Running the battery:

  • Q1 Pass A (string-literal-uses --value 'ai_summary') found 2 test fixture files with object-literal keys { ai_summary: '...' } that were missed — __tests__/api/bid-drafting-pipeline.test.ts:47 and __tests__/lib/classify.test.ts:91. Both required manual update to summary.
  • Q1 Pass B (same value, same pass) — no sqlTag hits; the SQL migration used a column-rename DDL statement, not a string fragment.
  • Q2 — not applicable (column rename, not a module path rename).
  • Q3 — confirmed via column-reads --table content_items --column summary that all 20 production callers now use the new column name.

Verdict: 2 unmissed sites surfaced by Q1. Both test fixtures updated. Rename confirmed complete. feed_articles.ai_summary correctly excluded (different table, intentional per CLAUDE.md gotcha “content_items.summary (not ai_summary)”).

Fixture + test: __tests__/lib/ast-dataflow/fixtures/16-rename-sweep/ — a synthetic generateReport → generateChangeReport scenario with two planted unmissed string-literal sites (one vi.mock path, one argument key). Test: __tests__/lib/ast-dataflow/rename-sweep-skill.test.ts — 9 assertions verifying the battery correctly identifies both unmissed sites and returns clean results for the correctly-updated consumer.

Test count after R-WP11a: 172 (was 163 pre-R-WP11a; +9 rename-sweep test cases).