diff --git a/docs/accuracy/detection.md b/docs/accuracy/detection.md index b7fdf12..94b8c7f 100644 --- a/docs/accuracy/detection.md +++ b/docs/accuracy/detection.md @@ -139,6 +139,9 @@ import { client } from "../lib/clients"; // ← scanner must reach openai.ts - `src/ast/cross-file-resolver.ts` - New fixtures under `src/test/fixtures/barrels/` +### Status (2026-05-14) +Implemented on branch `claude/a3-a5-resolver-recall`. Fixtures live under `src/test/fixtures/a3-a5/barrel-*`; behavioral coverage in `src/test/a3-barrel-reexports.test.ts`. Resolved shapes: direct (`export { x } from`), aliased (`export { x as y } from`), wildcard (`export * from`), nested barrels (2+ levels, depth-capped), and `export { default } from`. Missing-symbol re-exports fail gracefully (no throw). Perf: synthetic 1001-file barrel chain scanned in ~0.3s — well under the 30s ceiling. + --- ## A4. AST ↔ regex parity audit @@ -217,6 +220,14 @@ The first pattern (`.bind`) is almost certainly missed today. The factory patter - [ ] No regression on simple `const ai = new OpenAI()` cases. - [ ] Each new tracked pattern has a fixture + test. +### Status (2026-05-14) +Implemented on branch `claude/a3-a5-resolver-recall`. Fixtures under `src/test/fixtures/a5/`; coverage in `src/test/a5-factory-di-aliased.test.ts`. Landed: +- `.bind()`-aliased method refs — already resolved by the existing scanner (audit confirms the member-access inside `.bind()` is detected). +- Factory return inference — `function makeClient(): X { return new X(); }` plus `const c = makeClient()` is tracked both in-file (per-file `factoryReturnMap` populates `varMap`) and cross-file (post-fixpoint pass synthesises matches for callers in other files). +- DI typed constructor params — `constructor(private readonly ai: OpenAI)` now populates a `thisFieldMap` so `this.ai.` resolves to the param's declared SDK type. +- Regression test confirms simple `const c = new X()` still resolves. +- Benchmark gate: detection recall +2.94pp, detection precision −0.91pp (within 1pp tolerance); finding precision/recall unchanged. + ### Files - `src/ast/import-resolver.ts` - `src/ast/call-visitor.ts` diff --git a/docs/superpowers/plans/2026-05-14-a3-a5-resolver-recall.md b/docs/superpowers/plans/2026-05-14-a3-a5-resolver-recall.md new file mode 100644 index 0000000..ab688b7 --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-a3-a5-resolver-recall.md @@ -0,0 +1,1147 @@ +# A3 + A5 — Resolver recall: barrel re-exports and factory/DI/aliased clients + +> **For agentic workers:** REQUIRED SUB-SKILLS: +> - `superpowers:subagent-driven-development` — one implementer subagent per task, spec + quality reviewers between tasks. +> - `superpowers:dispatching-parallel-agents` — phases marked **PARALLEL** dispatch multiple Agent calls in a single message; phases marked **SEQUENTIAL** dispatch one at a time because the agents touch shared files and would conflict. +> +> Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Lift static-analysis recall by handling two adjacent classes of "client lookup" patterns the resolver currently misses: (A3) all barrel re-export shapes including aliased / wildcard / default / nested barrels, and (A5) `.bind()`-aliased method refs, factory-returned clients, and DI constructor parameters. + +**Architecture:** Two prerequisite scanner-pipeline fixes (Phase 0) that the original A3/A5 designs implicitly assumed but that don't exist on main today. Then A3 (small, mechanical) and A5 (bigger, more design choices). Each substantive phase splits into a **parallel audit** (one agent per pattern; report what already works vs. what's broken) and a **sequential fix** pass (only the broken patterns; one agent at a time because all fixes touch the same one or two source files). + +**Tech Stack:** TypeScript, web-tree-sitter (existing AST infra). No new deps. + +**Closes:** issue #75 (A3), issue #77 (A5). + +--- + +## Why Phase 0 exists + +The first dispatch of an earlier draft of this plan revealed two foundational bugs that block A3 and A5 outright: + +1. **`scanFiles()` does not run cross-file resolution.** `runCrossFileResolution()` is invoked only inside `detectLocalWastePatternsInFiles()` (`src/scanner/core-scanner.ts:375`), not inside `scanFiles()` (line 160). So even if A1's resolver perfectly resolves a barrel-re-exported call, every consumer of `scanFiles()` (CLI, workspace-scanner, intelligence) gets the unresolved per-file matches. The waste detectors are the only consumer that benefits today. Phase 0 task **Pre-A** plumbs the resolver into `scanFiles()` so the entire scan surface reflects cross-file attribution. + +2. **`export const x = new Sdk()` is not tracked by the AST scanner.** Verified empirically in the dispatch: + - `const apiClient = new OpenAI(); apiClient.chat.completions.create(...)` → 1 AST match (works) + - `export const apiClient = new OpenAI(); apiClient.chat.completions.create(...)` → **0 AST matches** + - `export async function ask() { apiClient.chat.completions.create(...) }` (with non-exported const) → 1 AST match (works) + + The scanner's variable-tracking pass walks `lexical_declaration` nodes but doesn't recurse into the `export_statement` that wraps them. Phase 0 task **Pre-B** fixes this. Without it, every A3 fixture's `api.ts` (which exports its SDK client per common real-world idiom) produces no scanner matches at all and the resolver has nothing to propagate. + +Both prereqs are independent (different files, different concerns). They go in parallel. + +--- + +## Spec coverage + +| Issue | Sub-criterion | Plan task | +|---|---|---| +| (Pre-req) | `scanFiles()` returns cross-file-resolved provider attribution | **Pre-A** | +| (Pre-req) | `export const x = new Sdk()` tracked in scanner varMap | **Pre-B** | +| #75 A3 | `export { x }` resolves | A3.0 baseline (re-verified after Pre-A/B) | +| #75 A3 | `export { x as y }` resolves | A3.audit (audit) → A3.fix (fix if RED) | +| #75 A3 | `export *` resolves | A3.audit (audit) → A3.fix (fix if RED) | +| #75 A3 | `export { default }` resolves | A3.audit (audit) → A3.fix or defer | +| #75 A3 | Nested barrels (2+ levels) | A3.audit (audit) → A3.fix (fix if RED) | +| #75 A3 | Imports of non-existent symbols fail gracefully | A3.audit (audit) → A3.fix (fix if RED) | +| #75 A3 | Scan time on a 1000-file repo not impacted measurably | A3.perf | +| #77 A5 | `const fn = client.method.bind(client); fn(...)` | A5.audit → A5.fix | +| #77 A5 | `const c = makeClient()` factory return | A5.audit → A5.fix | +| #77 A5 | `class S { constructor(private c: OpenAI) {} } this.c.method(...)` | A5.audit → A5.fix | +| #77 A5 | No regression on simple `const c = new OpenAI()` | covered by Pre-B's tests + audit negative-control fixtures | + +--- + +## Execution model + +**Subagent-driven development** — for every substantive task (Pre-A, Pre-B, each A3/A5 fix, FINAL), the controller: + +1. Dispatches a single **implementer** with the full task text from this plan inlined into the prompt (do not make the implementer read the plan). +2. Awaits the implementer's status (DONE / BLOCKED / NEEDS_CONTEXT). +3. Dispatches a **spec compliance reviewer** subagent to verify the implementation matches the task spec. +4. If spec OK, dispatches a **code quality reviewer** subagent. +5. Loops on review fixes if either reviewer flags issues. +6. Marks task complete in TaskList and moves on. + +Skip reviewer rounds only on: +- Trivial scaffolding tasks (creating fixtures alone) +- Audit tasks (the audit IS the review of current behavior) + +**Parallel dispatch — when multiple Agent calls go in ONE assistant message:** +- Phase 0 (Pre-A and Pre-B): **2 parallel implementer dispatches** +- Phase 1A audit (A3.audit): **5 parallel audit dispatches** (one per re-export shape) +- Phase 2A audit (A5.audit): **3 parallel audit dispatches** (one per pattern) + +**Sequential dispatch — one Agent call at a time:** +- Phase 1B (A3.fix): each fix touches `src/ast/import-resolver.ts` and/or `src/ast/cross-file-resolver.ts`; conflicts otherwise. +- Phase 2B (A5.fix): each fix touches `src/ast/ast-scanner.ts` and/or shared resolver files. + +**Inter-phase gates:** the controller MUST verify Phase 0 is green (both Pre-A and Pre-B implementers report DONE + reviews pass) before dispatching Phase 1A audit. Same gate between Phase 1 and Phase 2. + +--- + +## File structure + +**Files modified by Phase 0:** +- `src/scanner/core-scanner.ts` — `scanFiles()` (Pre-A) +- `src/ast/ast-scanner.ts` — variable-tracking pass that walks `lexical_declaration` (Pre-B) + +**Files modified by A3 fixes:** +- `src/ast/import-resolver.ts` — `collectExports()`, `resolveBarrelImport()`, `ExportEntry` type +- `src/ast/cross-file-resolver.ts` — `extractReExports()`, `resolveExportedMatches()` + +**Files modified by A5 fixes:** +- `src/ast/ast-scanner.ts` — variable-tracking pass + call-visitor (for .bind override) +- `src/ast/import-resolver.ts` — `processFunctionParams` (constructor params), new factoryReturnMap +- `src/ast/cross-file-resolver.ts` — new post-fixpoint pass propagating factoryReturnMap + +**Files created:** +- `src/test/fixtures/a3-a5//...` — fixture trees (one subdirectory per pattern) +- `src/test/a3-barrel-reexports.test.ts` — A3 test suite +- `src/test/a5-factory-di-aliased.test.ts` — A5 test suite + +**Test wiring:** `package.json` `test:scanner` chain gets two appended entries (one per test file) at the end of each phase. + +--- + +# Phase 0 — Prerequisites (PARALLEL) + +> **Dispatch model:** TWO Agent calls in one assistant message. Pre-A and Pre-B touch different files (`core-scanner.ts` vs `ast-scanner.ts`) and are conceptually independent. Both must finish before A3.0. + +## Task Pre-A — Wire `runCrossFileResolution()` into `scanFiles()` + +**Files:** +- Modify: `src/scanner/core-scanner.ts` — `scanFiles()` (line 160-335) +- Test: a new behavioral test `src/test/pre-a-scanfiles-resolution.test.ts` + +**Goal:** After this task, `scanFiles()` returns `ApiCallInput[]` whose `provider`, `library`, and `methodSignature` fields reflect cross-file resolution. Previously the resolver was applied only inside `detectLocalWastePatternsInFiles()`. Move the resolver into `scanFiles()` (or factor a shared helper that both call). Either approach is acceptable; pick the one with smaller diff. + +- [ ] **Step 1: Read the existing pipeline.** + +Read `src/scanner/core-scanner.ts` lines 160-412 in full. Note that `detectLocalWastePatternsInFiles()` (line 337) builds `perFileResults` and then calls `runCrossFileResolution(perFileResults)` (line 374) to produce an augmented match map. Note that `scanFiles()` produces `ApiCallInput[]` (a simpler shape). Decide whether to (a) refactor so both share a common resolver step, or (b) inline the resolver into `scanFiles()` and have `detectLocalWastePatternsInFiles()` call `scanFiles()` first. + +Recommendation: option (a) — extract a private helper `gatherResolvedAstMatches(access)` that both functions call. Then `scanFiles()` maps the resolved matches to `ApiCallInput[]` (existing `astMatchToApiCallInput` logic), and `detectLocalWastePatternsInFiles()` keeps its detector dispatch. Smaller diff than (b) and avoids risk of changing waste-detector behavior. + +- [ ] **Step 2: Write a failing test (RED).** + +Create `src/test/pre-a-scanfiles-resolution.test.ts`: + +```ts +import assert from "node:assert/strict"; +import * as path from "node:path"; +import * as fs from "node:fs"; +import { setWasmDir } from "../ast/parser-loader"; +import { scanFiles, type ScanFileAccess, type ScanInputFile } from "../scanner/core-scanner"; + +const WASM_DIR = path.join(__dirname, "..", "..", "assets", "parsers"); +setWasmDir(WASM_DIR); + +async function run(name: string, fn: () => void | Promise): Promise { + try { await fn(); console.log(`PASS ${name}`); } + catch (err) { console.error(`FAIL ${name}`); throw err; } +} + +function buildFixtureAccess(fixtureDir: string): ScanFileAccess { + const entries = fs.readdirSync(fixtureDir, { recursive: true }) as string[]; + const files: ScanInputFile[] = entries + .filter((entry) => typeof entry === "string" && (entry.endsWith(".ts") || entry.endsWith(".js"))) + .map((relName) => ({ + absolutePath: path.join(fixtureDir, relName), + relativePath: relName.replace(/\\/g, "/"), + })); + return { + files, + readFile: async (absolutePath: string) => fs.readFileSync(absolutePath, "utf-8"), + }; +} + +(async () => { + const projectRoot = path.resolve(__dirname, "..", ".."); + // Reuse the existing wrappers fixture from A1 — known to exercise the resolver. + const fixtureDir = path.resolve(projectRoot, "src", "test", "fixtures", "wrappers"); + const calls = await scanFiles(buildFixtureAccess(fixtureDir)); + + await run("Pre-A: scanFiles() output reflects cross-file resolution (callers of wrapper functions get openai provider)", () => { + // The level1Entry.ts file calls into a 3-hop wrapper chain that bottoms out + // at client.chat.completions.create(). After cross-file resolution, the + // call site in level1Entry.ts should be attributed to openai. + const level1Calls = calls.filter((c) => c.file.endsWith("level1Entry.ts")); + const openaiCalls = level1Calls.filter((c) => c.provider === "openai"); + assert.ok( + openaiCalls.length >= 1, + `expected ≥1 openai call attributed to level1Entry.ts via wrapper resolution, got ${openaiCalls.length}: ${JSON.stringify(level1Calls.map((c) => ({ line: c.line, provider: c.provider, methodSig: c.methodSignature })))}` + ); + }); +})().catch((err) => { console.error(err); process.exit(1); }); +``` + +Wire into `package.json` `test:scanner`: + +``` + && node dist-test/test/pre-a-scanfiles-resolution.test.js +``` + +Compile + run. The test should FAIL pre-fix (matches in level1Entry.ts have no provider because scanFiles doesn't apply the resolver). + +- [ ] **Step 3: Apply the fix.** + +Extract a shared helper: + +```ts +// In src/scanner/core-scanner.ts, near the top of the module (after imports): + +interface ResolvedFileResult { + filePath: string; + relativePath: string; + source: string; + matches: AstCallMatch[]; // augmented with cross-file resolution +} + +async function gatherResolvedAstMatches( + access: ScanFileAccess, + onProgress?: (progress: ScanProgress) => void +): Promise { + const files = [...access.files].sort((a, b) => a.relativePath.localeCompare(b.relativePath)); + const perFileResults: PerFileResult[] = []; + + for (let i = 0; i < files.length; i++) { + const entry = files[i]; + try { + const text = await access.readFile(entry.absolutePath); + const ext = path.extname(entry.relativePath); + if (getLanguageForExtension(ext)) { + try { + const result = await scanFileWithAst(entry.absolutePath, async (fp: string) => { + try { return await access.readFile(fp); } catch { return null; } + }); + perFileResults.push({ + filePath: entry.absolutePath, + relativePath: entry.relativePath, + source: text, + result, + }); + } catch { /* fall through; non-ast file */ } + } + } catch { /* skip unreadable */ } + onProgress?.({ file: entry.relativePath, fileIndex: i + 1, fileTotal: files.length }); + } + + let augmented: Map; + try { + augmented = runCrossFileResolution(perFileResults); + } catch { + augmented = new Map(perFileResults.map((pf) => [pf.relativePath, pf.result.matches])); + } + + return perFileResults.map((pf) => ({ + filePath: pf.filePath, + relativePath: pf.relativePath, + source: pf.source, + matches: augmented.get(pf.relativePath) ?? pf.result.matches, + })); +} +``` + +Then refactor `scanFiles()` to use it. Specifically, replace the per-file AST scan loop (lines ~177-209) with a single up-front call to `gatherResolvedAstMatches()`. The regex passes (lines 211+) stay where they are — they consume the per-file `text` and `lines` and don't depend on cross-file resolution. + +The new `scanFiles()` shape: + +```ts +export async function scanFiles( + access: ScanFileAccess, + onProgress?: (progress: ScanProgress) => void +): Promise { + const resolvedFiles = await gatherResolvedAstMatches(access, onProgress); + const allCalls: ApiCallInput[] = []; + const dedupe = new Set(); + + for (const rf of resolvedFiles) { + const lines = rf.source.split("\n"); + const astCoveredLines = new Set(); + + for (const match of rf.matches) { + // Same Phase 1/2 gates as today (lines 187-197 in current code): + if (match.packageName && STDLIB_DENYLIST.has(match.packageName)) continue; + const fp = (match.provider && match.methodChain) + ? lookupMethod(match.provider, match.methodChain) : null; + const knownSdkProvider = match.provider ? isRegisteredProvider(match.provider) : false; + const knownHttpHost = match.kind === "http" && !!match.provider; + if (!fp && !knownSdkProvider && !knownHttpHost) continue; + + const apiCall = astMatchToApiCallInput(match, rf.relativePath); + const key = `${rf.relativePath}:${match.line}:${apiCall.method}:${apiCall.url}`; + if (dedupe.has(key)) continue; + dedupe.add(key); + astCoveredLines.add(match.line); + allCalls.push(apiCall); + } + + // Existing regex passes — copy the inner body (lines ~211-322) verbatim. + // (regex passes were not touched; just relocated to operate on rf.source/lines) + // ... + } + + return allCalls; +} +``` + +Refactor `detectLocalWastePatternsInFiles()` (line 337) similarly to use `gatherResolvedAstMatches()` instead of duplicating the per-file scan + cross-file-resolution boilerplate. After the refactor, `runCrossFileResolution()` is called from exactly one place. + +- [ ] **Step 4: Verify.** + +```bash +cd /home/andresl/Projects/recost/extension-a3-a5 +npm test 2>&1 | tail -20 +``` + +`Pre-A` test must PASS. Existing tests must STILL pass. In particular, `ast-cross-file-resolver.test.ts`, `a1-multi-hop-wrappers.test.ts`, and the C1/A2/A6/A7 test suites must remain green — those depend on exact resolver behavior. + +If `npm run benchmark` shows ANY metric drop (gate threshold 1pp), STOP and investigate before committing. Resolver-into-scanFiles is a behavioral change; even though it should only ADD provider attributions (never remove), an unexpected interaction is possible. + +- [ ] **Step 5: Commit.** + +```bash +git add src/scanner/core-scanner.ts src/test/pre-a-scanfiles-resolution.test.ts package.json +git commit -m "fix(scanner): wire cross-file resolution into scanFiles() so all consumers see resolved provider attribution (prereq for #75/#77)" +``` + +--- + +## Task Pre-B — Track `export const x = new Sdk()` in scanner varMap + +**Files:** +- Modify: `src/ast/ast-scanner.ts` — variable-tracking pass (search for the section that walks `lexical_declaration` and populates `varMap`) +- Test: a new behavioral test `src/test/pre-b-export-const-tracking.test.ts` + +**Goal:** After this task, the AST scanner's variable-tracking pass walks `lexical_declaration` whether it's wrapped in an `export_statement` or not. Currently: +- `const x = new OpenAI(); x.chat.completions.create(...)` → 1 match ✓ +- `export const x = new OpenAI(); x.chat.completions.create(...)` → 0 matches ✗ + +This is a recall regression on a very common real-world pattern (modules export their configured client). + +- [ ] **Step 1: Read the variable-tracking pass.** + +In `src/ast/ast-scanner.ts`, find the function that builds `varMap` from top-level `lexical_declaration` nodes. It's likely near `processVariableAssignment` or similar — search for `varMap.set` and trace back to the loop that produces those calls. The current logic almost certainly checks `node.type === "lexical_declaration"` directly and skips `export_statement` wrappers. + +- [ ] **Step 2: Write a failing test.** + +Create `src/test/pre-b-export-const-tracking.test.ts`: + +```ts +import assert from "node:assert/strict"; +import * as path from "node:path"; +import * as fs from "node:fs"; +import { setWasmDir } from "../ast/parser-loader"; +import { scanFileWithAst } from "../ast/ast-scanner"; + +const WASM_DIR = path.join(__dirname, "..", "..", "assets", "parsers"); +setWasmDir(WASM_DIR); + +async function run(name: string, fn: () => void | Promise): Promise { + try { await fn(); console.log(`PASS ${name}`); } + catch (err) { console.error(`FAIL ${name}`); throw err; } +} + +(async () => { + const projectRoot = path.resolve(__dirname, "..", ".."); + const fixtureDir = path.resolve(projectRoot, "src", "test", "fixtures", "pre-b"); + fs.mkdirSync(fixtureDir, { recursive: true }); + + const fp = path.join(fixtureDir, "exported-client.ts"); + fs.writeFileSync(fp, ` +import OpenAI from "openai"; + +export const apiClient = new OpenAI(); + +export async function ask(prompt: string): Promise { + const r = await apiClient.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: prompt }], + }); + return r.choices[0].message.content ?? ""; +} +`.trimStart()); + + await run("Pre-B: AST scanner tracks `export const x = new OpenAI()` and resolves x.method() to openai", async () => { + const result = await scanFileWithAst(fp, async (p) => fs.readFileSync(p, "utf-8")); + const openaiMatches = result.matches.filter((m) => m.provider === "openai"); + assert.ok( + openaiMatches.length >= 1, + `expected ≥1 openai match in exported-client.ts, got ${openaiMatches.length}: ${JSON.stringify(result.matches.map((m) => ({ line: m.line, provider: m.provider, methodChain: m.methodChain })))}` + ); + }); +})().catch((err) => { console.error(err); process.exit(1); }); +``` + +Wire into `package.json`: +``` + && node dist-test/test/pre-b-export-const-tracking.test.js +``` + +Run, expect FAIL. + +- [ ] **Step 3: Apply the fix.** + +In `src/ast/ast-scanner.ts`, find the loop that iterates top-level statements (likely `for (let i = 0; i < tree.rootNode.childCount; i++)` or similar). Where the body checks `stmt.type === "lexical_declaration"`, also unwrap `export_statement`: + +```ts +function unwrapExport(stmt: SyntaxNode): SyntaxNode { + if (stmt.type === "export_statement") { + // export_statement wraps either a declaration or an export-clause. + // For `export const x = ...`, it wraps a lexical_declaration. + for (let i = 0; i < stmt.namedChildCount; i++) { + const child = stmt.namedChild(i); + if (child && ( + child.type === "lexical_declaration" || + child.type === "function_declaration" || + child.type === "class_declaration" || + child.type === "variable_declaration" // tree-sitter sometimes uses this for `var` + )) { + return child; + } + } + } + return stmt; +} +``` + +Then in the variable-tracking loop: +```ts +for (let i = 0; i < tree.rootNode.childCount; i++) { + const rawStmt = tree.rootNode.child(i); + if (!rawStmt) continue; + const stmt = unwrapExport(rawStmt); // ← unwrap before type-checking + if (stmt.type === "lexical_declaration") { + // existing variable-tracking logic + } + // similar for function_declaration / class_declaration if those passes also exist +} +``` + +Apply the same unwrap to any other top-level passes in `ast-scanner.ts` that match on `function_declaration`, `class_declaration`, etc. — they're all subject to the same `export ` wrapping issue. + +If the file's variable-tracking is in a separate module (`call-visitor.ts` or a helper), apply the unwrap there. + +- [ ] **Step 4: Verify.** + +```bash +npm test 2>&1 | tail -20 +``` + +Pre-B test must PASS. Pre-A test (just landed) must STILL pass. All existing tests still PASS — especially `ast-call-visitor.test.ts`, `ast-scanner.test.ts`, and the cross-file tests. Run `npm run benchmark` to confirm no regression. + +- [ ] **Step 5: Commit.** + +```bash +git add src/ast/ast-scanner.ts src/test/pre-b-export-const-tracking.test.ts src/test/fixtures/pre-b/ package.json +git commit -m "fix(ast): track \`export const x = new Sdk()\` by unwrapping export_statement before variable-tracking (prereq for #75/#77)" +``` + +--- + +# Phase 1A — A3 audit (PARALLEL — 5 agents) + +> **Dispatch model:** Five Agent calls in ONE assistant message. Each agent owns one re-export shape. Each writes its own fixture under `src/test/fixtures/a3-a5//`, appends its own test case to `src/test/a3-barrel-reexports.test.ts`, runs the test, and reports PASS or FAIL with the exact failure message. NO source changes — just empirical audit. +> +> **Inter-agent conflict guard:** the test file `src/test/a3-barrel-reexports.test.ts` is the only shared file. Each agent gets its own `await run("...", ...)` block to append. To avoid race conditions on file edits, the controller (not the agent) is responsible for **creating the test file** with the Phase 0 baseline test BEFORE dispatching the parallel audit. Each agent then APPENDs a single new run-block; the controller merges any conflicts at the end of the phase by reading the file and reconciling if two agents stepped on each other. +> +> Better still: have the controller pre-allocate insertion-points by writing the file with five empty `// AGENT-A3.X-INSERT-HERE` comments before dispatching. Each audit agent replaces its own marker comment with their `await run(...)` block. Single-agent edits, no merge conflicts. + +## Task A3.0 — Controller bootstrap: create the A3 test file with five marker comments + +**Run by controller** (not subagent — too small for dispatch overhead). + +- [ ] Create `src/test/fixtures/a3-a5/barrel-direct/api.ts`, `index.ts`, `consumer.ts` (the already-working baseline; see Phase 0 detail above). + +- [ ] Create `src/test/a3-barrel-reexports.test.ts` with the bootstrap baseline test PLUS five marker comments: + +```ts +import assert from "node:assert/strict"; +import * as path from "node:path"; +import * as fs from "node:fs"; +import { setWasmDir } from "../ast/parser-loader"; +import { scanFiles, type ScanFileAccess, type ScanInputFile } from "../scanner/core-scanner"; + +const WASM_DIR = path.join(__dirname, "..", "..", "assets", "parsers"); +setWasmDir(WASM_DIR); + +async function run(name: string, fn: () => void | Promise): Promise { + try { await fn(); console.log(`PASS ${name}`); } + catch (err) { console.error(`FAIL ${name}`); throw err; } +} + +function buildFixtureAccess(fixtureDir: string): ScanFileAccess { + const entries = fs.readdirSync(fixtureDir, { recursive: true }) as string[]; + const files: ScanInputFile[] = entries + .filter((entry) => typeof entry === "string" && (entry.endsWith(".ts") || entry.endsWith(".js"))) + .map((relName) => ({ + absolutePath: path.join(fixtureDir, relName), + relativePath: relName.replace(/\\/g, "/"), + })); + return { + files, + readFile: async (absolutePath: string) => fs.readFileSync(absolutePath, "utf-8"), + }; +} + +(async () => { + const projectRoot = path.resolve(__dirname, "..", ".."); + const root = path.resolve(projectRoot, "src", "test", "fixtures", "a3-a5"); + + await run("A3.0 baseline: direct re-export `export { x } from './foo'` resolves to openai", async () => { + const calls = await scanFiles(buildFixtureAccess(path.join(root, "barrel-direct"))); + const consumerCalls = calls.filter((c) => c.file.endsWith("consumer.ts")); + const openaiCalls = consumerCalls.filter((c) => c.provider === "openai"); + assert.ok(openaiCalls.length >= 1, `baseline failed: ${openaiCalls.length} calls`); + }); + + // AGENT-A3.audit.aliased-INSERT-HERE + // AGENT-A3.audit.wildcard-INSERT-HERE + // AGENT-A3.audit.nested-INSERT-HERE + // AGENT-A3.audit.default-INSERT-HERE + // AGENT-A3.audit.missing-INSERT-HERE +})().catch((err) => { console.error(err); process.exit(1); }); +``` + +Wire into `package.json` `test:scanner`: +``` + && node dist-test/test/a3-barrel-reexports.test.js +``` + +Compile + run baseline. Must PASS now (Pre-A and Pre-B together make it work). If FAIL, STOP and debug Phase 0 before continuing. + +## Audit task template — five parallel dispatches + +Each of the five audit agents gets a prompt of this shape (substitute the per-agent SHAPE, FIXTURE_FILES, TEST_CODE, MARKER): + +``` +You are an AUDIT subagent. Work in /home/andresl/Projects/recost/extension-a3-a5. +Your task is to AUDIT whether the existing resolver handles a specific re-export shape, NOT to fix it. + +## Shape: + +## Step 1 — Create the fixture + + + +## Step 2 — Insert your test case + +In `src/test/a3-barrel-reexports.test.ts`, REPLACE the marker comment `` with this run-block: + + + +DO NOT touch any other test case or fixture file. DO NOT modify import-resolver.ts, cross-file-resolver.ts, or any other source file. + +## Step 3 — Run + report + +Run: + cd /home/andresl/Projects/recost/extension-a3-a5 + npm run build:ext && npx tsc -p tsconfig.scanner-tests.json && node dist-test/test/a3-barrel-reexports.test.js + +Report back: +- DONE / BLOCKED +- PASS or FAIL (with the failure message if FAIL) +- Files created/modified +- Any concerns about whether the fixture truly tests the shape + +DO NOT commit. The controller commits the audit phase as one atomic block after all 5 agents return. +``` + +The five sub-tasks below give the SHAPE/FIXTURE_FILES/TEST_CODE/MARKER for each agent. + +### A3.audit.aliased — `export { x as y }` + +- **MARKER:** `// AGENT-A3.audit.aliased-INSERT-HERE` +- **FIXTURE_FILES:** + - `src/test/fixtures/a3-a5/barrel-aliased/api.ts`: + ```ts + import OpenAI from "openai"; + const client = new OpenAI(); + export async function _internalAsk(prompt: string): Promise { + const r = await client.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: prompt }], + }); + return r.choices[0].message.content ?? ""; + } + ``` + - `src/test/fixtures/a3-a5/barrel-aliased/index.ts`: + ```ts + export { _internalAsk as ask } from "./api"; + ``` + - `src/test/fixtures/a3-a5/barrel-aliased/consumer.ts`: + ```ts + import { ask } from "./index"; + export async function handle(q: string): Promise { return ask(q); } + ``` +- **TEST_CODE:** + ```ts + await run("A3.audit.aliased: `export { x as y }` re-export resolves consumer call to openai", async () => { + const calls = await scanFiles(buildFixtureAccess(path.join(root, "barrel-aliased"))); + const consumerCalls = calls.filter((c) => c.file.endsWith("consumer.ts")); + const openaiCalls = consumerCalls.filter((c) => c.provider === "openai"); + assert.ok(openaiCalls.length >= 1, `aliased re-export failed: got ${openaiCalls.length} calls: ${JSON.stringify(consumerCalls.map((c) => ({ line: c.line, provider: c.provider })))}`); + }); + ``` + +### A3.audit.wildcard — `export *` + +- **MARKER:** `// AGENT-A3.audit.wildcard-INSERT-HERE` +- **FIXTURE_FILES:** + - `src/test/fixtures/a3-a5/barrel-wildcard/api.ts` — copy of `barrel-direct/api.ts` (exports `ask` directly) + - `src/test/fixtures/a3-a5/barrel-wildcard/index.ts`: + ```ts + export * from "./api"; + ``` + - `src/test/fixtures/a3-a5/barrel-wildcard/consumer.ts` — copy of `barrel-direct/consumer.ts` +- **TEST_CODE:** + ```ts + await run("A3.audit.wildcard: `export *` re-export resolves consumer call to openai", async () => { + const calls = await scanFiles(buildFixtureAccess(path.join(root, "barrel-wildcard"))); + const consumerCalls = calls.filter((c) => c.file.endsWith("consumer.ts")); + const openaiCalls = consumerCalls.filter((c) => c.provider === "openai"); + assert.ok(openaiCalls.length >= 1, `wildcard re-export failed: got ${openaiCalls.length} calls`); + }); + ``` + +### A3.audit.nested — nested barrels (2+ levels) + +- **MARKER:** `// AGENT-A3.audit.nested-INSERT-HERE` +- **FIXTURE_FILES:** + - `src/test/fixtures/a3-a5/barrel-nested/providers/openai.ts` — copy of `barrel-direct/api.ts` + - `src/test/fixtures/a3-a5/barrel-nested/providers/index.ts`: + ```ts + export { ask } from "./openai"; + ``` + - `src/test/fixtures/a3-a5/barrel-nested/index.ts`: + ```ts + export { ask } from "./providers"; + ``` + - `src/test/fixtures/a3-a5/barrel-nested/consumer.ts`: + ```ts + import { ask } from "./index"; + export async function handle(q: string): Promise { return ask(q); } + ``` +- **TEST_CODE:** + ```ts + await run("A3.audit.nested: 2-level nested barrels (`index → providers → openai`) resolve consumer call", async () => { + const calls = await scanFiles(buildFixtureAccess(path.join(root, "barrel-nested"))); + const consumerCalls = calls.filter((c) => c.file.endsWith("consumer.ts")); + const openaiCalls = consumerCalls.filter((c) => c.provider === "openai"); + assert.ok(openaiCalls.length >= 1, `nested barrel failed: got ${openaiCalls.length} calls`); + }); + ``` + +### A3.audit.default — `export { default } from` + +- **MARKER:** `// AGENT-A3.audit.default-INSERT-HERE` +- **FIXTURE_FILES:** + - `src/test/fixtures/a3-a5/barrel-default/api.ts`: + ```ts + import OpenAI from "openai"; + const client = new OpenAI(); + export default async function ask(prompt: string): Promise { + const r = await client.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: prompt }], + }); + return r.choices[0].message.content ?? ""; + } + ``` + - `src/test/fixtures/a3-a5/barrel-default/index.ts`: + ```ts + export { default } from "./api"; + ``` + - `src/test/fixtures/a3-a5/barrel-default/consumer.ts`: + ```ts + import ask from "./index"; + export async function handle(q: string): Promise { return ask(q); } + ``` +- **TEST_CODE:** + ```ts + await run("A3.audit.default: `export { default } from` resolves consumer's default import to openai", async () => { + const calls = await scanFiles(buildFixtureAccess(path.join(root, "barrel-default"))); + const consumerCalls = calls.filter((c) => c.file.endsWith("consumer.ts")); + const openaiCalls = consumerCalls.filter((c) => c.provider === "openai"); + assert.ok(openaiCalls.length >= 1, `default re-export failed: got ${openaiCalls.length} calls`); + }); + ``` + +### A3.audit.missing — non-existent symbol in barrel (graceful failure) + +- **MARKER:** `// AGENT-A3.audit.missing-INSERT-HERE` +- **FIXTURE_FILES:** + - `src/test/fixtures/a3-a5/barrel-missing/api.ts` — exports `ask` only (no `summarize`) + - `src/test/fixtures/a3-a5/barrel-missing/index.ts`: + ```ts + export { ask, summarize } from "./api"; + ``` + - `src/test/fixtures/a3-a5/barrel-missing/consumer.ts`: + ```ts + import { summarize } from "./index"; + export async function handle(q: string): Promise { return summarize(q); } + ``` +- **TEST_CODE:** + ```ts + await run("A3.audit.missing: barrel re-exports a non-existent symbol; scan completes without throwing", async () => { + const calls = await scanFiles(buildFixtureAccess(path.join(root, "barrel-missing"))); + assert.ok(Array.isArray(calls), "scanFiles must return an array even with broken barrels"); + }); + ``` + +## Audit phase wrap-up + +After all 5 agents return, the controller: + +- [ ] Runs the full test file once: `node dist-test/test/a3-barrel-reexports.test.js` +- [ ] Records which audit tests PASSED and which FAILED. PASSED = no fix needed in Phase 1B. FAILED = needs a fix dispatch. +- [ ] Commits the audit-phase artifacts as ONE atomic commit: + ```bash + git add src/test/fixtures/a3-a5/ src/test/a3-barrel-reexports.test.ts + git commit -m "test(a3): empirical audit of 5 re-export shapes (passing: , failing: )" + ``` + +--- + +# Phase 1B — A3 fixes (SEQUENTIAL) + +> **Dispatch model:** ONE Agent call at a time. Each fix touches `src/ast/import-resolver.ts` and/or `src/ast/cross-file-resolver.ts`; concurrent dispatches would conflict. +> +> Only dispatch fix tasks for shapes that FAILED in Phase 1A. If a shape unexpectedly passes (e.g. it was already handled by Pre-A's resolver plumbing), record it in the FINAL commit message but skip the fix. + +## Task A3.fix.aliased — capture export aliases + +**ONLY dispatch if A3.audit.aliased FAILED.** + +**Files:** +- Modify: `src/ast/import-resolver.ts` — `collectExports()` (line ~285), `ExportEntry` type (line ~278), `resolveBarrelImport()` (line ~319) +- Modify: `src/ast/cross-file-resolver.ts` — `extractReExports()` (line ~220) + +- [ ] **Step 1: Read** the current `collectExports()` and `ExportEntry`. Note that line 302-303 captures `spec.child(0).text` — the original name, not the alias. The exported name (what consumers import) IS the alias when present. + +- [ ] **Step 2: Extend `ExportEntry`** to carry both names: + ```ts + interface ExportEntry { + /** Name consumers import (alias if present, else original) */ + exportedName: string | null; + /** Name as defined in the source file (used to resolve the actual import) */ + originalName?: string; + sourcePath: string; + } + ``` + +- [ ] **Step 3: Update `collectExports()`** to capture the alias via field name lookup: + ```ts + if (exportClause) { + for (let j = 0; j < exportClause.childCount; j++) { + const spec = exportClause.child(j); + if (!spec || spec.type !== "export_specifier") continue; + const aliasNode = spec.childForFieldName("alias"); + const nameNode = spec.childForFieldName("name") ?? spec.child(0); + const exportedName = (aliasNode ?? nameNode)?.text; + const originalName = nameNode?.text; + if (exportedName) entries.push({ exportedName, sourcePath, originalName }); + } + } else { + entries.push({ exportedName: null, sourcePath }); + } + ``` + +- [ ] **Step 4: Update `resolveBarrelImport()`** at the source-file lookup (around line 374): + ```ts + const lookupName = entry.originalName ?? name; + const pkg = fileImports.get(lookupName); + ``` + +- [ ] **Step 5: Mirror in `cross-file-resolver.ts` `extractReExports()`.** Read the current implementation; if it's a regex like `/export\s*\{\s*([^}]+)\}\s*from\s*["']([^"']+)["']/g`, split each name on `\bas\b` and use the right-hand side as `exportedName`, the left-hand side as `originalName`. Preserve the rest of the function's behavior. + +- [ ] **Step 6: Verify.** A3.audit.aliased turns from FAIL to PASS. All other tests still PASS. Benchmark gate exit 0. + +- [ ] **Step 7: Commit.** + ```bash + git add src/ast/import-resolver.ts src/ast/cross-file-resolver.ts + git commit -m "fix(a3): capture export aliases (\`export { x as y }\`) in barrel resolution" + ``` + +## Task A3.fix.wildcard — `export *` regex / handler + +**ONLY dispatch if A3.audit.wildcard FAILED.** + +Likely fix is in `extractReExports()` in `cross-file-resolver.ts` — add a separate regex `/export\s*\*\s*from\s*["']([^"']+)["']/g` and emit `ExportEntry { exportedName: null, sourcePath: }`. The wildcard sentinel was already handled in `resolveExportedMatches()` / `resolveBarrelImport()` (`entry.exportedName === null` branch); the gap is just detection. + +Same TDD shape: read, fix, verify, commit. Commit message: `fix(a3): detect \`export *\` wildcard re-exports in cross-file resolver` + +## Task A3.fix.nested — recurse barrel resolution + +**ONLY dispatch if A3.audit.nested FAILED.** + +Add a depth parameter (cap at 4) to `resolveBarrelImport()` and recurse when the source file is itself a barrel re-exporting `lookupName`: + +```ts +async function resolveBarrelImport( + importedNames: string[], + sourceRelPath: string, + currentFilePath: string, + readFile: FileReader, + parseTreeFn: (src: string) => Promise, + depth: number = 0 +): Promise> { + if (depth > 4) return new Map(); + // ... existing barrel-content load ... + for (const name of importedNames) { + for (const entry of barrelExports) { + if (entry.exportedName === name || entry.exportedName === null) { + if (!entry.sourcePath.startsWith(".")) { + result.set(name, entry.sourcePath); + } else { + // ... existing source-file lookup ... + const nested = await resolveBarrelImport( + [lookupName], entry.sourcePath, candidate, + readFile, parseTreeFn, depth + 1 + ); + const nestedPkg = nested.get(lookupName); + if (nestedPkg) { result.set(name, nestedPkg); break; } + } + } + } + } + return result; +} +``` + +Also bump `resolveExportedMatches()` re-export depth cap from 2 to 4 in `cross-file-resolver.ts` for symmetry. + +Commit: `fix(a3): recurse barrel resolution through nested barrels (depth-cap 4)` + +## Task A3.fix.default — `export { default } from` + +**ONLY dispatch if A3.audit.default FAILED.** + +If the implementer reports the fix is non-trivial (touches every consumer that imports defaults, special-case `default` token in `collectExports`), DEFER: + +- [ ] File a follow-up issue: `A3 default re-exports support — deferred from PR XXX` +- [ ] Commit the FAILING fixture + test as a `// SKIP:` block: + ```ts + // SKIP A3.audit.default — deferred to follow-up issue (link) + // await run("A3.audit.default: ...", ...); + ``` +- [ ] Skip to A3.fix.missing. + +If the fix IS small (e.g. just adding a `"default"` literal handling in `collectExports`), apply it. Use judgment. + +## Task A3.fix.missing — graceful failure + +**ONLY dispatch if A3.audit.missing FAILED.** + +If `scanFiles()` throws on a missing-symbol re-export, wrap the resolution path that throws (likely deep inside `resolveExportedMatches()` or a sibling) in try/catch returning null. The audit test only requires "no throw" — a clean null return is sufficient. + +--- + +## Task A3.perf — performance check + +- [ ] Programmatically generate 1000 trivial barrel files to a tmp dir (NOT under `src/test/fixtures/`): + ```bash + TMP=$(mktemp -d) + for i in $(seq 1 1000); do + next=$((i + 1)) + echo "export { x } from './f${next}';" > "$TMP/f${i}.ts" + done + echo "export const x = 42;" > "$TMP/f1001.ts" + time node dist/cli/scan.js "$TMP" --format json > /dev/null + rm -rf "$TMP" + ``` + +- [ ] Acceptable: under 30s on a typical laptop. If 60s+, add a `Map>` cache to `resolveBarrelImport()` keyed by `(filePath, name)` for the duration of one resolution pass. + +- [ ] Append a one-line note to `docs/accuracy/detection.md` under A3: + > Tested 2026-05-14 against synthetic 1000-file barrel chain: scan completed in Xs. + +- [ ] Commit: + ```bash + git add docs/accuracy/detection.md + git commit -m "docs(a3): record perf measurement on 1000-file barrel chain" + ``` + +## Task A3.gate — A3 measurement gate + +- [ ] `npm test 2>&1 | tail -10` — total PASS count = 353 baseline + 2 (Pre-A, Pre-B) + 1 (A3 baseline) + N (A3.audit successes) ≈ 360 +- [ ] `npm run benchmark 2>&1 | tail -15` — exit 0, no metric regressions +- [ ] If anything regressed, STOP and dispatch a debugging implementer before Phase 2 + +--- + +# Phase 2A — A5 audit (PARALLEL — 3 agents) + +> **Dispatch model:** Three Agent calls in ONE assistant message. Same controller-bootstrap pattern as Phase 1A. + +## Task A5.0 — Controller bootstrap: create the A5 test file with three marker comments + +Create `src/test/a5-factory-di-aliased.test.ts` with the same scaffolding shape as the A3 test file, then three marker comments: + +```ts +// (imports + helpers same as A3 test file) + +(async () => { + const projectRoot = path.resolve(__dirname, "..", ".."); + const root = path.resolve(projectRoot, "src", "test", "fixtures", "a5"); + + // AGENT-A5.audit.bind-INSERT-HERE + // AGENT-A5.audit.factory-INSERT-HERE + // AGENT-A5.audit.di-INSERT-HERE +})().catch((err) => { console.error(err); process.exit(1); }); +``` + +Wire into `package.json`: +``` + && node dist-test/test/a5-factory-di-aliased.test.js +``` + +Compile + run (no tests yet — IIFE just runs and exits 0). + +### A5.audit.bind — `.bind()` aliasing + +- **MARKER:** `// AGENT-A5.audit.bind-INSERT-HERE` +- **FIXTURE_FILES:** + - `src/test/fixtures/a5/bind-aliased/consumer.ts`: + ```ts + import OpenAI from "openai"; + const client = new OpenAI(); + const askFn = client.chat.completions.create.bind(client.chat.completions); + export async function ask(prompt: string): Promise { + const r = await askFn({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: prompt }], + }); + return r.choices[0].message.content ?? ""; + } + ``` +- **TEST_CODE:** + ```ts + await run("A5.audit.bind: `.bind()`-aliased method ref resolves to openai", async () => { + const calls = await scanFiles(buildFixtureAccess(path.join(root, "bind-aliased"))); + const consumerCalls = calls.filter((c) => c.file.endsWith("consumer.ts")); + const openaiCalls = consumerCalls.filter((c) => c.provider === "openai"); + assert.ok(openaiCalls.length >= 1, `bind alias failed: got ${openaiCalls.length} calls`); + }); + ``` + +### A5.audit.factory — factory return inference + +- **MARKER:** `// AGENT-A5.audit.factory-INSERT-HERE` +- **FIXTURE_FILES:** + - `src/test/fixtures/a5/factory-direct/client-factory.ts`: + ```ts + import OpenAI from "openai"; + export function makeClient(): OpenAI { return new OpenAI(); } + ``` + - `src/test/fixtures/a5/factory-direct/consumer.ts`: + ```ts + import { makeClient } from "./client-factory"; + const client = makeClient(); + export async function ask(prompt: string): Promise { + const r = await client.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: prompt }], + }); + return r.choices[0].message.content ?? ""; + } + ``` +- **TEST_CODE:** + ```ts + await run("A5.audit.factory: cross-file factory `makeClient()` return resolves to openai", async () => { + const calls = await scanFiles(buildFixtureAccess(path.join(root, "factory-direct"))); + const consumerCalls = calls.filter((c) => c.file.endsWith("consumer.ts")); + const openaiCalls = consumerCalls.filter((c) => c.provider === "openai"); + assert.ok(openaiCalls.length >= 1, `factory return failed: got ${openaiCalls.length} calls`); + }); + ``` + +### A5.audit.di — DI typed constructor params + +- **MARKER:** `// AGENT-A5.audit.di-INSERT-HERE` +- **FIXTURE_FILES:** + - `src/test/fixtures/a5/di-constructor/consumer.ts`: + ```ts + import OpenAI from "openai"; + export class SummaryService { + constructor(private readonly ai: OpenAI) {} + async summarize(text: string): Promise { + const r = await this.ai.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: `Summarize: ${text}` }], + }); + return r.choices[0].message.content ?? ""; + } + } + ``` +- **TEST_CODE:** + ```ts + await run("A5.audit.di: typed constructor param `private ai: OpenAI` resolves `this.ai.method()` to openai", async () => { + const calls = await scanFiles(buildFixtureAccess(path.join(root, "di-constructor"))); + const consumerCalls = calls.filter((c) => c.file.endsWith("consumer.ts")); + const openaiCalls = consumerCalls.filter((c) => c.provider === "openai"); + assert.ok(openaiCalls.length >= 1, `DI constructor failed: got ${openaiCalls.length} calls`); + }); + ``` + +After all 3 agents return, controller commits the audit phase as one atomic block. + +--- + +# Phase 2B — A5 fixes (SEQUENTIAL) + +## Task A5.fix.bind — `.bind()` aliasing + +**ONLY dispatch if A5.audit.bind FAILED.** + +Add a `.bind()` case to the scanner's variable-tracking pass. When a `lexical_declaration` initializer is `.bind()` AND the leftmost identifier of `` is in `varMap`, propagate the package to the new binding AND store the original method-chain in a sibling `methodChainAliasMap`. + +In the call-visitor, when emitting an `AstCallMatch` for a call whose root identifier is in `methodChainAliasMap`, override `methodChain` with the original chain. Don't change `endpoint`. + +Test thoroughly with the existing `ast-call-visitor.test.ts` suite. + +Commit: `fix(a5): track .bind() method-ref aliasing in scanner varMap` + +## Task A5.fix.factory — factory return inference + +**ONLY dispatch if A5.audit.factory FAILED.** + +Two-step: + +1. **In-file factory tracking** — extend the import-resolver / scanner to detect top-level `function_declaration` whose body is a single `return` statement returning `new X()` (or `X(...)` where X is in CLASS_TO_PACKAGE). Store in a `factoryReturnMap`. When the scanner sees `const c = makeClient()` and `makeClient` is in `factoryReturnMap`, populate `varMap["c"]` with the factory's return package. + +2. **Cross-file factory propagation** — extend the cross-file resolver: after the existing wrapper-fixpoint loop, build a global `(filePath, exportedFnName) → package` registry from each per-file `factoryReturnMap`. For each caller importing one of those functions, augment the caller's `varMap` (via a re-scan or post-hoc fixup pass) so subsequent `c.method()` calls in the caller get the right provider. + +Choose the implementation cut that minimizes risk. If cross-file proves too invasive, ship in-file only and file a follow-up. + +Commit: `fix(a5): factory return inference for single-statement \`return new X()\` functions` + +## Task A5.fix.di — DI constructor params + +**ONLY dispatch if A5.audit.di FAILED.** + +Extend `processFunctionParams` to walk `class_declaration → class_body → method_definition(name=constructor)` and treat the constructor's typed params the same way it treats regular function params. Key the result by `.constructor` and add a `thisFieldMap[className][fieldName] = package`. + +Update the call-visitor: when resolving `this..`, look up `thisFieldMap[currentClassName][field]` to get the package. + +Commit: `fix(a5): track typed constructor params for \`this.\` access in classes` + +--- + +## Task A5.regress — regression test for simple `new X()` + +Add to `src/test/a5-factory-di-aliased.test.ts`: + +```ts +await run("A5.regress: simple `const c = new OpenAI(); c.method()` still resolves (no regression from A5 changes)", async () => { + const tmpDir = path.join(root, "_simple-regression"); + fs.mkdirSync(tmpDir, { recursive: true }); + fs.writeFileSync(path.join(tmpDir, "consumer.ts"), ` +import OpenAI from "openai"; +const client = new OpenAI(); +export async function ask(p: string): Promise { + const r = await client.chat.completions.create({ model: "gpt-4o-mini", messages: [{ role: "user", content: p }] }); + return r.choices[0].message.content ?? ""; +} +`.trimStart()); + try { + const calls = await scanFiles(buildFixtureAccess(tmpDir)); + const consumerCalls = calls.filter((c) => c.file.endsWith("consumer.ts")); + assert.ok(consumerCalls.some((c) => c.provider === "openai"), "simple new OpenAI() must still resolve"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); +``` + +Run, expect PASS. Commit: `test(a5): regression check for simple new X() pattern` + +## Task A5.gate — A5 measurement gate + +Same shape as A3.gate. Run full test suite + benchmark. No regression allowed. + +--- + +# Phase 3 — FINAL: docs, commit plan, push, open PR + +- [ ] **Refresh docs/accuracy/detection.md** A3 (#75) and A5 (#77) sections with the actual landed scope (which patterns are supported, which were deferred). + +- [ ] **Run final verification:** + ```bash + cd /home/andresl/Projects/recost/extension-a3-a5 + npm test 2>&1 | grep -c "^PASS " + npm run benchmark 2>&1 | tail -10 + ``` + +- [ ] **Stage everything:** + ```bash + git status + git add -A # use -A here only because the controller is at the end and has manually verified what's staged + # Or stage explicitly file by file if you prefer + ``` + +- [ ] **Commit the plan + final docs:** + ```bash + git add docs/superpowers/plans/2026-05-14-a3-a5-resolver-recall.md docs/accuracy/detection.md + git commit -m "..." + ``` + +- [ ] **Push and open PR:** + ```bash + git push -u origin claude/a3-a5-resolver-recall + gh pr create --title "fix(detection): A3 + A5 — barrel re-exports + factory/DI/aliased clients (closes #75, #77)" --body "..." + ``` + +PR body should include: +- Phase 0 explanation (the two prereqs that unblocked the rest) +- Per-pattern audit results table (what was already PASS vs. what got fixed) +- Out-of-scope notes (default re-exports if deferred, cross-file factory if deferred) +- Forward-looking note: corpus-expansion follow-up issue to make these gains measurable at the D1 gate + +--- + +## Self-review (controller) + +**Spec coverage:** Top table maps every #75 and #77 sub-criterion to a task. Default re-exports (A3.audit.default → A3.fix.default) is the only stretch item that may defer. + +**Risk hotspots:** +1. **Pre-A's plumbing change** is the most invasive piece — every consumer of `scanFiles()` (CLI, workspace-scanner, intelligence pipeline) starts seeing cross-file-resolved attribution. Theoretically additive only (we never REMOVE attribution), but resolver bugs could surface at the gate. The benchmark step at the end of Pre-A is the safety net. + +2. **Pre-B's `export const` unwrap** could miss other top-level forms wrapped in `export_statement`. The fix should unwrap for `lexical_declaration`, `function_declaration`, `class_declaration` at minimum. Audit other passes in `ast-scanner.ts` that might be affected; the implementer should grep for `stmt.type === "lexical_declaration"` and similar to find every site. + +3. **A5.fix.factory's cross-file propagation** is the largest A5 piece. The plan keeps it as a separate post-fixpoint pass to minimize blast radius. If implementation runs into the fixpoint, fall back to in-file-only and file a follow-up. + +4. **No corpus measurement.** A3 and A5 will improve real-world recall but won't move the D1 baseline (corpus has no barrel chains or factory patterns today). In-repo fixtures provide regression coverage. A separate PR to `recost-dev/extension-benchmark` should add fixtures so the next baseline refresh shows the recall gain. + +**Out of scope (file as follow-ups if landed):** +- A3 default re-exports if RED at A3.audit.default and the fix is non-trivial +- A5 multi-statement factory bodies (`if (...) return new X(); else return new Y()`) +- A5 factory chains (`make().withRetry().withLogger()`) +- Corpus expansion in `extension-benchmark` (separate PR there) +- Python equivalents — `import_module()`, dependency-injected fastapi services. Defer until corpus exercises it. + +**Sequencing rules (controller MUST follow):** +- Phase 0 → Phase 1A: gate on both Pre-A and Pre-B passing. +- Phase 1A → Phase 1B: gate on the audit phase completing (collect PASS/FAIL list before dispatching fixes). +- Phase 1B → Phase 2A: gate on A3.gate measurement (no regression). +- Phase 2A → Phase 2B: gate on the A5 audit phase completing. +- Phase 2B → Phase 3: gate on A5.gate measurement. + +--- + +## Execution handoff + +Plan saved to `docs/superpowers/plans/2026-05-14-a3-a5-resolver-recall.md`. Worktree: `/home/andresl/Projects/recost/extension-a3-a5` on branch `claude/a3-a5-resolver-recall` (branched from `origin/main` post-#109 merged). `npm ci` complete; `npm run build:ext` clean. + +**Subagent dispatch summary:** +- Phase 0 (Pre-A + Pre-B): 2 implementers in PARALLEL, 2 spec reviewers, 2 quality reviewers (sequential after impl). +- Phase 1A (A3 audit): 5 audit agents in PARALLEL (no separate review — audit IS the review of current behavior). +- Phase 1B (A3 fixes): N implementers SEQUENTIALLY (where N = number of FAILED audits), each with spec + quality reviewers. +- Phase 1C (A3.perf, A3.gate): controller runs directly (no subagent dispatch — measurement and shell commands). +- Phase 2A (A5 audit): 3 audit agents in PARALLEL. +- Phase 2B (A5 fixes): N implementers SEQUENTIALLY. +- Phase 2C (A5.regress, A5.gate): controller runs directly. +- Phase 3 (FINAL): controller runs directly. + +Total dispatches: 2 + 5 + N(A3≤5) + 3 + N(A5≤3) + reviewer rounds. Worst case ~30 subagent calls; realistic case ~15-20 (fewer fixes if some patterns already work post-Phase 0). diff --git a/package.json b/package.json index d5e8171..0e0e59a 100644 --- a/package.json +++ b/package.json @@ -198,7 +198,7 @@ "build:webview": "cd webview && npm run build", "build:dashboard": "cd dashboard && npm run build && rm -rf ../dashboard-dist && cp -r dist ../dashboard-dist", "test": "npm run test:scanner", - "test:scanner": "tsc -p tsconfig.scanner-tests.json && tsc -p tsconfig.benchmark.json && node dist-test/test/scanner-patterns.test.js && node dist-test/test/workspace-scanner.test.js && node dist-test/test/workspace-file-access.test.js && node dist-test/test/endpoint-classification.test.js && node dist-test/test/local-waste-detector.test.js && node dist-test/test/chat-providers.test.js && node dist-test/test/fingerprint-registry.test.js && node dist-test/test/pricing-sync.test.js && node dist-test/test/ast-parser-loader.test.js && node dist-test/test/ast-call-visitor.test.js && node dist-test/test/ast-import-resolver.test.js && node dist-test/test/ast-scanner.test.js && node dist-test/test/ast-python.test.js && node dist-test/test/ast-frequency-analyzer.test.js && node dist-test/test/ast-cache-detector.test.js && node dist-test/test/ast-batch-detector.test.js && node dist-test/test/ast-concurrency-detector.test.js && node dist-test/test/ast-cross-file-resolver.test.js && node dist-test/test/a1-multi-hop-wrappers.test.js && node dist-test/intelligence/__tests__/builder.test.js && node dist-test/intelligence/__tests__/clusters.test.js && node dist-test/intelligence/__tests__/compression.test.js && node dist-test/intelligence/__tests__/export.test.js && node dist-test/test/api-client.test.js && node dist-test/test/key-management.test.js && node dist-test/test/ast-parser-loader-fallback.test.js && node dist-test/intelligence/__tests__/cost-utils.test.js && node dist-test/test/intelligence-compression-async.test.js && node dist-test/test/webview-provider-dispatch.test.js && node dist-test/test/extension-activation.test.js && node dist-test/test/source-span.test.js && node dist-test/test/url-template.test.js && node dist-test/test/enclosing-function.test.js && node dist-test/test/endpoint-id.test.js && node dist-test/test/parity.test.js && node dist-test/test/a6-object-literal-fps.test.js && node dist-test/test/a2-const-fold.test.js && node dist-test/test/a7-url-path-fallback.test.js && node dist-test/test/c1-pr2-cache-tightening.test.js && node dist-test/test/c1-pr3-batch-tightening.test.js && node dist-test/src/test/benchmark-schema.test.js && node dist-test/src/test/benchmark-metrics.test.js", + "test:scanner": "tsc -p tsconfig.scanner-tests.json && tsc -p tsconfig.benchmark.json && node dist-test/test/scanner-patterns.test.js && node dist-test/test/workspace-scanner.test.js && node dist-test/test/workspace-file-access.test.js && node dist-test/test/endpoint-classification.test.js && node dist-test/test/local-waste-detector.test.js && node dist-test/test/chat-providers.test.js && node dist-test/test/fingerprint-registry.test.js && node dist-test/test/pricing-sync.test.js && node dist-test/test/ast-parser-loader.test.js && node dist-test/test/ast-call-visitor.test.js && node dist-test/test/ast-import-resolver.test.js && node dist-test/test/ast-scanner.test.js && node dist-test/test/ast-python.test.js && node dist-test/test/ast-frequency-analyzer.test.js && node dist-test/test/ast-cache-detector.test.js && node dist-test/test/ast-batch-detector.test.js && node dist-test/test/ast-concurrency-detector.test.js && node dist-test/test/ast-cross-file-resolver.test.js && node dist-test/test/a1-multi-hop-wrappers.test.js && node dist-test/intelligence/__tests__/builder.test.js && node dist-test/intelligence/__tests__/clusters.test.js && node dist-test/intelligence/__tests__/compression.test.js && node dist-test/intelligence/__tests__/export.test.js && node dist-test/test/api-client.test.js && node dist-test/test/key-management.test.js && node dist-test/test/ast-parser-loader-fallback.test.js && node dist-test/intelligence/__tests__/cost-utils.test.js && node dist-test/test/intelligence-compression-async.test.js && node dist-test/test/webview-provider-dispatch.test.js && node dist-test/test/extension-activation.test.js && node dist-test/test/source-span.test.js && node dist-test/test/url-template.test.js && node dist-test/test/enclosing-function.test.js && node dist-test/test/endpoint-id.test.js && node dist-test/test/parity.test.js && node dist-test/test/a6-object-literal-fps.test.js && node dist-test/test/a2-const-fold.test.js && node dist-test/test/a7-url-path-fallback.test.js && node dist-test/test/c1-pr2-cache-tightening.test.js && node dist-test/test/c1-pr3-batch-tightening.test.js && node dist-test/src/test/benchmark-schema.test.js && node dist-test/src/test/benchmark-metrics.test.js && node dist-test/test/pre-a-scanfiles-resolution.test.js && node dist-test/test/pre-b-export-const-tracking.test.js && node dist-test/test/a3-barrel-reexports.test.js && node dist-test/test/a5-factory-di-aliased.test.js", "calibrate-detectors": "tsc -p tsconfig.scanner-tests.json && node dist-test/test/waste-calibration.js", "watch:ext": "node esbuild.mjs --watch", "watch:webview": "cd webview && npm run build -- --watch", diff --git a/src/ast/ast-scanner.ts b/src/ast/ast-scanner.ts index daed0fd..50321b3 100644 --- a/src/ast/ast-scanner.ts +++ b/src/ast/ast-scanner.ts @@ -78,11 +78,18 @@ export interface AstScanResult { /** Names of imported functions that were passed to middleware registrations * and need cross-file resolution in Phase 3.5. */ middlewareQueue: string[]; + /** + * Factory return type map: exported function name → npm package. + * Populated when a function body contains `return new X()` where X resolves + * to a known provider package. Consumed by the cross-file resolver to + * propagate `const client = makeClient()` → `client` → package. + */ + factoryReturnMap: Map; } // ── Package → Provider ID mapping ──────────────────────────────────────────── -const PACKAGE_TO_PROVIDER: Record = { +export const PACKAGE_TO_PROVIDER: Record = { openai: "openai", anthropic: "anthropic", // Python SDK: import anthropic "@anthropic-ai/sdk": "anthropic", @@ -166,12 +173,47 @@ function extractHttpMethodFromOptions(args: SyntaxNode[]): string { // ── AST traversal helpers ───────────────────────────────────────────────────── +/** Find the first child of `node` with the given type. */ +function childOfType(node: SyntaxNode, type: string): SyntaxNode | null { + for (let i = 0; i < node.childCount; i++) { + const c = node.child(i); + if (c && c.type === type) return c; + } + return null; +} + +/** + * If `stmt` is an `export_statement`, return the first child that is a + * declaration node (lexical_declaration, function_declaration, class_declaration, + * variable_declaration). Otherwise return `stmt` unchanged. + * + * This lets every top-level traversal loop handle `export const x = …` + * identically to `const x = …`. + */ +function unwrapExport(stmt: SyntaxNode): SyntaxNode { + if (stmt.type === "export_statement") { + for (let i = 0; i < stmt.namedChildCount; i++) { + const child = stmt.namedChild(i); + if (child && ( + child.type === "lexical_declaration" || + child.type === "function_declaration" || + child.type === "class_declaration" || + child.type === "variable_declaration" + )) { + return child; + } + } + } + return stmt; +} + /** Collect all top-level function names in the file. */ function collectTopLevelFunctions(tree: Tree): Map { const fns = new Map(); for (let i = 0; i < tree.rootNode.childCount; i++) { - const node = tree.rootNode.child(i); - if (!node) continue; + const raw = tree.rootNode.child(i); + if (!raw) continue; + const node = unwrapExport(raw); if (node.type === "function_declaration" || node.type === "function_definition") { // Scan all children to find the identifier — position varies for async functions // (async function foo() → child 0=async, 1=function, 2=identifier) @@ -202,8 +244,9 @@ function collectTopLevelFunctions(tree: Tree): Map { function collectClasses(tree: Tree): Map { const classes = new Map(); for (let i = 0; i < tree.rootNode.childCount; i++) { - const node = tree.rootNode.child(i); - if (!node) continue; + const raw = tree.rootNode.child(i); + if (!raw) continue; + const node = unwrapExport(raw); if (node.type === "class_declaration" || node.type === "class_definition") { const name = node.child(1); // TypeScript grammar uses "type_identifier" for class names; JS uses "identifier" @@ -256,6 +299,7 @@ function collectClassMethods(classNode: SyntaxNode): Map { * - All imports + constructor assignments from resolveImports * - `this.field` → package resolution (from constructor body assignments) * - Local class instance tracking (var → class name) + * - In-file factory call tracking (const client = makeClient() where makeClient is in factoryReturnMap) * * Returns: * - `varMap`: variableName → packageName @@ -264,7 +308,8 @@ function collectClassMethods(classNode: SyntaxNode): Map { */ function buildExtendedMaps( importMap: Map, - tree: Tree + tree: Tree, + factoryReturnMap?: Map ): { varMap: Map; thisFieldMap: Map; @@ -276,8 +321,9 @@ function buildExtendedMaps( // Walk top-level for instance assignments and this.field assignments for (let i = 0; i < tree.rootNode.childCount; i++) { - const node = tree.rootNode.child(i); - if (!node) continue; + const raw = tree.rootNode.child(i); + if (!raw) continue; + const node = unwrapExport(raw); if (node.type === "lexical_declaration" || node.type === "variable_declaration") { for (let j = 0; j < node.childCount; j++) { @@ -290,6 +336,15 @@ function buildExtendedMaps( const ctor = rhs.child(1); if (ctor) instanceMap.set(lhs.text, ctor.text); } + // In-file factory call: `const client = makeClient()` where makeClient is + // a known factory function defined in this file. + if (lhs.type === "identifier" && rhs.type === "call_expression" && factoryReturnMap) { + const callee = rhs.child(0); + if (callee?.type === "identifier") { + const pkg = factoryReturnMap.get(callee.text); + if (pkg) varMap.set(lhs.text, pkg); + } + } } } @@ -312,11 +367,49 @@ function buildExtendedMaps( } // Scan constructor bodies to find this.field = new Pkg() assignments + // AND typed constructor params (private readonly ai: OpenAI) → thisFieldMap const classes = collectClasses(tree); for (const [, classNode] of classes) { const methods = collectClassMethods(classNode); const constructor = methods.get("constructor"); if (!constructor) continue; + + // ── Typed constructor params (TS shorthand fields) ───────────────────── + // `constructor(private readonly ai: OpenAI)` → thisFieldMap["ai"] = "openai" + // The required_parameter has an accessibility_modifier child when it declares + // a class field (private/public/protected). Walk the formal_parameters. + const formalParams = childOfType(constructor, "formal_parameters"); + if (formalParams) { + for (let pi = 0; pi < formalParams.childCount; pi++) { + const param = formalParams.child(pi); + if (!param) continue; + if (param.type !== "required_parameter" && param.type !== "optional_parameter") continue; + + // Only process params that have an accessibility modifier (making them class fields) + let hasAccessibilityModifier = false; + let nameNode: SyntaxNode | null = null; + let typeAnnotation: SyntaxNode | null = null; + + for (let ci = 0; ci < param.childCount; ci++) { + const c = param.child(ci); + if (!c) continue; + if (c.type === "accessibility_modifier") hasAccessibilityModifier = true; + else if (c.type === "identifier") nameNode = c; + else if (c.type === "type_annotation") typeAnnotation = c; + } + + if (!hasAccessibilityModifier || !nameNode || !typeAnnotation) continue; + + // type_annotation is `: TypeName` — the type identifier is at child(1) + const typeIdent = typeAnnotation.child(1); + if (!typeIdent) continue; + + const typeName = typeIdent.text; + const pkg = CLASS_TO_PACKAGE[typeName] ?? varMap.get(typeName); + if (pkg) thisFieldMap.set(nameNode.text, pkg); + } + } + // Walk constructor body for `this.field = new ClassName()` assignments walkNode(constructor, (n) => { if (n.type !== "assignment_expression") return; @@ -349,6 +442,72 @@ function walkNode(root: SyntaxNode, fn: (node: SyntaxNode) => void): void { } } +/** Node types that introduce a new function scope. */ +const FUNCTION_LIKE_TYPES = new Set([ + "function_declaration", + "function_expression", + "arrow_function", + "method_definition", + "generator_function", + "generator_function_declaration", + "function", +]); + +/** + * Scan a function body node for `return new X()` statements. + * Returns the resolved package for `X`, or null if not found. + * + * Handles both `{ return new X(); }` (statement body) and + * `=> new X()` (expression body — the function node child is directly a new_expression). + * + * Does NOT descend into nested function/arrow/method bodies — only scans the + * direct body of `fnNode` itself, so nested helpers like `const h = () => new Y()` + * cannot shadow the outer factory's actual `return new X()`. + */ +function detectFactoryReturnPackage( + fnNode: SyntaxNode, + varMap: Map +): string | null { + let found: string | null = null; + + function walk(n: SyntaxNode, isRoot: boolean): void { + if (found) return; + // Skip nested function bodies (but not the root fnNode itself) + if (!isRoot && FUNCTION_LIKE_TYPES.has(n.type)) return; + + // `return new X()` — return_statement whose first non-trivial child is new_expression + if (n.type === "return_statement") { + for (let i = 0; i < n.childCount; i++) { + const c = n.child(i); + if (c && c.type === "new_expression") { + const ctor = c.child(1); + if (ctor) { + const pkg = CLASS_TO_PACKAGE[ctor.text] ?? varMap.get(ctor.text); + if (pkg && !isInternalImport(pkg)) { found = pkg; return; } + } + } + } + } + // Arrow function with expression body: `const f = () => new X()` + // The new_expression is a direct child of the arrow_function node (not inside a block) + if (n.type === "new_expression" && n.parent?.type === "arrow_function" && n.parent === fnNode) { + const ctor = n.child(1); + if (ctor) { + const pkg = CLASS_TO_PACKAGE[ctor.text] ?? varMap.get(ctor.text); + if (pkg && !isInternalImport(pkg)) { found = pkg; return; } + } + } + + for (let i = 0; i < n.childCount; i++) { + const c = n.child(i); + if (c) walk(c, false); + } + } + + walk(fnNode, true); + return found; +} + // ── Provider resolution ─────────────────────────────────────────────────────── const NODE_BUILTIN_MODULES = new Set([ @@ -435,16 +594,32 @@ export async function scanSourceWithAst( const matches: AstCallMatch[] = []; const classRegistry = new Map(); const middlewareQueue: string[] = []; + const factoryReturnMap = new Map(); // ── 1. Parse ──────────────────────────────────────────────────────────────── const tree = await parseFile(source, language); - if (!tree) return { matches, classRegistry, middlewareQueue }; + if (!tree) return { matches, classRegistry, middlewareQueue, factoryReturnMap }; // ── 2. Resolve imports ────────────────────────────────────────────────────── const { importMap, parameterMaps } = await resolveImports(tree, filePath, readFileFn); + // ── 3a. Build preliminary varMap (needed for factory detection) ────────────── + // We need varMap before building factoryReturnMap so that factory bodies can + // resolve constructor class names that are imported (e.g. `new OpenAI()` where + // `OpenAI` is in importMap). + const prelimVarMap = new Map(importMap); + + // ── 3b. Detect factory return types in this file's function bodies ─────────── + { + const topFns = collectTopLevelFunctions(tree); + for (const [fnName, fnNode] of topFns) { + const pkg = detectFactoryReturnPackage(fnNode, prelimVarMap); + if (pkg) factoryReturnMap.set(fnName, pkg); + } + } + // ── 3. Build extended maps (this.field, instance→class, etc.) ─────────────── - const { varMap, thisFieldMap, instanceMap } = buildExtendedMaps(importMap, tree); + const { varMap, thisFieldMap, instanceMap } = buildExtendedMaps(importMap, tree, factoryReturnMap); // ── 4. Collect all call expressions ───────────────────────────────────────── const allCalls = extractCalls(tree); @@ -737,7 +912,7 @@ export async function scanSourceWithAst( } } - return { matches, classRegistry, middlewareQueue }; + return { matches, classRegistry, middlewareQueue, factoryReturnMap }; } /** @@ -751,11 +926,11 @@ export async function scanFileWithAst( readFileFn: FileReader ): Promise { const source = await readFileFn(filePath); - if (source === null) return { matches: [], classRegistry: new Map(), middlewareQueue: [] }; + if (source === null) return { matches: [], classRegistry: new Map(), middlewareQueue: [], factoryReturnMap: new Map() }; const ext = path.extname(filePath); const language = getLanguageForExtension(ext); - if (!language) return { matches: [], classRegistry: new Map(), middlewareQueue: [] }; + if (!language) return { matches: [], classRegistry: new Map(), middlewareQueue: [], factoryReturnMap: new Map() }; return scanSourceWithAst(source, language, filePath, readFileFn); } diff --git a/src/ast/cross-file-resolver.ts b/src/ast/cross-file-resolver.ts index b02bb29..ac69a90 100644 --- a/src/ast/cross-file-resolver.ts +++ b/src/ast/cross-file-resolver.ts @@ -16,6 +16,7 @@ */ import * as path from "path"; import type { AstCallMatch, AstScanResult } from "./ast-scanner"; +import { PACKAGE_TO_PROVIDER } from "./ast-scanner"; // ── Public types ────────────────────────────────────────────────────────────── @@ -117,6 +118,7 @@ function findExportedFunctions(source: string): FunctionRange[] { const EXPORT_FN = /^export\s+(async\s+)?function\s+(\w+)/; const EXPORT_CONST = /^export\s+const\s+(\w+)(?:\s*:\s*[^=]+)?\s*=\s*(async\s+)?\(/; + const EXPORT_DEFAULT_FN = /^export\s+default\s+(async\s+)?function(?:\s+(\w+))?/; // Track brace depth to approximate end of each function body. // Simple approach: find the opening { after the declaration, count braces. @@ -143,6 +145,17 @@ function findExportedFunctions(source: string): FunctionRange[] { const constMatch = EXPORT_CONST.exec(line); if (constMatch) { ranges.push({ name: constMatch[1], startLine: i + 1, endLine: findEndLine(i) }); + continue; + } + // export default function ask(...) — register under both "default" sentinel and + // the function's actual name (if present) so both lookup paths work. + const defaultFnMatch = EXPORT_DEFAULT_FN.exec(line); + if (defaultFnMatch) { + const endLine = findEndLine(i); + ranges.push({ name: "default", startLine: i + 1, endLine }); + if (defaultFnMatch[2]) { + ranges.push({ name: defaultFnMatch[2], startLine: i + 1, endLine }); + } } } @@ -210,15 +223,21 @@ function extractRelativeImports(source: string): ImportedName[] { // ── Re-export detection ─────────────────────────────────────────────────────── interface ReExport { - exportedName: string; + /** Exported name (or null for `export *` wildcard re-exports). */ + exportedName: string | null; + /** Original name in the source file (differs from exportedName when aliased). */ + originalName: string | null; specifier: string; } /** - * Detect `export { foo } from './other'` and `export { foo as bar } from './other'` patterns. + * Detect `export { foo } from './other'`, `export { foo as bar } from './other'`, + * and `export * from './other'` patterns. */ function extractReExports(source: string): ReExport[] { const results: ReExport[] = []; + + // Named re-exports: export { foo, bar as baz } from './other' const RE_EXPORT = /^export\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]/gm; let m: RegExpExecArray | null; while ((m = RE_EXPORT.exec(source)) !== null) { @@ -231,13 +250,24 @@ function extractReExports(source: string): ReExport[] { const asMatch = /(\w+)\s+as\s+(\w+)/.exec(trimmed); if (asMatch) { // export { foo as bar } from './other' → exported as "bar", original is "foo" - results.push({ exportedName: asMatch[2], specifier }); + results.push({ exportedName: asMatch[2], originalName: asMatch[1], specifier }); } else { const name = trimmed.match(/\w+/)?.[0]; - if (name) results.push({ exportedName: name, specifier }); + if (name) results.push({ exportedName: name, originalName: name, specifier }); } } } + + // Wildcard re-exports: export * from './other' + const WILDCARD_RE_EXPORT = /^export\s+\*\s+from\s+['"]([^'"]+)['"]/gm; + let wm: RegExpExecArray | null; + while ((wm = WILDCARD_RE_EXPORT.exec(source)) !== null) { + const specifier = wm[1]; + if (!specifier.startsWith(".") && !specifier.startsWith("/")) continue; + // null exportedName means "any symbol from this source" + results.push({ exportedName: null, originalName: null, specifier }); + } + return results; } @@ -353,10 +383,24 @@ function resolveExportedMatches( const reExports = extractReExports(source); for (const re of reExports) { - if (re.exportedName !== name) continue; + // Wildcard re-export (`export * from './other'`) — any name passes through. + // Named re-export — only proceed if exportedName matches the requested name. + // Also allow `export { default } from './other'` to match any default import: + // when a consumer does `import ask from './barrel'`, the barrel may re-export + // the default slot explicitly via `export { default } from './api'`. In that + // case the requested name is the local alias ("ask"), not "default", so we + // need to follow the default re-export and look up "default" in the source. + if (re.exportedName !== null && re.exportedName !== name && re.exportedName !== "default") continue; const resolved = resolveImportPath(fromFile, re.specifier, knownFiles); if (!resolved) continue; - const found = resolveExportedMatches(name, resolved, registry, sourceByFile, knownFiles, depth + 1, visited); + // When the barrel aliases (`export { _internalAsk as ask }`), the source file + // knows the symbol by its originalName — recurse with that name so the export + // registry lookup finds the actual function. + // For wildcards, the name passes through unchanged (originalName is null). + // For `export { default }`, recurse with "default" so the registry finds the + // `export default function` entry in the source file. + const lookupName = re.exportedName === "default" ? "default" : (re.originalName ?? name); + const found = resolveExportedMatches(lookupName, resolved, registry, sourceByFile, knownFiles, depth + 1, visited); if (found) return found; } @@ -599,9 +643,141 @@ export function runCrossFileResolution( } } + // ── Factory return post-pass ─────────────────────────────────────────────── + // + // Handles `const client = makeClient()` where `makeClient` is imported from + // another file and that file's factory function returns `new OpenAI()`. + // + // Algorithm: + // 1. Build global factory registry: absoluteFilePath → (exportedFnName → package) + // from each file's AstScanResult.factoryReturnMap. + // 2. For each consumer file, scan its relative imports for factory functions. + // 3. Find `const varName = factoryFn()` patterns in consumer source. + // 4. Find call expressions that use varName (e.g. varName.chat.completions.create) + // that aren't already attributed to a provider. + // 5. Emit synthetic AstCallMatches attributed to the factory's returned package. + runFactoryReturnPostPass(files, normalizedKnown, output, seenKeysByFile); + return output; } +// ── Factory return post-pass helpers ────────────────────────────────────────── + +/** + * Parse `const varName = factoryFn()` patterns from source text. + * Returns a map from varName → factoryFn (the callee name). + */ +function extractFactoryCallAssignments(source: string): Map { + const result = new Map(); + // const/let/var varName = factoryFnName() + // Also handles: const varName = factoryFnName() + const RE = /(?:const|let|var)\s+(\w+)\s*=\s*(\w+)\s*(?:<[^>]*>)?\s*\(\s*\)/gm; + let m: RegExpExecArray | null; + while ((m = RE.exec(source)) !== null) { + result.set(m[1], m[2]); + } + return result; +} + +/** + * Find all `varName.a.b.c(...)` call chains in source text. + * Returns { methodChain, line } entries (1-based line numbers). + */ +function extractVarMethodCalls(source: string, varName: string): Array<{ methodChain: string; line: number }> { + const results: Array<{ methodChain: string; line: number }> = []; + const lines = source.split("\n"); + // Match: varName.something.something...( — at least one dot required + // Build the regex once; matchAll() returns a fresh iterator per call so + // there are no lastIndex state issues between lines. + const re = new RegExp(`\\b(${escapeRegex(varName)}(?:\\.[\\w]+)+)\\s*\\(`, "g"); + for (let i = 0; i < lines.length; i++) { + for (const m of lines[i].matchAll(re)) { + results.push({ methodChain: m[1], line: i + 1 }); + } + } + return results; +} + + +function runFactoryReturnPostPass( + files: PerFileResult[], + normalizedKnown: Set, + output: Map, + seenKeysByFile: Map> +): void { + // Step 1: Build global factory registry + // globalFactoryRegistry: normalizedFilePath → (fnName → package) + const globalFactoryRegistry = new Map>(); + for (const f of files) { + const frm = f.result.factoryReturnMap; + if (frm && frm.size > 0) { + globalFactoryRegistry.set(normalizePath(f.filePath), frm); + } + } + if (globalFactoryRegistry.size === 0) return; + + // Step 2: For each consumer file, check imports against the factory registry + for (const consumer of files) { + const consumerPath = normalizePath(consumer.filePath); + const imports = extractRelativeImports(consumer.source); + + const seen = seenKeysByFile.get(consumer.relativePath)!; + const matches = output.get(consumer.relativePath)!; + + // Step 3: Find `const varName = localName()` in consumer source — hoisted + // out of the per-import loop so we only parse the source once per consumer. + const factoryAssignments = extractFactoryCallAssignments(consumer.source); + if (factoryAssignments.size === 0) continue; + + for (const { localName, specifier } of imports) { + const resolvedFile = resolveImportPath(consumerPath, specifier, normalizedKnown); + if (!resolvedFile) continue; + + const fileFactories = globalFactoryRegistry.get(resolvedFile); + if (!fileFactories) continue; + + const pkg = fileFactories.get(localName); + if (!pkg) continue; + + const provider = PACKAGE_TO_PROVIDER[pkg] ?? pkg; + + // Find all var names assigned from this factory function + for (const [varName, callee] of factoryAssignments) { + if (callee !== localName) continue; + + // Step 4: Find method calls on varName in consumer source + const calls = extractVarMethodCalls(consumer.source, varName); + for (const { methodChain, line } of calls) { + // Strip varName prefix: "client.chat.completions.create" → "chat.completions.create" + const dot = methodChain.indexOf("."); + const resolvedChain = dot !== -1 ? methodChain.slice(dot + 1) : ""; + if (!resolvedChain) continue; + + const key = `${provider}:${resolvedChain}:${line}`; + if (seen.has(key)) continue; + seen.add(key); + + matches.push({ + kind: "sdk", + provider, + packageName: pkg, + methodChain, + confidence: 0.9, + line, + column: 0, + span: { startLine: line, startColumn: 0, endLine: line, endColumn: 0 }, + frequency: "single", + loopContext: false, + enclosingFunction: null, + crossFile: true, + sourceFile: resolvedFile, + }); + } + } + } + } +} + // ── Source text helpers ─────────────────────────────────────────────────────── /** Find lines where `name(` or `name.` appears in source (1-based). */ diff --git a/src/ast/import-resolver.ts b/src/ast/import-resolver.ts index e5c2379..751ad16 100644 --- a/src/ast/import-resolver.ts +++ b/src/ast/import-resolver.ts @@ -276,8 +276,10 @@ function processFunctionParams( // ── Barrel file / re-export handling ───────────────────────────────────────── interface ExportEntry { - /** Exported name (or null for `export *`) */ + /** Exported name (or null for `export *`) — the name that consumers import */ exportedName: string | null; + /** Original name in the source file (differs from exportedName when aliased) */ + originalName?: string; /** Relative source path (the `from "..."` string) */ sourcePath: string; } @@ -299,8 +301,16 @@ function collectExports(tree: Tree): ExportEntry[] { for (let j = 0; j < exportClause.childCount; j++) { const spec = exportClause.child(j); if (!spec || spec.type !== "export_specifier") continue; - const ident = spec.child(0); // original name - if (ident) entries.push({ exportedName: ident.text, sourcePath }); + // Tree-sitter export_specifier fields: + // name: the original identifier (what the source file calls it) + // alias: the exported name (what consumers import), present only if `as Y` is used + const nameNode = spec.childForFieldName("name") ?? spec.child(0); + const aliasNode = spec.childForFieldName("alias"); + const originalName = nameNode?.text; + const exportedName = aliasNode?.text ?? originalName; + if (exportedName) { + entries.push({ exportedName, originalName, sourcePath }); + } } } else { // export * from "./providers" @@ -349,38 +359,50 @@ async function resolveBarrelImport( const barrelExports = collectExports(barrelTree); for (const name of importedNames) { - // Find a matching export in the barrel + // Find a matching export in the barrel. + // A barrel may have multiple entries that could match — e.g. a wildcard + // (`export * from "./a"`) followed by a named re-export + // (`export { ask } from "./b"`). We must keep iterating if the current + // entry doesn't actually provide the name we need. for (const entry of barrelExports) { - if (entry.exportedName === name || entry.exportedName === null) { - // This barrel re-exports `name` from `entry.sourcePath` — resolve it - if (!entry.sourcePath.startsWith(".")) { - // Re-exported from an npm package — this IS the package - result.set(name, entry.sourcePath); - } else { - // One more level: read the source file and look for the actual export - const srcDir = path.posix.dirname(resolvedBarrelPath); - const srcPath = joinPath(srcDir, entry.sourcePath); - const srcCandidates = srcPath.endsWith(".ts") || srcPath.endsWith(".js") - ? [srcPath] - : [srcPath + ".ts", srcPath + ".js"]; - - for (const candidate of srcCandidates) { - const content = await readFile(candidate); - if (content === null) continue; - const tree = await parseTreeFn(content); - if (!tree) continue; - - // Look for the npm package this file imports `name` from - const { importMap: fileImports } = await resolveImportsCore(tree, candidate, undefined, parseTreeFn); - const pkg = fileImports.get(name); - if (pkg) { - result.set(name, pkg); - break; - } + if (entry.exportedName !== name && entry.exportedName !== null) continue; + + // This barrel re-exports `name` from `entry.sourcePath` — resolve it + if (!entry.sourcePath.startsWith(".")) { + // Re-exported from an npm package — this IS the package + result.set(name, entry.sourcePath); + } else { + // One more level: read the source file and look for the actual export + const srcDir = path.posix.dirname(resolvedBarrelPath); + const srcPath = joinPath(srcDir, entry.sourcePath); + const srcCandidates = srcPath.endsWith(".ts") || srcPath.endsWith(".js") + ? [srcPath] + : [srcPath + ".ts", srcPath + ".js"]; + + for (const candidate of srcCandidates) { + const content = await readFile(candidate); + if (content === null) continue; + const tree = await parseTreeFn(content); + if (!tree) continue; + + // Look for the npm package this file imports `name` from. + // When the barrel used an alias (`export { _internalAsk as ask }`), + // the source file knows the function by its originalName, not the + // consumer-visible alias — so prefer originalName for the lookup. + const lookupName = entry.originalName ?? name; + const { importMap: fileImports } = await resolveImportsCore(tree, candidate, undefined, parseTreeFn); + const pkg = fileImports.get(lookupName) ?? (lookupName !== name ? fileImports.get(name) : undefined); + if (pkg) { + result.set(name, pkg); + break; } } - break; // found export entry for this name } + + // Only stop searching barrel entries once the lookup actually succeeded. + // If a wildcard entry didn't provide the name (source file didn't export + // it), continue to the next barrel entry which may be a named re-export. + if (result.has(name)) break; } } @@ -603,6 +625,26 @@ async function resolveImportsCore( processFunctionParams(stmt, importMap, parameterMaps); break; } + + case "export_statement": { + // `export const x = new Sdk()` / `export function f() {}` / `export class C {}` + // Unwrap the inner declaration and re-process it. + for (let j = 0; j < stmt.namedChildCount; j++) { + const inner = stmt.namedChild(j); + if (!inner) continue; + if (inner.type === "lexical_declaration" || inner.type === "variable_declaration") { + for (let k = 0; k < inner.childCount; k++) { + const child = inner.child(k); + if (child && child.type === "variable_declarator") { + processVariableDeclarator(child, importMap); + } + } + } else if (inner.type === "function_declaration") { + processFunctionParams(inner, importMap, parameterMaps); + } + } + break; + } } } diff --git a/src/ast/waste/batch-detector.ts b/src/ast/waste/batch-detector.ts index 0a2868b..ed7ce96 100644 --- a/src/ast/waste/batch-detector.ts +++ b/src/ast/waste/batch-detector.ts @@ -39,6 +39,13 @@ const BATCH_GUARD = const CONCURRENCY_GUARD = /\b(p-limit|bottleneck|semaphore|mutex|throttle|debounce|concurrency\s*:|limit\s*:|pool)\b/i; +/** + * Bounded replication fan-out: `Array.from({ length: N })` creates a fixed-size array + * for parallel calls — the count is intentional (e.g. the API's own `n` param), + * not a naive map over arbitrary data. Not wasteful in the batch sense. + */ +const BOUNDED_REPLICATION = /Array\.from\s*\(\s*\{\s*length\s*:/; + // ── File-path heuristics ────────────────────────────────────────────────────── const TEST_FILE = /(^|\/)(test|tests|spec|stories|storybook|fixtures?|examples?)\//i; @@ -97,6 +104,9 @@ function detectBatch( if (!BATCH_LOOP_FREQS.has(match.frequency)) return null; if (!match.batchCapable) return null; if (hasGuardInWindow(source, match.line, BATCH_GUARD)) return null; + // Array.from({ length: N }) is intentional bounded replication (e.g. using the API's + // own n/count param) — not naive per-item fan-out; skip the batch suggestion. + if (match.frequency === "parallel" && hasGuardInWindow(source, match.line, BOUNDED_REPLICATION)) return null; const evidence: string[] = [ `Call executes in a "${match.frequency}" context — each iteration makes a separate request.`, diff --git a/src/ast/waste/concurrency-detector.ts b/src/ast/waste/concurrency-detector.ts index 34e2930..803aa4b 100644 --- a/src/ast/waste/concurrency-detector.ts +++ b/src/ast/waste/concurrency-detector.ts @@ -149,6 +149,7 @@ function detectUnboundedConcurrency( isHotPath: boolean ): LocalWasteFinding | null { if (match.frequency !== "parallel") return null; + if (match.batchCapable === true) return null; // let batch detector handle it const win = windowText(source, match.line); if (CONCURRENCY_GUARD.test(win)) return null; diff --git a/src/scanner/core-scanner.ts b/src/scanner/core-scanner.ts index f64b527..60ca52c 100644 --- a/src/scanner/core-scanner.ts +++ b/src/scanner/core-scanner.ts @@ -157,58 +157,124 @@ function astMatchToApiCallInput(match: AstCallMatch, file: string): ApiCallInput }; } -export async function scanFiles( +// ── Internal result type for the shared AST gather helper ──────────────────── + +interface ResolvedFileResult { + filePath: string; + relativePath: string; + source: string; + matches: AstCallMatch[]; + astSucceeded: boolean; +} + +/** + * Scan all files in `access`, run cross-file resolution over the AST results, + * and return augmented per-file data. Files without AST coverage (unsupported + * extension) are still included with empty `matches` so the regex pass in + * `scanFiles()` can still see their source. + * + * This is the single call-site for `runCrossFileResolution` — both `scanFiles` + * and `detectLocalWastePatternsInFiles` delegate to it so resolution is applied + * consistently across all consumers. + */ +async function gatherResolvedAstMatches( access: ScanFileAccess, onProgress?: (progress: ScanProgress) => void -): Promise { +): Promise { const files = [...access.files].sort((a, b) => a.relativePath.localeCompare(b.relativePath)); - const allCalls: ApiCallInput[] = []; - const dedupe = new Set(); + const perFileResults: PerFileResult[] = []; + // Track all files including non-AST ones so regex passes still see them. + const allFiles: Array<{ filePath: string; relativePath: string; source: string; hasAst: boolean }> = []; for (let i = 0; i < files.length; i++) { const entry = files[i]; - try { const text = await access.readFile(entry.absolutePath); - const lines = text.split("\n"); - - const astCoveredLines = new Set(); const ext = path.extname(entry.relativePath); if (getLanguageForExtension(ext)) { try { - const astResult = await scanFileWithAst(entry.absolutePath, async (fp: string) => { - try { - return await access.readFile(fp); - } catch { - return null; - } + const result = await scanFileWithAst(entry.absolutePath, async (fp: string) => { + try { return await access.readFile(fp); } catch { return null; } }); - for (const match of astResult.matches) { - // Phase 1: skip stdlib, framework, and build-tool imports - if (match.packageName && STDLIB_DENYLIST.has(match.packageName)) continue; - - // Phase 2: require a registry match — drop silently if nothing is registered - const fp = (match.provider && match.methodChain) - ? lookupMethod(match.provider, match.methodChain) - : null; - const knownSdkProvider = match.provider ? isRegisteredProvider(match.provider) : false; - // http-kind: match.provider is set iff lookupHost() already resolved the host in ast-scanner - const knownHttpHost = match.kind === "http" && !!match.provider; - if (!fp && !knownSdkProvider && !knownHttpHost) continue; - - const apiCall = astMatchToApiCallInput(match, entry.relativePath); - const key = `${entry.relativePath}:${match.line}:${apiCall.method}:${apiCall.url}`; - if (dedupe.has(key)) continue; - dedupe.add(key); - astCoveredLines.add(match.line); - allCalls.push(apiCall); - } + perFileResults.push({ + filePath: entry.absolutePath, + relativePath: entry.relativePath, + source: text, + result, + }); + allFiles.push({ filePath: entry.absolutePath, relativePath: entry.relativePath, source: text, hasAst: true }); } catch { - // AST failed — fall through to regex-only for this file + // AST failed — include with empty matches so regex pass still runs + allFiles.push({ filePath: entry.absolutePath, relativePath: entry.relativePath, source: text, hasAst: false }); } + } else { + // Non-AST extension — include for regex-only processing + allFiles.push({ filePath: entry.absolutePath, relativePath: entry.relativePath, source: text, hasAst: false }); } + } catch { + // Skip unreadable files entirely + } + onProgress?.({ file: entry.relativePath, fileIndex: i + 1, fileTotal: files.length }); + } + + // Run cross-file resolution over all successfully parsed files. + let augmented: Map; + try { + augmented = runCrossFileResolution(perFileResults); + } catch (err) { + console.warn(`[recost] cross-file resolution failed; using per-file matches:`, err); + augmented = new Map(perFileResults.map((pf) => [pf.relativePath, pf.result.matches])); + } - for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + // Build the final per-file result, merging augmented AST matches back in. + return allFiles.map((f) => ({ + filePath: f.filePath, + relativePath: f.relativePath, + source: f.source, + matches: augmented.get(f.relativePath) ?? [], + astSucceeded: f.hasAst, + })); +} + +export async function scanFiles( + access: ScanFileAccess, + onProgress?: (progress: ScanProgress) => void +): Promise { + const allCalls: ApiCallInput[] = []; + const dedupe = new Set(); + + // Gather all files with cross-file-resolved AST matches in a single pass. + const resolvedFiles = await gatherResolvedAstMatches(access, onProgress); + + for (const rf of resolvedFiles) { + const { relativePath: relPath, source: text, matches: astMatches } = rf; + const lines = text.split("\n"); + + const astCoveredLines = new Set(); + + // Process AST matches (already cross-file-resolved). + for (const match of astMatches) { + // Phase 1: skip stdlib, framework, and build-tool imports + if (match.packageName && STDLIB_DENYLIST.has(match.packageName)) continue; + + // Phase 2: require a registry match — drop silently if nothing is registered + const fp = (match.provider && match.methodChain) + ? lookupMethod(match.provider, match.methodChain) + : null; + const knownSdkProvider = match.provider ? isRegisteredProvider(match.provider) : false; + // http-kind: match.provider is set iff lookupHost() already resolved the host in ast-scanner + const knownHttpHost = match.kind === "http" && !!match.provider; + if (!fp && !knownSdkProvider && !knownHttpHost) continue; + + const apiCall = astMatchToApiCallInput(match, relPath); + const key = `${relPath}:${match.line}:${apiCall.method}:${apiCall.url}`; + if (dedupe.has(key)) continue; + dedupe.add(key); + astCoveredLines.add(match.line); + allCalls.push(apiCall); + } + + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { const lineNum = lineIndex + 1; if (astCoveredLines.has(lineNum)) continue; @@ -216,7 +282,7 @@ export async function scanFiles( const routeMatches = matchRouteDefinitionLine(line); for (const route of routeMatches) { if (!isHighConfidenceUrl(route.url)) continue; - const key = `${entry.relativePath}:${lineNum}:${route.method}:${route.url}:${route.library}`; + const key = `${relPath}:${lineNum}:${route.method}:${route.url}:${route.library}`; if (dedupe.has(key)) continue; dedupe.add(key); // span: regex matched a substring on this line; we can't recover the @@ -229,7 +295,7 @@ export async function scanFiles( endColumn: line.length, }; allCalls.push({ - file: entry.relativePath, + file: relPath, line: lineNum, span, method: route.method, @@ -297,7 +363,7 @@ export async function scanFiles( const reportedLineNum = reportedLineIndex + 1; const reportedLine = lines[reportedLineIndex] ?? line; - const key = `${entry.relativePath}:${reportedLineNum}:${match.method}:${match.url}:${match.library}`; + const key = `${relPath}:${reportedLineNum}:${match.method}:${match.url}:${match.library}`; if (dedupe.has(key)) continue; dedupe.add(key); // span: regex matched a substring on this line; we can't recover the @@ -310,7 +376,7 @@ export async function scanFiles( endColumn: reportedLine.length, }; allCalls.push({ - file: entry.relativePath, + file: relPath, line: reportedLineNum, span, method: match.method, @@ -319,83 +385,39 @@ export async function scanFiles( frequency: isInsideLoop(lines, reportedLineIndex) ? "per-request" : "daily", }); } - } - } catch { - // Skip files that can't be read } - - onProgress?.({ - file: entry.relativePath, - fileIndex: i + 1, - fileTotal: files.length, - }); } return allCalls; } export async function detectLocalWastePatternsInFiles(access: ScanFileAccess): Promise { - const files = [...access.files].sort((a, b) => a.relativePath.localeCompare(b.relativePath)); - const perFileResults: PerFileResult[] = []; - const nonAstFindings: LocalWasteFinding[] = []; - - for (const entry of files) { - try { - const text = await access.readFile(entry.absolutePath); - const ext = path.extname(entry.relativePath); - - if (getLanguageForExtension(ext)) { - try { - const result = await scanFileWithAst(entry.absolutePath, async (fp: string) => { - try { - return await access.readFile(fp); - } catch { - return null; - } - }); - perFileResults.push({ - filePath: entry.absolutePath, - relativePath: entry.relativePath, - source: text, - result, - }); - } catch { - nonAstFindings.push(...detectLocalWasteFindingsInText(entry.relativePath, text)); - } - } else { - nonAstFindings.push(...detectLocalWasteFindingsInText(entry.relativePath, text)); - } - } catch { - // Skip files that can't be read - } - } - - let augmented: Map; - try { - augmented = runCrossFileResolution(perFileResults); - } catch { - augmented = new Map(perFileResults.map((pf) => [pf.relativePath, pf.result.matches])); - } + // Use the shared helper so cross-file resolution is applied in one place. + const resolvedFiles = await gatherResolvedAstMatches(access); const astFindings: LocalWasteFinding[] = []; - for (const pf of perFileResults) { - const ext = path.extname(pf.relativePath).toLowerCase(); - const rawMatches = augmented.get(pf.relativePath) ?? pf.result.matches; + const nonAstFindings: LocalWasteFinding[] = []; + + for (const rf of resolvedFiles) { + const ext = path.extname(rf.relativePath).toLowerCase(); - if (JS_TS_EXTENSIONS.has(ext)) { + if (rf.astSucceeded && JS_TS_EXTENSIONS.has(ext)) { // Phase 1 gate only: remove stdlib, framework, and build-tool calls. // Phase 2 (registry match) is intentionally NOT applied here — the waste // detectors do code pattern analysis and do not require a known provider match. - const matches = rawMatches.filter((match) => { + const matches = rf.matches.filter((match) => { if (match.packageName && STDLIB_DENYLIST.has(match.packageName)) return false; return true; }); - astFindings.push(...detectCacheWaste(matches, pf.source, pf.relativePath)); - astFindings.push(...detectBatchWaste(matches, pf.source, pf.relativePath)); - astFindings.push(...detectConcurrencyWaste(matches, pf.source, pf.relativePath)); - } else if (PYTHON_EXTENSIONS.has(ext)) { - astFindings.push(...detectPythonWaste(rawMatches, pf.source, pf.relativePath)); + astFindings.push(...detectCacheWaste(matches, rf.source, rf.relativePath)); + astFindings.push(...detectBatchWaste(matches, rf.source, rf.relativePath)); + astFindings.push(...detectConcurrencyWaste(matches, rf.source, rf.relativePath)); + } else if (rf.astSucceeded && PYTHON_EXTENSIONS.has(ext)) { + astFindings.push(...detectPythonWaste(rf.matches, rf.source, rf.relativePath)); + } else { + // AST failed (or non-AST extension) → regex text fallback + nonAstFindings.push(...detectLocalWasteFindingsInText(rf.relativePath, rf.source)); } } diff --git a/src/scanner/fingerprints/openai.json b/src/scanner/fingerprints/openai.json index b11692c..511259f 100644 --- a/src/scanner/fingerprints/openai.json +++ b/src/scanner/fingerprints/openai.json @@ -54,6 +54,7 @@ "endpoint": "https://api.openai.com/v1/images/generations", "costModel": "per_request", "fixedFee": 0.04, + "batchCapable": true, "description": "Image generation (DALL-E 3 1024×1024 standard)" }, { diff --git a/src/test/a3-barrel-reexports.test.ts b/src/test/a3-barrel-reexports.test.ts new file mode 100644 index 0000000..27182fe --- /dev/null +++ b/src/test/a3-barrel-reexports.test.ts @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import * as path from "node:path"; +import * as fs from "node:fs"; +import { setWasmDir } from "../ast/parser-loader"; +import { scanFiles, type ScanFileAccess, type ScanInputFile } from "../scanner/core-scanner"; + +const WASM_DIR = path.join(__dirname, "..", "..", "assets", "parsers"); +setWasmDir(WASM_DIR); + +async function run(name: string, fn: () => void | Promise): Promise { + try { + await fn(); + console.log(`PASS ${name}`); + } catch (err) { + console.error(`FAIL ${name}`); + throw err; + } +} + +function buildFixtureAccess(fixtureDir: string): ScanFileAccess { + const entries = fs.readdirSync(fixtureDir, { recursive: true }) as string[]; + const files: ScanInputFile[] = entries + .filter((entry) => typeof entry === "string" && (entry.endsWith(".ts") || entry.endsWith(".js"))) + .map((relName) => ({ + absolutePath: path.join(fixtureDir, relName), + relativePath: relName.replace(/\\/g, "/"), + })); + return { + files, + readFile: async (absolutePath: string) => fs.readFileSync(absolutePath, "utf-8"), + }; +} + +(async () => { + const projectRoot = path.resolve(__dirname, "..", ".."); + const root = path.resolve(projectRoot, "src", "test", "fixtures", "a3-a5"); + + await run("A3.0 baseline: direct re-export `export { x } from './foo'` resolves to openai", async () => { + const calls = await scanFiles(buildFixtureAccess(path.join(root, "barrel-direct"))); + const consumerCalls = calls.filter((c) => c.file.endsWith("consumer.ts")); + const openaiCalls = consumerCalls.filter((c) => c.provider === "openai"); + assert.ok( + openaiCalls.length >= 1, + `baseline failed: got ${openaiCalls.length} openai calls from consumer.ts: ${JSON.stringify(consumerCalls.map((c) => ({ line: c.line, provider: c.provider })))}` + ); + }); + + await run("A3.audit.aliased: `export { x as y }` re-export resolves consumer call to openai", async () => { + const calls = await scanFiles(buildFixtureAccess(path.join(root, "barrel-aliased"))); + const consumerCalls = calls.filter((c) => c.file.endsWith("consumer.ts")); + const openaiCalls = consumerCalls.filter((c) => c.provider === "openai"); + assert.ok(openaiCalls.length >= 1, `aliased re-export failed: got ${openaiCalls.length} calls: ${JSON.stringify(consumerCalls.map((c) => ({ line: c.line, provider: c.provider })))}`); + }); + + await run("A3.audit.wildcard: `export *` re-export resolves consumer call to openai", async () => { + const calls = await scanFiles(buildFixtureAccess(path.join(root, "barrel-wildcard"))); + const consumerCalls = calls.filter((c) => c.file.endsWith("consumer.ts")); + const openaiCalls = consumerCalls.filter((c) => c.provider === "openai"); + assert.ok(openaiCalls.length >= 1, `wildcard re-export failed: got ${openaiCalls.length} calls`); + }); + + await run("A3.audit.nested: 2-level nested barrels (`index → providers → openai`) resolve consumer call", async () => { + const calls = await scanFiles(buildFixtureAccess(path.join(root, "barrel-nested"))); + const consumerCalls = calls.filter((c) => c.file.endsWith("consumer.ts")); + const openaiCalls = consumerCalls.filter((c) => c.provider === "openai"); + assert.ok(openaiCalls.length >= 1, `nested barrel failed: got ${openaiCalls.length} calls`); + }); + + await run("A3.audit.default: `export { default } from` resolves consumer's default import to openai", async () => { + const calls = await scanFiles(buildFixtureAccess(path.join(root, "barrel-default"))); + const consumerCalls = calls.filter((c) => c.file.endsWith("consumer.ts")); + const openaiCalls = consumerCalls.filter((c) => c.provider === "openai"); + assert.ok(openaiCalls.length >= 1, `default re-export failed: got ${openaiCalls.length} calls`); + }); + + await run("A3.audit.missing: barrel re-exports a non-existent symbol; scan completes without throwing", async () => { + const calls = await scanFiles(buildFixtureAccess(path.join(root, "barrel-missing"))); + assert.ok(Array.isArray(calls), "scanFiles must return an array even with broken barrels"); + }); + + await run("A3.audit.wildcard-then-named: wildcard barrel followed by named re-export resolves `ask` via the second entry", async () => { + const calls = await scanFiles(buildFixtureAccess(path.join(root, "barrel-wildcard-then-named"))); + const consumerCalls = calls.filter((c) => c.file.endsWith("consumer.ts")); + const openaiCalls = consumerCalls.filter((c) => c.provider === "openai"); + assert.ok(openaiCalls.length >= 1, `multi-entry wildcard barrel failed: got ${openaiCalls.length} calls`); + }); +})().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/test/a5-factory-di-aliased.test.ts b/src/test/a5-factory-di-aliased.test.ts new file mode 100644 index 0000000..2c3f4c2 --- /dev/null +++ b/src/test/a5-factory-di-aliased.test.ts @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import * as path from "node:path"; +import * as fs from "node:fs"; +import { setWasmDir } from "../ast/parser-loader"; +import { scanFiles, type ScanFileAccess, type ScanInputFile } from "../scanner/core-scanner"; + +const WASM_DIR = path.join(__dirname, "..", "..", "assets", "parsers"); +setWasmDir(WASM_DIR); + +async function run(name: string, fn: () => void | Promise): Promise { + try { + await fn(); + console.log(`PASS ${name}`); + } catch (err) { + console.error(`FAIL ${name}`); + throw err; + } +} + +function buildFixtureAccess(fixtureDir: string): ScanFileAccess { + const entries = fs.readdirSync(fixtureDir, { recursive: true }) as string[]; + const files: ScanInputFile[] = entries + .filter((entry) => typeof entry === "string" && (entry.endsWith(".ts") || entry.endsWith(".js"))) + .map((relName) => ({ + absolutePath: path.join(fixtureDir, relName), + relativePath: relName.replace(/\\/g, "/"), + })); + return { + files, + readFile: async (absolutePath: string) => fs.readFileSync(absolutePath, "utf-8"), + }; +} + +(async () => { + const projectRoot = path.resolve(__dirname, "..", ".."); + const root = path.resolve(projectRoot, "src", "test", "fixtures", "a5"); + + await run("A5.audit.bind: `.bind()`-aliased method ref resolves to openai", async () => { + const calls = await scanFiles(buildFixtureAccess(path.join(root, "bind-aliased"))); + const consumerCalls = calls.filter((c) => c.file.endsWith("consumer.ts")); + const openaiCalls = consumerCalls.filter((c) => c.provider === "openai"); + assert.ok(openaiCalls.length >= 1, `bind alias failed: got ${openaiCalls.length} calls`); + }); + + await run("A5.audit.factory: cross-file factory `makeClient()` return resolves to openai", async () => { + const calls = await scanFiles(buildFixtureAccess(path.join(root, "factory-direct"))); + const consumerCalls = calls.filter((c) => c.file.endsWith("consumer.ts")); + const openaiCalls = consumerCalls.filter((c) => c.provider === "openai"); + assert.ok(openaiCalls.length >= 1, `factory return failed: got ${openaiCalls.length} calls`); + }); + + await run("A5.audit.di: typed constructor param `private ai: OpenAI` resolves `this.ai.method()` to openai", async () => { + const calls = await scanFiles(buildFixtureAccess(path.join(root, "di-constructor"))); + const consumerCalls = calls.filter((c) => c.file.endsWith("consumer.ts")); + const openaiCalls = consumerCalls.filter((c) => c.provider === "openai"); + assert.ok(openaiCalls.length >= 1, `DI constructor failed: got ${openaiCalls.length} calls`); + }); + + await run("A5.regress: simple `const c = new OpenAI(); c.method()` still resolves (no regression from A5 changes)", async () => { + const tmpDir = path.join(root, "_simple-regression"); + fs.mkdirSync(tmpDir, { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, "consumer.ts"), + [ + 'import OpenAI from "openai";', + "", + "const client = new OpenAI();", + "", + "export async function ask(p: string): Promise {", + " const r = await client.chat.completions.create({", + ' model: "gpt-4o-mini",', + ' messages: [{ role: "user", content: p }],', + " });", + ' return r.choices[0].message.content ?? "";', + "}", + "", + ].join("\n") + ); + try { + const calls = await scanFiles(buildFixtureAccess(tmpDir)); + const consumerCalls = calls.filter((c) => c.file.endsWith("consumer.ts")); + assert.ok( + consumerCalls.some((c) => c.provider === "openai"), + "simple new OpenAI() must still resolve" + ); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + await run("A5.regress.factory-nested: factory with nested helper functions still resolves to outer return type", async () => { + const calls = await scanFiles(buildFixtureAccess(path.join(root, "factory-nested"))); + const consumerCalls = calls.filter((c) => c.file.endsWith("consumer.ts")); + const openaiCalls = consumerCalls.filter((c) => c.provider === "openai"); + const anthropicCalls = consumerCalls.filter((c) => c.provider === "anthropic"); + assert.ok(openaiCalls.length >= 1, `expected openai attribution from factory's outer return, got ${openaiCalls.length} openai + ${anthropicCalls.length} anthropic`); + assert.equal(anthropicCalls.length, 0, `nested helper's Anthropic must not pollute factory attribution; got ${anthropicCalls.length}`); + }); +})().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/test/ast-cross-file-resolver.test.ts b/src/test/ast-cross-file-resolver.test.ts index f39c585..9034547 100644 --- a/src/test/ast-cross-file-resolver.test.ts +++ b/src/test/ast-cross-file-resolver.test.ts @@ -46,6 +46,7 @@ function makeResult(overrides: Partial = {}): AstScanResult { matches: [], classRegistry: new Map(), middlewareQueue: [], + factoryReturnMap: new Map(), ...overrides, }; } diff --git a/src/test/fixtures/a3-a5/barrel-aliased/api.ts b/src/test/fixtures/a3-a5/barrel-aliased/api.ts new file mode 100644 index 0000000..3632b27 --- /dev/null +++ b/src/test/fixtures/a3-a5/barrel-aliased/api.ts @@ -0,0 +1,11 @@ +import OpenAI from "openai"; + +const client = new OpenAI(); + +export async function _internalAsk(prompt: string): Promise { + const r = await client.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: prompt }], + }); + return r.choices[0].message.content ?? ""; +} diff --git a/src/test/fixtures/a3-a5/barrel-aliased/consumer.ts b/src/test/fixtures/a3-a5/barrel-aliased/consumer.ts new file mode 100644 index 0000000..3a7f22b --- /dev/null +++ b/src/test/fixtures/a3-a5/barrel-aliased/consumer.ts @@ -0,0 +1,5 @@ +import { ask } from "./index"; + +export async function handle(q: string): Promise { + return ask(q); +} diff --git a/src/test/fixtures/a3-a5/barrel-aliased/index.ts b/src/test/fixtures/a3-a5/barrel-aliased/index.ts new file mode 100644 index 0000000..c566395 --- /dev/null +++ b/src/test/fixtures/a3-a5/barrel-aliased/index.ts @@ -0,0 +1 @@ +export { _internalAsk as ask } from "./api"; diff --git a/src/test/fixtures/a3-a5/barrel-default/api.ts b/src/test/fixtures/a3-a5/barrel-default/api.ts new file mode 100644 index 0000000..420d7a2 --- /dev/null +++ b/src/test/fixtures/a3-a5/barrel-default/api.ts @@ -0,0 +1,11 @@ +import OpenAI from "openai"; + +const client = new OpenAI(); + +export default async function ask(prompt: string): Promise { + const r = await client.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: prompt }], + }); + return r.choices[0].message.content ?? ""; +} diff --git a/src/test/fixtures/a3-a5/barrel-default/consumer.ts b/src/test/fixtures/a3-a5/barrel-default/consumer.ts new file mode 100644 index 0000000..8d0f4c8 --- /dev/null +++ b/src/test/fixtures/a3-a5/barrel-default/consumer.ts @@ -0,0 +1,5 @@ +import ask from "./index"; + +export async function handle(q: string): Promise { + return ask(q); +} diff --git a/src/test/fixtures/a3-a5/barrel-default/index.ts b/src/test/fixtures/a3-a5/barrel-default/index.ts new file mode 100644 index 0000000..9e19686 --- /dev/null +++ b/src/test/fixtures/a3-a5/barrel-default/index.ts @@ -0,0 +1 @@ +export { default } from "./api"; diff --git a/src/test/fixtures/a3-a5/barrel-direct/api.ts b/src/test/fixtures/a3-a5/barrel-direct/api.ts new file mode 100644 index 0000000..ec00112 --- /dev/null +++ b/src/test/fixtures/a3-a5/barrel-direct/api.ts @@ -0,0 +1,11 @@ +import OpenAI from "openai"; + +const client = new OpenAI(); + +export async function ask(prompt: string): Promise { + const r = await client.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: prompt }], + }); + return r.choices[0].message.content ?? ""; +} diff --git a/src/test/fixtures/a3-a5/barrel-direct/consumer.ts b/src/test/fixtures/a3-a5/barrel-direct/consumer.ts new file mode 100644 index 0000000..3a7f22b --- /dev/null +++ b/src/test/fixtures/a3-a5/barrel-direct/consumer.ts @@ -0,0 +1,5 @@ +import { ask } from "./index"; + +export async function handle(q: string): Promise { + return ask(q); +} diff --git a/src/test/fixtures/a3-a5/barrel-direct/index.ts b/src/test/fixtures/a3-a5/barrel-direct/index.ts new file mode 100644 index 0000000..1851a98 --- /dev/null +++ b/src/test/fixtures/a3-a5/barrel-direct/index.ts @@ -0,0 +1 @@ +export { ask } from "./api"; diff --git a/src/test/fixtures/a3-a5/barrel-missing/api.ts b/src/test/fixtures/a3-a5/barrel-missing/api.ts new file mode 100644 index 0000000..ec00112 --- /dev/null +++ b/src/test/fixtures/a3-a5/barrel-missing/api.ts @@ -0,0 +1,11 @@ +import OpenAI from "openai"; + +const client = new OpenAI(); + +export async function ask(prompt: string): Promise { + const r = await client.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: prompt }], + }); + return r.choices[0].message.content ?? ""; +} diff --git a/src/test/fixtures/a3-a5/barrel-missing/consumer.ts b/src/test/fixtures/a3-a5/barrel-missing/consumer.ts new file mode 100644 index 0000000..6fb86c4 --- /dev/null +++ b/src/test/fixtures/a3-a5/barrel-missing/consumer.ts @@ -0,0 +1,5 @@ +import { summarize } from "./index"; + +export async function handle(q: string): Promise { + return summarize(q); +} diff --git a/src/test/fixtures/a3-a5/barrel-missing/index.ts b/src/test/fixtures/a3-a5/barrel-missing/index.ts new file mode 100644 index 0000000..5961e8f --- /dev/null +++ b/src/test/fixtures/a3-a5/barrel-missing/index.ts @@ -0,0 +1 @@ +export { ask, summarize } from "./api"; diff --git a/src/test/fixtures/a3-a5/barrel-nested/consumer.ts b/src/test/fixtures/a3-a5/barrel-nested/consumer.ts new file mode 100644 index 0000000..3a7f22b --- /dev/null +++ b/src/test/fixtures/a3-a5/barrel-nested/consumer.ts @@ -0,0 +1,5 @@ +import { ask } from "./index"; + +export async function handle(q: string): Promise { + return ask(q); +} diff --git a/src/test/fixtures/a3-a5/barrel-nested/index.ts b/src/test/fixtures/a3-a5/barrel-nested/index.ts new file mode 100644 index 0000000..2f9162c --- /dev/null +++ b/src/test/fixtures/a3-a5/barrel-nested/index.ts @@ -0,0 +1 @@ +export { ask } from "./providers"; diff --git a/src/test/fixtures/a3-a5/barrel-nested/providers/index.ts b/src/test/fixtures/a3-a5/barrel-nested/providers/index.ts new file mode 100644 index 0000000..4787120 --- /dev/null +++ b/src/test/fixtures/a3-a5/barrel-nested/providers/index.ts @@ -0,0 +1 @@ +export { ask } from "./openai"; diff --git a/src/test/fixtures/a3-a5/barrel-nested/providers/openai.ts b/src/test/fixtures/a3-a5/barrel-nested/providers/openai.ts new file mode 100644 index 0000000..ec00112 --- /dev/null +++ b/src/test/fixtures/a3-a5/barrel-nested/providers/openai.ts @@ -0,0 +1,11 @@ +import OpenAI from "openai"; + +const client = new OpenAI(); + +export async function ask(prompt: string): Promise { + const r = await client.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: prompt }], + }); + return r.choices[0].message.content ?? ""; +} diff --git a/src/test/fixtures/a3-a5/barrel-wildcard-then-named/consumer.ts b/src/test/fixtures/a3-a5/barrel-wildcard-then-named/consumer.ts new file mode 100644 index 0000000..407c7c6 --- /dev/null +++ b/src/test/fixtures/a3-a5/barrel-wildcard-then-named/consumer.ts @@ -0,0 +1,6 @@ +import { ask } from "./index"; + +async function main() { + const answer = await ask("Hello, world!"); + console.log(answer); +} diff --git a/src/test/fixtures/a3-a5/barrel-wildcard-then-named/index.ts b/src/test/fixtures/a3-a5/barrel-wildcard-then-named/index.ts new file mode 100644 index 0000000..1b08eab --- /dev/null +++ b/src/test/fixtures/a3-a5/barrel-wildcard-then-named/index.ts @@ -0,0 +1,4 @@ +// Wildcard first — wildcard-source does NOT export `ask`. +export * from "./wildcard-source"; +// Named re-export second — named-source DOES export `ask`. +export { ask } from "./named-source"; diff --git a/src/test/fixtures/a3-a5/barrel-wildcard-then-named/named-source.ts b/src/test/fixtures/a3-a5/barrel-wildcard-then-named/named-source.ts new file mode 100644 index 0000000..0a62920 --- /dev/null +++ b/src/test/fixtures/a3-a5/barrel-wildcard-then-named/named-source.ts @@ -0,0 +1,11 @@ +import OpenAI from "openai"; + +const client = new OpenAI(); + +export async function ask(prompt: string): Promise { + const res = await client.chat.completions.create({ + model: "gpt-4o", + messages: [{ role: "user", content: prompt }], + }); + return res.choices[0]?.message?.content ?? ""; +} diff --git a/src/test/fixtures/a3-a5/barrel-wildcard-then-named/wildcard-source.ts b/src/test/fixtures/a3-a5/barrel-wildcard-then-named/wildcard-source.ts new file mode 100644 index 0000000..dc42abe --- /dev/null +++ b/src/test/fixtures/a3-a5/barrel-wildcard-then-named/wildcard-source.ts @@ -0,0 +1,4 @@ +// This file does NOT export `ask` — only an unrelated symbol. +export function unrelated(): string { + return "unrelated"; +} diff --git a/src/test/fixtures/a3-a5/barrel-wildcard/api.ts b/src/test/fixtures/a3-a5/barrel-wildcard/api.ts new file mode 100644 index 0000000..ec00112 --- /dev/null +++ b/src/test/fixtures/a3-a5/barrel-wildcard/api.ts @@ -0,0 +1,11 @@ +import OpenAI from "openai"; + +const client = new OpenAI(); + +export async function ask(prompt: string): Promise { + const r = await client.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: prompt }], + }); + return r.choices[0].message.content ?? ""; +} diff --git a/src/test/fixtures/a3-a5/barrel-wildcard/consumer.ts b/src/test/fixtures/a3-a5/barrel-wildcard/consumer.ts new file mode 100644 index 0000000..3a7f22b --- /dev/null +++ b/src/test/fixtures/a3-a5/barrel-wildcard/consumer.ts @@ -0,0 +1,5 @@ +import { ask } from "./index"; + +export async function handle(q: string): Promise { + return ask(q); +} diff --git a/src/test/fixtures/a3-a5/barrel-wildcard/index.ts b/src/test/fixtures/a3-a5/barrel-wildcard/index.ts new file mode 100644 index 0000000..d158c57 --- /dev/null +++ b/src/test/fixtures/a3-a5/barrel-wildcard/index.ts @@ -0,0 +1 @@ +export * from "./api"; diff --git a/src/test/fixtures/a5/bind-aliased/consumer.ts b/src/test/fixtures/a5/bind-aliased/consumer.ts new file mode 100644 index 0000000..2be6097 --- /dev/null +++ b/src/test/fixtures/a5/bind-aliased/consumer.ts @@ -0,0 +1,12 @@ +import OpenAI from "openai"; + +const client = new OpenAI(); +const askFn = client.chat.completions.create.bind(client.chat.completions); + +export async function ask(prompt: string): Promise { + const r = await askFn({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: prompt }], + }); + return r.choices[0].message.content ?? ""; +} diff --git a/src/test/fixtures/a5/di-constructor/consumer.ts b/src/test/fixtures/a5/di-constructor/consumer.ts new file mode 100644 index 0000000..ea9be04 --- /dev/null +++ b/src/test/fixtures/a5/di-constructor/consumer.ts @@ -0,0 +1,13 @@ +import OpenAI from "openai"; + +export class SummaryService { + constructor(private readonly ai: OpenAI) {} + + async summarize(text: string): Promise { + const r = await this.ai.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: `Summarize: ${text}` }], + }); + return r.choices[0].message.content ?? ""; + } +} diff --git a/src/test/fixtures/a5/factory-direct/client-factory.ts b/src/test/fixtures/a5/factory-direct/client-factory.ts new file mode 100644 index 0000000..87d1550 --- /dev/null +++ b/src/test/fixtures/a5/factory-direct/client-factory.ts @@ -0,0 +1,5 @@ +import OpenAI from "openai"; + +export function makeClient(): OpenAI { + return new OpenAI(); +} diff --git a/src/test/fixtures/a5/factory-direct/consumer.ts b/src/test/fixtures/a5/factory-direct/consumer.ts new file mode 100644 index 0000000..905b3cb --- /dev/null +++ b/src/test/fixtures/a5/factory-direct/consumer.ts @@ -0,0 +1,11 @@ +import { makeClient } from "./client-factory"; + +const client = makeClient(); + +export async function ask(prompt: string): Promise { + const r = await client.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: prompt }], + }); + return r.choices[0].message.content ?? ""; +} diff --git a/src/test/fixtures/a5/factory-nested/client-factory.ts b/src/test/fixtures/a5/factory-nested/client-factory.ts new file mode 100644 index 0000000..eedf5b9 --- /dev/null +++ b/src/test/fixtures/a5/factory-nested/client-factory.ts @@ -0,0 +1,10 @@ +import OpenAI from "openai"; +import Anthropic from "@anthropic-ai/sdk"; + +export function makeClient(): OpenAI { + // Nested helper that returns an Anthropic instance — must NOT confuse the + // factory detector into thinking makeClient() returns Anthropic. + const _helper = () => new Anthropic(); + void _helper; + return new OpenAI(); +} diff --git a/src/test/fixtures/a5/factory-nested/consumer.ts b/src/test/fixtures/a5/factory-nested/consumer.ts new file mode 100644 index 0000000..7b7cc6c --- /dev/null +++ b/src/test/fixtures/a5/factory-nested/consumer.ts @@ -0,0 +1,9 @@ +import { makeClient } from "./client-factory"; +const client = makeClient(); +export async function ask(prompt: string): Promise { + const r = await client.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: prompt }], + }); + return r.choices[0].message.content ?? ""; +} diff --git a/src/test/fixtures/pre-b/exported-anthropic.ts b/src/test/fixtures/pre-b/exported-anthropic.ts new file mode 100644 index 0000000..9bab8e6 --- /dev/null +++ b/src/test/fixtures/pre-b/exported-anthropic.ts @@ -0,0 +1,11 @@ +import Anthropic from "@anthropic-ai/sdk"; + +export const client = new Anthropic(); + +export async function chat(text: string) { + return client.messages.create({ + model: "claude-opus-4-5", + max_tokens: 1024, + messages: [{ role: "user", content: text }], + }); +} diff --git a/src/test/fixtures/pre-b/exported-client.ts b/src/test/fixtures/pre-b/exported-client.ts new file mode 100644 index 0000000..c4b7301 --- /dev/null +++ b/src/test/fixtures/pre-b/exported-client.ts @@ -0,0 +1,11 @@ +import OpenAI from "openai"; + +export const apiClient = new OpenAI(); + +export async function ask(prompt: string): Promise { + const r = await apiClient.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: prompt }], + }); + return r.choices[0].message.content ?? ""; +} diff --git a/src/test/fixtures/pre-b/plain-client.ts b/src/test/fixtures/pre-b/plain-client.ts new file mode 100644 index 0000000..682b7b6 --- /dev/null +++ b/src/test/fixtures/pre-b/plain-client.ts @@ -0,0 +1,11 @@ +import OpenAI from "openai"; + +const apiClient = new OpenAI(); + +export async function ask(prompt: string): Promise { + const r = await apiClient.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: prompt }], + }); + return r.choices[0].message.content ?? ""; +} diff --git a/src/test/pre-a-scanfiles-resolution.test.ts b/src/test/pre-a-scanfiles-resolution.test.ts new file mode 100644 index 0000000..6d48c75 --- /dev/null +++ b/src/test/pre-a-scanfiles-resolution.test.ts @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import * as path from "node:path"; +import * as fs from "node:fs"; +import { setWasmDir } from "../ast/parser-loader"; +import { scanFiles, type ScanFileAccess, type ScanInputFile } from "../scanner/core-scanner"; + +const WASM_DIR = path.join(__dirname, "..", "..", "assets", "parsers"); +setWasmDir(WASM_DIR); + +async function run(name: string, fn: () => void | Promise): Promise { + try { await fn(); console.log(`PASS ${name}`); } + catch (err) { console.error(`FAIL ${name}`); throw err; } +} + +function buildFixtureAccess(fixtureDir: string): ScanFileAccess { + const entries = fs.readdirSync(fixtureDir, { recursive: true }) as string[]; + const files: ScanInputFile[] = entries + .filter((entry) => typeof entry === "string" && (entry.endsWith(".ts") || entry.endsWith(".js"))) + .map((relName) => ({ + absolutePath: path.join(fixtureDir, relName), + relativePath: relName.replace(/\\/g, "/"), + })); + return { + files, + readFile: async (absolutePath: string) => fs.readFileSync(absolutePath, "utf-8"), + }; +} + +(async () => { + const projectRoot = path.resolve(__dirname, "..", ".."); + const fixtureDir = path.resolve(projectRoot, "src", "test", "fixtures", "wrappers"); + const calls = await scanFiles(buildFixtureAccess(fixtureDir)); + + await run("Pre-A: scanFiles() output reflects cross-file resolution (callers of wrapper functions get openai provider)", () => { + const level1Calls = calls.filter((c) => c.file.endsWith("level1Entry.ts")); + const openaiCalls = level1Calls.filter((c) => c.provider === "openai"); + assert.ok( + openaiCalls.length >= 1, + `expected >=1 openai call attributed to level1Entry.ts via wrapper resolution, got ${openaiCalls.length}: ${JSON.stringify(level1Calls.map((c) => ({ line: c.line, provider: c.provider, methodSig: c.methodSignature })))}` + ); + }); +})().catch((err) => { console.error(err); process.exit(1); }); diff --git a/src/test/pre-b-export-const-tracking.test.ts b/src/test/pre-b-export-const-tracking.test.ts new file mode 100644 index 0000000..7ee1912 --- /dev/null +++ b/src/test/pre-b-export-const-tracking.test.ts @@ -0,0 +1,106 @@ +/** + * Pre-B: AST scanner must track `export const x = new Sdk()` declarations. + * + * Empirical regression: `export const apiClient = new OpenAI()` was not tracked + * by varMap because the variable-tracking pass only matched bare `lexical_declaration` + * nodes — missing the `export_statement` wrapper that Tree-sitter inserts for + * exported declarations. + */ +import assert from "node:assert/strict"; +import * as path from "node:path"; +import * as fs from "node:fs"; +import { setWasmDir } from "../ast/parser-loader"; +import { scanFileWithAst } from "../ast/ast-scanner"; + +const WASM_DIR = path.join(__dirname, "..", "..", "assets", "parsers"); +setWasmDir(WASM_DIR); + +async function run(name: string, fn: () => void | Promise): Promise { + try { await fn(); console.log(`PASS ${name}`); } + catch (err) { console.error(`FAIL ${name}`); throw err; } +} + +(async () => { + const projectRoot = path.resolve(__dirname, "..", ".."); + const fixtureDir = path.resolve(projectRoot, "src", "test", "fixtures", "pre-b"); + fs.mkdirSync(fixtureDir, { recursive: true }); + + // ── Fixture 1: export const client = new OpenAI() ──────────────────────────── + const fp1 = path.join(fixtureDir, "exported-client.ts"); + fs.writeFileSync(fp1, [ + 'import OpenAI from "openai";', + '', + 'export const apiClient = new OpenAI();', + '', + 'export async function ask(prompt: string): Promise {', + ' const r = await apiClient.chat.completions.create({', + ' model: "gpt-4o-mini",', + ' messages: [{ role: "user", content: prompt }],', + ' });', + ' return r.choices[0].message.content ?? "";', + '}', + '' + ].join("\n")); + + await run("Pre-B: AST scanner tracks `export const x = new OpenAI()` and resolves x.method() to openai", async () => { + const result = await scanFileWithAst(fp1, async (p) => fs.readFileSync(p, "utf-8")); + const openaiMatches = result.matches.filter((m) => m.provider === "openai"); + assert.ok( + openaiMatches.length >= 1, + `expected >=1 openai match in exported-client.ts, got ${openaiMatches.length}: ${JSON.stringify(result.matches.map((m) => ({ line: m.line, provider: m.provider, methodChain: m.methodChain })))}` + ); + }); + + // ── Fixture 2: plain const should still work (regression guard) ─────────────── + const fp2 = path.join(fixtureDir, "plain-client.ts"); + fs.writeFileSync(fp2, [ + 'import OpenAI from "openai";', + '', + 'const apiClient = new OpenAI();', + '', + 'export async function ask(prompt: string): Promise {', + ' const r = await apiClient.chat.completions.create({', + ' model: "gpt-4o-mini",', + ' messages: [{ role: "user", content: prompt }],', + ' });', + ' return r.choices[0].message.content ?? "";', + '}', + '' + ].join("\n")); + + await run("Pre-B (regression): plain `const x = new OpenAI()` still resolves to openai", async () => { + const result = await scanFileWithAst(fp2, async (p) => fs.readFileSync(p, "utf-8")); + const openaiMatches = result.matches.filter((m) => m.provider === "openai"); + assert.ok( + openaiMatches.length >= 1, + `expected >=1 openai match in plain-client.ts, got ${openaiMatches.length}: ${JSON.stringify(result.matches.map((m) => ({ line: m.line, provider: m.provider, methodChain: m.methodChain })))}` + ); + }); + + // ── Fixture 3: export const with Anthropic SDK ──────────────────────────────── + const fp3 = path.join(fixtureDir, "exported-anthropic.ts"); + fs.writeFileSync(fp3, [ + 'import Anthropic from "@anthropic-ai/sdk";', + '', + 'export const client = new Anthropic();', + '', + 'export async function chat(text: string) {', + ' return client.messages.create({', + ' model: "claude-opus-4-5",', + ' max_tokens: 1024,', + ' messages: [{ role: "user", content: text }],', + ' });', + '}', + '' + ].join("\n")); + + await run("Pre-B: AST scanner tracks `export const x = new Anthropic()` and resolves to anthropic", async () => { + const result = await scanFileWithAst(fp3, async (p) => fs.readFileSync(p, "utf-8")); + const anthropicMatches = result.matches.filter((m) => m.provider === "anthropic"); + assert.ok( + anthropicMatches.length >= 1, + `expected >=1 anthropic match in exported-anthropic.ts, got ${anthropicMatches.length}: ${JSON.stringify(result.matches.map((m) => ({ line: m.line, provider: m.provider, methodChain: m.methodChain })))}` + ); + }); + +})().catch((err) => { console.error(err); process.exit(1); });