diff --git a/docs/superpowers/plans/2026-05-27-wave3-resolver-followups.md b/docs/superpowers/plans/2026-05-27-wave3-resolver-followups.md new file mode 100644 index 0000000..ad3c571 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-wave3-resolver-followups.md @@ -0,0 +1,733 @@ +# Wave 3 — Resolver Follow-ups Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close #114 (default-vs-named import disambiguation), #115 (factory-with-arguments resolution), and #116 (split `images.generate` off `batchCapable` into a new `inlineParallelCapable` flag) in one bundled PR. + +**Architecture:** Two file-disjoint tracks run in parallel, then a sequential integration phase wires the new tests into CI and runs the benchmark gate. Track A (#114, #115) lives entirely in `src/ast/cross-file-resolver.ts`. Track B (#116) touches the fingerprint type defs, the AST scanner, `openai.json`, and the two waste detectors. They share **no** source files — the only shared file is `package.json` (test list), deferred to integration. + +**Tech Stack:** TypeScript (strict), web-tree-sitter (WASM, AST scanning), homegrown `run(name, fn)` test harness compiled via `tsconfig.scanner-tests.json` to `dist-test/` and run with `node`. + +--- + +## Parallelization Map + +| Track | Tasks | Files (exclusive) | May run concurrently with | +|-------|-------|-------------------|---------------------------| +| **A** | A1 (#114), A2 (#115) | `src/ast/cross-file-resolver.ts` + `src/test/fixtures/a3-followup/**` + `src/test/a3-default-import-threading.test.ts` + factory fixtures/test | Track B | +| **B** | B1, B2 (#116) | `src/scanner/fingerprints/types.ts`, `src/analysis/types.ts`, `src/ast/ast-scanner.ts`, `src/scanner/fingerprints/openai.json`, `src/ast/waste/concurrency-detector.ts`, `src/ast/waste/batch-detector.ts` + `src/test/ast-inline-parallel.test.ts` | Track A | +| **C** | C1–C3 (integration) | `package.json`, runs full suite + benchmark | after A **and** B | + +**Within Track A, A1 must precede A2** (same file). **Within Track B, B1 must precede B2.** Tracks A and B are independent. + +**Execution note (subagent-driven, parallel):** Dispatch Track A and Track B as two concurrent subagents, each in its own git worktree off `wave3/resolver-followups` (use `superpowers:using-git-worktrees`). Each subagent compiles and runs only its own new test file directly — it does **not** edit `package.json`. After both tracks merge back, run Phase C in the main branch. If running without worktrees, do Track A fully, then Track B, then Phase C. + +**Per-task test command (single file):** +```bash +npx tsc -p tsconfig.scanner-tests.json && node dist-test/test/.test.js +``` +Expected output lines are `PASS ` / `FAIL `; a thrown assertion exits non-zero. + +--- + +## File Structure + +- `src/ast/cross-file-resolver.ts` — **modify.** `ImportedName` interface gains `isDefault`; `extractRelativeImports` populates it; `resolveExportedMatches` gains an `isDefault` param and a rewritten re-export filter; `extractFactoryCallAssignments` regex widened. +- `src/scanner/fingerprints/types.ts` — **modify.** Add `inlineParallelCapable?: boolean` to `MethodFingerprint`. +- `src/analysis/types.ts` — **modify.** Add `inlineParallelCapable?: boolean` to the two shapes that already carry `batchCapable` (lines 19 and 58). +- `src/ast/ast-scanner.ts` — **modify.** Add `inlineParallelCapable?: boolean` to `AstCallMatch`; propagate from fingerprint at the three sites that copy `batchCapable` (~660, ~802, ~826). +- `src/scanner/fingerprints/openai.json` — **modify.** `images.generate`: drop `batchCapable`, add `inlineParallelCapable`. +- `src/ast/waste/concurrency-detector.ts` — **modify.** Line 152 suppress on either flag. +- `src/ast/waste/batch-detector.ts` — **modify.** `detectNPlusOne` guard; new `detectInlineParallel`; register in `detectBatchWaste`. +- `src/test/fixtures/a3-followup/mixed-barrel/**` — **create.** Heterogeneous barrel fixture (5 files). +- `src/test/a3-default-import-threading.test.ts` — **create.** +- `src/test/fixtures/factory-args/**` — **create.** Factory fixture (consumer + factory module). +- `src/test/factory-with-args.test.ts` — **create.** +- `src/test/ast-inline-parallel.test.ts` — **create.** +- `package.json` — **modify (Phase C only).** Append the three new compiled test files to the `test:scanner` chain. + +--- + +## TRACK A — Resolver (#114, #115) + +### Task A1: #114 — default-vs-named disambiguation + +**Files:** +- Create: `src/test/fixtures/a3-followup/mixed-barrel/openai-default.ts` +- Create: `src/test/fixtures/a3-followup/mixed-barrel/anthropic-named.ts` +- Create: `src/test/fixtures/a3-followup/mixed-barrel/barrel.ts` +- Create: `src/test/fixtures/a3-followup/mixed-barrel/consumer.ts` +- Create: `src/test/a3-default-import-threading.test.ts` +- Modify: `src/ast/cross-file-resolver.ts` (`ImportedName` ~178, `extractRelativeImports` ~187, `resolveExportedMatches` ~359 + call sites ~561/~608) + +- [ ] **Step 1: Create the heterogeneous barrel fixture** + +`openai-default.ts` (default export is a wrapper calling OpenAI): +```ts +import OpenAI from "openai"; + +const openai = new OpenAI(); + +export default async function gen(prompt: string): Promise { + const r = await openai.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: prompt }], + }); + return r.choices[0]?.message?.content ?? ""; +} +``` + +`anthropic-named.ts` (named export `ask` is a wrapper calling Anthropic — a DIFFERENT provider): +```ts +import Anthropic from "@anthropic-ai/sdk"; + +const anthropic = new Anthropic(); + +export async function ask(prompt: string): Promise { + const r = await anthropic.messages.create({ + model: "claude-opus-4-5", + max_tokens: 256, + messages: [{ role: "user", content: prompt }], + }); + return r.content[0]?.type === "text" ? r.content[0].text : ""; +} +``` + +`barrel.ts` (default re-export FIRST so the current bug triggers, named re-export second): +```ts +export { default } from "./openai-default"; +export { ask } from "./anthropic-named"; +``` + +`consumer.ts` (mixes a default import with a named import from the same barrel): +```ts +import gen, { ask } from "./barrel"; + +export async function handle(q: string): Promise { + const a = await gen(q); + const b = await ask(q); + return a + b; +} +``` + +- [ ] **Step 2: Write the failing test** + +Create `src/test/a3-default-import-threading.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 root = path.resolve(__dirname, "..", "..", "src", "test", "fixtures", "a3-followup"); + + await run("A3-followup: named import in a mixed barrel resolves to its OWN provider, not the default re-export's", async () => { + const calls = await scanFiles(buildFixtureAccess(path.join(root, "mixed-barrel"))); + const consumerCalls = calls.filter((c) => c.file.endsWith("consumer.ts")); + // `ask` is a named import → must resolve to anthropic (its real provider). + assert.ok( + consumerCalls.some((c) => c.provider === "anthropic"), + `named import leaked to the default's provider: ${JSON.stringify(consumerCalls.map((c) => ({ line: c.line, provider: c.provider })))}` + ); + // `gen` is a default import → must still resolve to openai. + assert.ok( + consumerCalls.some((c) => c.provider === "openai"), + `default import failed to resolve: ${JSON.stringify(consumerCalls.map((c) => ({ line: c.line, provider: c.provider })))}` + ); + }); +})().catch((err) => { + console.error(err); + process.exit(1); +}); +``` + +- [ ] **Step 3: Run the test to verify it fails** + +```bash +npx tsc -p tsconfig.scanner-tests.json && node dist-test/test/a3-default-import-threading.test.js +``` +Expected: `FAIL` — `consumerCalls.some(c => c.provider === "anthropic")` is false because the named import `ask` follows the `export { default }` re-export and is misattributed to openai. + +- [ ] **Step 4: Add `isDefault` to `ImportedName` and populate it** + +In `src/ast/cross-file-resolver.ts`, change the interface (~178): +```ts +interface ImportedName { + localName: string; + specifier: string; // import source string + isDefault: boolean; +} +``` +In `extractRelativeImports`, the named-import branch (~206 and ~209) pushes `isDefault: false`: +```ts + if (asMatch) { + results.push({ localName: asMatch[2], specifier, isDefault: false }); + } else { + const name = trimmed.match(/\w+/)?.[0]; + if (name) results.push({ localName: name, specifier, isDefault: false }); + } +``` +The default-import branch (~217) pushes `isDefault: true`: +```ts + if (defaultMatch) { + results.push({ localName: defaultMatch[1], specifier, isDefault: true }); + } +``` + +- [ ] **Step 5: Thread `isDefault` into `resolveExportedMatches` and rewrite the filter** + +Change the signature (~359), adding `isDefault` before `visited`: +```ts +function resolveExportedMatches( + name: string, + fromFile: string, + registry: ExportRegistry, + sourceByFile: Map, + knownFiles: Set, + depth: number, + isDefault: boolean, + visited: Set = new Set() +): AstCallMatch[] | null { +``` +Replace the re-export loop body (the `for (const re of reExports)` block, ~385–405) with the explicit split: +```ts + for (const re of reExports) { + let follow = false; + let nextName = name; + let nextIsDefault = false; + if (isDefault) { + // A default binding flows ONLY through `export { default } from "./x"`. + if (re.exportedName === "default") { follow = true; nextName = "default"; nextIsDefault = true; } + } else { + // A named binding flows through wildcards and name-matching named + // re-exports, NEVER through `export { default }`. + if (re.exportedName === null) { follow = true; nextName = name; } + else if (re.exportedName === name) { follow = true; nextName = re.originalName ?? name; } + } + if (!follow) continue; + const resolved = resolveImportPath(fromFile, re.specifier, knownFiles); + if (!resolved) continue; + const found = resolveExportedMatches(nextName, resolved, registry, sourceByFile, knownFiles, depth + 1, nextIsDefault, visited); + if (found) return found; + } +``` + +- [ ] **Step 6: Pass `isDefault` at both call sites** + +Regular import propagation (~540 loop + ~561 call): destructure `isDefault` and pass it: +```ts + for (const { localName, specifier, isDefault } of imports) { +``` +```ts + const calleeMatches = resolveExportedMatches( + localName, + resolvedFile, + registry, + sourceByFile, + normalizedKnown, + 0, + isDefault + ); +``` +Middleware propagation (~608) — middleware refs are always named imports, pass `false`: +```ts + const calleeMatches = resolveExportedMatches( + mwName, + resolvedFile, + registry, + sourceByFile, + normalizedKnown, + 0, + false + ); +``` + +- [ ] **Step 7: Run the new test to verify it passes** + +```bash +npx tsc -p tsconfig.scanner-tests.json && node dist-test/test/a3-default-import-threading.test.js +``` +Expected: `PASS A3-followup: named import in a mixed barrel...`. + +- [ ] **Step 8: Run the PR #110 barrel regression to verify no regression** + +```bash +node dist-test/test/a3-barrel-reexports.test.js +``` +Expected: all 7 `PASS` lines (direct, aliased, wildcard, nested, default, missing, wildcard-then-named). If the `default` shape now fails, the direct-lookup-for-default path needs `name → "default"` mapping when `isDefault` is true — add that in the `fileExports.get` block and re-run. + +- [ ] **Step 9: Commit** + +```bash +git add src/ast/cross-file-resolver.ts src/test/a3-default-import-threading.test.ts src/test/fixtures/a3-followup/ +git commit -m "fix(wave3): disambiguate default vs named imports in resolveExportedMatches (#114) + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task A2: #115 — factory-with-arguments + +**Files:** +- Create: `src/test/fixtures/factory-args/factory.ts` +- Create: `src/test/fixtures/factory-args/consumer.ts` +- Create: `src/test/factory-with-args.test.ts` +- Modify: `src/ast/cross-file-resolver.ts` (`extractFactoryCallAssignments` ~670) + +- [ ] **Step 1: Create the factory fixture** + +`factory.ts`: +```ts +import OpenAI from "openai"; + +export function makeClient(_config?: unknown): OpenAI { + return new OpenAI(); +} +``` + +`consumer.ts` (zero-arg, single-arg, object-arg, multi-arg, and multi-line variants): +```ts +import { makeClient } from "./factory"; + +const c0 = makeClient(); +const c1 = makeClient(config); +const c2 = makeClient({ apiKey: process.env.KEY }); +const c3 = makeClient(env, options); +const c4 = makeClient( + env, + options, +); + +export async function run() { + await c0.chat.completions.create({ model: "gpt-4o", messages: [] }); + await c1.chat.completions.create({ model: "gpt-4o", messages: [] }); + await c2.chat.completions.create({ model: "gpt-4o", messages: [] }); + await c3.chat.completions.create({ model: "gpt-4o", messages: [] }); + await c4.chat.completions.create({ model: "gpt-4o", messages: [] }); +} +``` +> Note: `config`, `env`, `options` are undeclared identifiers — fine, the fixture is scanned as text, never compiled (`src/test/fixtures` is excluded by `tsconfig.scanner-tests.json`). + +- [ ] **Step 2: Write the failing test** + +Create `src/test/factory-with-args.test.ts` (same harness preamble as Task A1 — `setWasmDir`, `run`, `buildFixtureAccess`): +```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((e) => typeof e === "string" && (e.endsWith(".ts") || e.endsWith(".js"))) + .map((relName) => ({ absolutePath: path.join(fixtureDir, relName), relativePath: relName.replace(/\\/g, "/") })); + return { files, readFile: async (p: string) => fs.readFileSync(p, "utf-8") }; +} + +(async () => { + const root = path.resolve(__dirname, "..", "..", "src", "test", "fixtures", "factory-args"); + await run("A5-followup: factory calls with arguments resolve the assigned client to openai", async () => { + const calls = await scanFiles(buildFixtureAccess(path.join(root))); + const consumerCalls = calls.filter((c) => c.file.endsWith("consumer.ts") && c.provider === "openai"); + // 5 client variables (c0..c4), each makes one chat call → expect 5 openai-attributed calls. + assert.ok( + consumerCalls.length >= 5, + `expected >=5 openai calls (one per factory variant), got ${consumerCalls.length}: ${JSON.stringify(consumerCalls.map((c) => ({ line: c.line, provider: c.provider })))}` + ); + }); +})().catch((err) => { console.error(err); process.exit(1); }); +``` + +- [ ] **Step 3: Run the test to verify it fails** + +```bash +npx tsc -p tsconfig.scanner-tests.json && node dist-test/test/factory-with-args.test.js +``` +Expected: `FAIL` — only `c0` (zero-arg) resolves, so fewer than 5 openai calls are attributed. + +- [ ] **Step 4: Widen the factory-call regex** + +In `extractFactoryCallAssignments` (~674), widen the trailing parens group: +```ts + const RE = /(?:const|let|var)\s+(\w+)\s*=\s*(\w+)\s*(?:<[^>]*>)?\s*\(\s*[^)]*\)/gm; +``` + +- [ ] **Step 5: Run the test to verify it passes** + +```bash +npx tsc -p tsconfig.scanner-tests.json && node dist-test/test/factory-with-args.test.js +``` +Expected: `PASS A5-followup: factory calls with arguments...`. + +- [ ] **Step 6: Run the PR #110 factory regression** + +```bash +node dist-test/test/a5-factory-di-aliased.test.js +``` +Expected: all `PASS` lines (zero-arg factory still resolves). + +- [ ] **Step 7: Commit** + +```bash +git add src/ast/cross-file-resolver.ts src/test/factory-with-args.test.ts src/test/fixtures/factory-args/ +git commit -m "fix(wave3): resolve factory calls with arguments in extractFactoryCallAssignments (#115) + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## TRACK B — Fingerprint flag + detectors (#116) + +### Task B1: Add `inlineParallelCapable` flag and propagate it + +**Files:** +- Modify: `src/scanner/fingerprints/types.ts` (~40) +- Modify: `src/analysis/types.ts` (~19, ~58) +- Modify: `src/ast/ast-scanner.ts` (`AstCallMatch` ~58; propagation ~660, ~802, ~826) + +- [ ] **Step 1: Add the field to `MethodFingerprint`** + +`src/scanner/fingerprints/types.ts`, after the `batchCapable?` line (~40): +```ts + batchCapable?: boolean; + /** True for endpoints with an inline n/count parameter (e.g. images.generate) — NOT a real batch API. */ + inlineParallelCapable?: boolean; +``` + +- [ ] **Step 2: Add the field to the two `analysis/types.ts` shapes** + +In `src/analysis/types.ts`, after each `batchCapable?: boolean;` (lines 19 and 58): +```ts + batchCapable?: boolean; + inlineParallelCapable?: boolean; +``` + +- [ ] **Step 3: Add the field to `AstCallMatch`** + +In `src/ast/ast-scanner.ts`, after the `batchCapable?` line in the `AstCallMatch` interface (~58): +```ts + batchCapable?: boolean; + inlineParallelCapable?: boolean; +``` + +- [ ] **Step 4: Propagate from fingerprint at the three copy sites** + +In `src/ast/ast-scanner.ts`, at each of the three places that spread `batchCapable: fp.batchCapable` (~660, ~802, ~826), add the new field directly after it: +```ts +batchCapable: fp.batchCapable, inlineParallelCapable: fp.inlineParallelCapable, cacheCapable: fp.cacheCapable +``` +(Match the existing line's exact punctuation/spacing at each site.) + +- [ ] **Step 5: Verify the build compiles** + +```bash +npm run build:ext +``` +Expected: clean build, no TypeScript errors (the field is additive/optional). + +- [ ] **Step 6: Commit** + +```bash +git add src/scanner/fingerprints/types.ts src/analysis/types.ts src/ast/ast-scanner.ts +git commit -m "feat(wave3): add inlineParallelCapable fingerprint flag plumbing (#116) + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +### Task B2: Reclassify `images.generate` and add `detectInlineParallel` + +**Files:** +- Create: `src/test/ast-inline-parallel.test.ts` +- Modify: `src/scanner/fingerprints/openai.json` (~52–58) +- Modify: `src/ast/waste/concurrency-detector.ts` (~152) +- Modify: `src/ast/waste/batch-detector.ts` (`detectNPlusOne` ~162; new `detectInlineParallel`; `detectBatchWaste` ~288) + +- [ ] **Step 1: Write the failing detector test** + +Create `src/test/ast-inline-parallel.test.ts`: +```ts +import assert from "node:assert/strict"; +import { detectBatchWaste } from "../ast/waste/batch-detector"; +import type { AstCallMatch } from "../ast/ast-scanner"; +import { pointSpan } from "../scanner/source-span"; + +function makeMatch(overrides: Partial): AstCallMatch { + const line = overrides.line ?? 10; + const column = overrides.column ?? 0; + return { + kind: "sdk", provider: "openai", packageName: "openai", + methodChain: "openai.images.generate", confidence: 1, method: "POST", + endpoint: "/v1/images/generations", line, column, span: pointSpan(line, column), + frequency: "single", loopContext: false, enclosingFunction: null, + streaming: false, batchCapable: false, inlineParallelCapable: false, + cacheCapable: false, isMiddleware: false, ...overrides, + }; +} + +function run(name: string, fn: () => void): void { + try { fn(); console.log(`PASS ${name}`); } + catch (err) { console.error(`FAIL ${name}`); throw err; } +} + +run("inline-parallel: inlineParallelCapable fan-out → n/count suggestion, NOT batch-endpoint text", () => { + const match = makeMatch({ inlineParallelCapable: true, frequency: "parallel", loopContext: true, line: 4 }); + const source = [ + "import OpenAI from 'openai';", + "const openai = new OpenAI();", + "const prompts = ['a', 'b', 'c'];", + "const imgs = await Promise.all(prompts.map((p) => openai.images.generate({ prompt: p })));", + ].join("\n"); + const findings = detectBatchWaste([match], source, "/project/src/img.ts"); + assert.ok( + findings.some((f) => /n\/count parameter|count parameter|single call/i.test(f.description)), + `expected an inline-parallel (n/count) suggestion, got: ${JSON.stringify(findings.map((f) => f.description))}` + ); + assert.ok( + !findings.some((f) => /batch request|batch endpoint|consolidate into a single batch/i.test(f.description)), + `must not emit batch-endpoint text: ${JSON.stringify(findings.map((f) => f.description))}` + ); +}); + +run("inline-parallel: a real batchCapable API in the same shape still emits batch text", () => { + const match = makeMatch({ methodChain: "client.embeddings.create", batchCapable: true, frequency: "parallel", loopContext: true, line: 4 }); + const source = [ + "import OpenAI from 'openai';", + "const client = new OpenAI();", + "const r = await Promise.all(texts.map((t) => client.embeddings.create({ model: 'text-embedding-3-small', input: t })));", + ].join("\n"); + const findings = detectBatchWaste([match], source, "/project/src/embed.ts"); + assert.ok(findings.some((f) => f.type === "batch" && /batch/i.test(f.description)), "real batch API should still get batch text"); +}); + +run("inline-parallel: Array.from({length:n}) idiom stays fully suppressed", () => { + const match = makeMatch({ inlineParallelCapable: true, frequency: "parallel", loopContext: true, line: 4 }); + const source = [ + "import OpenAI from 'openai';", + "const openai = new OpenAI();", + "const imgs = await Promise.all(Array.from({ length: 4 }).map(() => openai.images.generate({ prompt: 'x' })));", + ].join("\n"); + const findings = detectBatchWaste([match], source, "/project/src/img.ts"); + assert.equal(findings.length, 0, `expected no findings for the Array.from idiom, got: ${JSON.stringify(findings.map((f) => f.description))}`); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +npx tsc -p tsconfig.scanner-tests.json && node dist-test/test/ast-inline-parallel.test.js +``` +Expected: `FAIL` on the first case — no detector reads `inlineParallelCapable`, so no n/count suggestion is produced. + +- [ ] **Step 3: Reclassify `images.generate` in the fingerprint** + +In `src/scanner/fingerprints/openai.json`, the `images.generate` entry (~52–58): replace `"batchCapable": true,` with `"inlineParallelCapable": true,`: +```json + { + "pattern": "images.generate", + "httpMethod": "POST", + "endpoint": "https://api.openai.com/v1/images/generations", + "costModel": "per_request", + "fixedFee": 0.04, + "inlineParallelCapable": true, + "description": "Image generation (DALL-E 3 1024×1024 standard)" + }, +``` + +- [ ] **Step 4: Suppress the fan-out finding on either flag** + +In `src/ast/waste/concurrency-detector.ts` (~152): +```ts + if (match.batchCapable === true || match.inlineParallelCapable === true) return null; // batch / inline-parallel detectors handle it +``` + +- [ ] **Step 5: Guard `detectNPlusOne` against inline-parallel** + +In `src/ast/waste/batch-detector.ts` (~162): +```ts + if (match.batchCapable || match.inlineParallelCapable) return null; // batch / inline-parallel detectors handle these +``` + +- [ ] **Step 6: Add `detectInlineParallel` and register it** + +In `src/ast/waste/batch-detector.ts`, add this function next to `detectBatch` (mirrors its structure; all helpers are module-scoped): +```ts +// ── Inline-parallel finding (endpoint has an n/count parameter) ─────────────── + +function detectInlineParallel( + match: AstCallMatch, + source: string, + filePath: string, + isTestLike: boolean +): LocalWasteFinding | null { + if (!isRealProviderMatch(match)) return null; + if (!BATCH_LOOP_FREQS.has(match.frequency)) return null; + if (!match.inlineParallelCapable) return null; + if (hasGuardInWindow(source, match.line, BATCH_GUARD)) return null; + // Array.from({ length: N }) is intentional bounded replication — not naive fan-out. + if (match.frequency === "parallel" && hasGuardInWindow(source, match.line, BOUNDED_REPLICATION)) return null; + + const evidence: string[] = [ + `Call executes in a "${match.frequency}" context — each iteration issues a separate request.`, + "This endpoint accepts an n/count parameter that returns multiple results from a single request.", + ]; + const small = isSmallBounded(source, match.line); + if (small) evidence.push("Loop appears bounded to a small collection (≤5 items)."); + + let score = 1; + if (match.frequency === "unbounded-loop") score += 3; + else if (match.frequency === "bounded-loop") score += 2; + else if (match.frequency === "parallel") score += 2; + else if (match.frequency === "polling") score += 4; + if (small) score -= 1; + if (isTestLike) score -= 1; + + let confidence = 0.52 + Math.min(score, 5) * 0.07; + if (small) confidence -= 0.10; + if (isTestLike) confidence -= 0.10; + confidence = clamp(confidence); + if (confidence < 0.35) return null; + + return { + id: `local-inline_parallel-${filePath}:${match.line}`, + type: "batch" as SuggestionType, + severity: scoreToSeverity(score), + confidence, + description: + "This endpoint accepts an n/count parameter — request multiple results in a single call instead of issuing one request per item.", + affectedFile: filePath, + line: match.line, + evidence, + }; +} +``` +Register it inside `detectBatchWaste`'s per-match loop, right after the `detectNPlusOne` push (~292): +```ts + const inlineFinding = detectInlineParallel(match, source, filePath, isTestLike); + if (inlineFinding) findings.push(inlineFinding); +``` + +- [ ] **Step 7: Run the test to verify it passes** + +```bash +npx tsc -p tsconfig.scanner-tests.json && node dist-test/test/ast-inline-parallel.test.js +``` +Expected: 3× `PASS`. + +- [ ] **Step 8: Run existing detector + fingerprint regressions** + +```bash +node dist-test/test/ast-batch-detector.test.js && node dist-test/test/ast-concurrency-detector.test.js && node dist-test/test/fingerprint-registry.test.js +``` +Expected: all `PASS`. (If any test asserted `images.generate` is `batchCapable`, update that expectation to `inlineParallelCapable`.) + +- [ ] **Step 9: Commit** + +```bash +git add src/scanner/fingerprints/openai.json src/ast/waste/concurrency-detector.ts src/ast/waste/batch-detector.ts src/test/ast-inline-parallel.test.ts +git commit -m "feat(wave3): inline-parallel detector + images.generate reclassification (#116) + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## PHASE C — Integration & Verification (after Tracks A and B merge) + +### Task C1: Wire new tests into the CI chain and run the full suite + +**Files:** +- Modify: `package.json` (`test:scanner` script) + +- [ ] **Step 1: Append the three new compiled test files to `test:scanner`** + +In `package.json`, append to the end of the `test:scanner` command chain (before the closing quote): +``` + && node dist-test/test/a3-default-import-threading.test.js && node dist-test/test/factory-with-args.test.js && node dist-test/test/ast-inline-parallel.test.js +``` + +- [ ] **Step 2: Run the full scanner suite** + +```bash +npm run test:scanner +``` +Expected: every test prints `PASS`; the process exits 0. Pay attention to `parity.test.js` (AST↔regex parity #76) — it must still pass. + +- [ ] **Step 3: Commit** + +```bash +git add package.json +git commit -m "test(wave3): register #114/#115/#116 tests in test:scanner chain + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +### Task C2: Benchmark gate (no regression) + +- [ ] **Step 1: Run the benchmark against the pinned fixtures** + +```bash +git clone --depth 1 https://github.com/recost-dev/extension-benchmark.git /tmp/wave3-fixtures +cd /tmp/wave3-fixtures && git fetch --depth 1 origin "$(tr -d '\n\r' < /home/andresl/Projects/recost/extension/.benchmark-fixtures-sha)" && git checkout FETCH_HEAD +cd /home/andresl/Projects/recost/extension +npm run build:ext +npm run benchmark -- --fixtures /tmp/wave3-fixtures --report benchmark/report.json +``` +Expected: exit 0. The runner fails (exit 1) only if a metric drops more than 1pp below `benchmark/baseline.json`. `detectionRecall` must stay ≥ 51.47%; precision within tolerance. No positive delta is expected (the corpus does not yet exercise barrel/factory patterns — that is Wave 2 / #113). + +- [ ] **Step 2: If the gate fails**, inspect which metric dropped (the runner prints `metric: baseline% → current% (Δ pp)`). A precision drop on `images.generate`-adjacent finding types is the likely culprit — re-check the `detectInlineParallel` guards. Do **not** run `--update-baseline` to mask a real regression. + +### Task C3: Finish the branch + +- [ ] **Step 1: Confirm the issues' acceptance criteria are all met** (re-read #114/#115/#116 checkboxes against the diff). +- [ ] **Step 2: Invoke `superpowers:finishing-a-development-branch`** to open the PR (title referencing "Wave 3", body `Closes #114, #115, #116`) and present merge options. Do not push or merge without explicit user approval. + +--- + +## Self-Review Notes (for the planner) + +- **Spec coverage:** #114 → A1; #115 → A2; #116 flag plumbing → B1, detector behavior + fingerprint → B2; benchmark gate → C2; test-registration gotcha → C1. All spec sections mapped. +- **Type consistency:** `inlineParallelCapable?: boolean` is the single field name used in `MethodFingerprint`, both `analysis/types.ts` shapes, and `AstCallMatch`. `detectInlineParallel` and `detectBatchWaste` match `batch-detector.ts` signatures. `resolveExportedMatches`'s new `isDefault` param is threaded at the declaration, the recursive call, and both external call sites. +- **Direct-default-lookup caveat:** A1 Step 8 explicitly tests the PR #110 `default` shape and gives the remediation if it regresses — this is the one place the spec flagged as needing fixture confirmation. diff --git a/docs/superpowers/specs/2026-05-27-wave3-resolver-followups-design.md b/docs/superpowers/specs/2026-05-27-wave3-resolver-followups-design.md new file mode 100644 index 0000000..86cc933 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-wave3-resolver-followups-design.md @@ -0,0 +1,167 @@ +# Wave 3 — Resolver Follow-ups Design + +**Date:** 2026-05-27 +**Closes:** #114, #115, #116 +**Wave label:** `wave/3-resolver-followups` +**Area:** `area/detection` +**PR shape:** one bundled PR + +## Goal + +Three precision/recall follow-ups left behind by PR #110 (A3 barrels + A5 factories), all in the cross-file resolver and the AST waste detectors: + +1. **#114** — `resolveExportedMatches` cannot tell a default import from a named one, so a named import can wrongly inherit the provider of a sibling `export { default } from "./x"` re-export in a heterogeneous barrel. +2. **#115** — `extractFactoryCallAssignments` matches only no-arg factory calls (`makeClient()`); `makeClient(config)` and friends silently fail to resolve. +3. **#116** — `openai.images.generate` carries `batchCapable: true`, which conflates a true batch endpoint with DALL·E's inline `n`/count parameter. In a generic loop/fan-out this makes the batch detector emit the wrong guidance ("use the batch endpoint"). + +These ship together because #114 and #115 live in the same file (`src/ast/cross-file-resolver.ts`) and #116 unblocks Wave 4 (#117); a single benchmark run covers all three. + +## Non-goals + +- **Wave 4 / #117** (recovering the two C1 false negatives) depends on #116's flag but is a separate PR. +- **No `baseline.json` refresh** unless the benchmark gate demands it. These patterns are not yet exercised by the D1 corpus (that is Wave 2 / #113), so a measurable recall delta is not expected here. +- **No conversion of the factory post-pass to AST.** The post-pass operates on raw source text by design; #115 stays a regex change consistent with that surface. +- **No new wrapper-chain depth or cycle changes.** Only the default-vs-named filter logic in `resolveExportedMatches` changes. + +## Architecture + +All three changes are localized: + +- **#114, #115** — `src/ast/cross-file-resolver.ts` only (plus fixtures + tests). +- **#116** — a new optional fingerprint flag threaded through the type definitions and the AST scanner, one fingerprint JSON edit, and the two waste detectors (plus fixtures + tests). + +No interface in `src/intelligence/types.ts` changes. No IPC message changes. + +--- + +## Section A — #114: default-vs-named disambiguation + +### Root cause + +`extractRelativeImports` (`cross-file-resolver.ts:187`) parses both default imports (`import D from "./p"`, line 215–218) and named imports (`import { A, B as C } from "./p"`, line 197–212), but emits both as `{ localName, specifier }` — the import *kind* is discarded. + +Downstream, `resolveExportedMatches` (line 359) receives only `name` (the local name). Its re-export filter at line 393: + +```ts +if (re.exportedName !== null && re.exportedName !== name && re.exportedName !== "default") continue; +``` + +The `re.exportedName !== "default"` clause means **any** lookup — including a named import — will follow a `export { default } from "./x"` re-export and look up `"default"` in `x`. If `x`'s default export is, say, an OpenAI client, a named import like `ask` wrongly inherits OpenAI as its provider when the barrel mixes shapes. + +### Change + +1. Add `isDefault: boolean` to the `ImportedName` interface (line 178). Set `true` in the default-import branch, `false` in the named-import branch of `extractRelativeImports`. +2. Add an `isDefault` parameter to `resolveExportedMatches`. Pass it from both call sites: + - Regular import propagation (line 561): pass the import entry's `isDefault`. + - Middleware propagation (line 608): middleware names are always named imports → pass `false`. +3. Replace the single-line filter with an explicit per-binding split: + +```ts +for (const re of reExports) { + let follow = false; + let nextName = name; + let nextIsDefault = false; + if (isDefault) { + // A default binding flows ONLY through `export { default } from "./x"`. + if (re.exportedName === "default") { follow = true; nextName = "default"; nextIsDefault = true; } + } else { + // A named binding flows through wildcards and name-matching named re-exports, + // NEVER through `export { default }`. + if (re.exportedName === null) { follow = true; nextName = name; } + else if (re.exportedName === name) { follow = true; nextName = re.originalName ?? name; } + } + if (!follow) continue; + const resolved = resolveImportPath(fromFile, re.specifier, knownFiles); + if (!resolved) continue; + const found = resolveExportedMatches(nextName, resolved, registry, sourceByFile, knownFiles, depth + 1, nextIsDefault, visited); + if (found) return found; +} +``` + +4. **Verify the direct-lookup path** (line 376, `fileExports.get(name)`): a default import's `name` is the consumer's local alias, but the export registry may key the default export under `"default"`. The mixed-barrel fixture must include a *direct* default import of an `export default` declaration to confirm this resolves; if it does not, map `name → "default"` for direct lookups when `isDefault` is true. (Confirm during implementation; do not pre-emptively change without a failing fixture.) + +### Tests + +- Fixtures under `src/test/fixtures/a3-followup/`: + - `barrel.ts`: `export { default } from "./a"` (a.ts default-exports an OpenAI client) **mixed with** `export { ask } from "./b"` (b.ts makes a different-provider call). + - `consumer.ts`: `import client, { ask } from "./barrel"; client.chat...; ask(...)`. +- `src/test/a3-default-import-threading.test.ts`: + - `client.chat.*` resolves to OpenAI; `ask(...)` resolves to b.ts's provider — **not** OpenAI. + - Regression: re-run the 5 PR #110 barrel shapes (`src/test/a3-barrel-reexports.test.ts` fixtures) — all still resolve. + +--- + +## Section B — #115: factory-with-arguments + +### Change + +In `extractFactoryCallAssignments` (line 670), widen the trailing argument group: + +```diff +- const RE = /(?:const|let|var)\s+(\w+)\s*=\s*(\w+)\s*(?:<[^>]*>)?\s*\(\s*\)/gm; ++ const RE = /(?:const|let|var)\s+(\w+)\s*=\s*(\w+)\s*(?:<[^>]*>)?\s*\(\s*[^)]*\)/gm; +``` + +The `(\w+)` capture groups for var name and factory-fn name precede the argument list, so they are unaffected. `[^)]*` (no `s` flag needed — a negated class matches newlines) covers single-level args and multi-line arg lists. Deeply nested-paren args (`makeClient(getConfig())`) still match because the regex only needs to find the opening `(` plus the captured names; the trailing unmatched `)` is harmless. Degrades to no-detection (never a false positive) for anything it cannot match. + +### Tests + +- Fixtures demonstrating: zero-arg (`makeClient()`), single-arg (`makeClient(config)`), object-arg (`makeClient({ apiKey: process.env.KEY })`), multi-arg (`makeClient(env, options)`), and multi-line args. +- Test asserting each variant resolves to the factory's `factoryReturnMap` provider. +- Regression: PR #110's zero-arg factory test still passes. + +--- + +## Section C — #116: `inlineParallelCapable` flag + +### Flag plumbing + +Add `inlineParallelCapable?: boolean` to: +- `src/scanner/fingerprints/types.ts` (alongside `batchCapable`, line 40). +- `src/analysis/types.ts` — both shapes that carry `batchCapable` (the AST-match shape ~line 19 and the endpoint shape ~line 58). +- The `AstCallMatch` interface in `src/ast/ast-scanner.ts` (~line 58). +- Propagate from fingerprint → match at the three sites in `ast-scanner.ts` (~660, ~802, ~826) that already copy `batchCapable`/`cacheCapable`/`streaming`. + +### Fingerprint + +`src/scanner/fingerprints/openai.json`, `images.generate` (line 52–58): remove `"batchCapable": true`, add `"inlineParallelCapable": true`. + +### Detectors + +**`src/ast/waste/concurrency-detector.ts:152`** — preserve PR #110's fan-out suppression for inline-parallel endpoints: + +```diff +- if (match.batchCapable === true) return null; // let batch detector handle it ++ if (match.batchCapable === true || match.inlineParallelCapable === true) return null; +``` + +**`src/ast/waste/batch-detector.ts`** — stop the wrong text and route inline-parallel separately: +- `detectBatch` (line 105): `if (!match.batchCapable) return null;` stays as-is. With `batchCapable` removed from `images.generate`, this detector no longer fires the "use the batch endpoint" text for it. (The existing `BOUNDED_REPLICATION` guard at line 109 continues to protect true batch APIs in the `Array.from({length:N})` idiom.) +- `detectNPlusOne` (line 162): `if (match.batchCapable) return null;` → `if (match.batchCapable || match.inlineParallelCapable) return null;`, so inline-parallel endpoints in a bounded/unbounded loop don't fall through to the N+1 finding. +- **New** `detectInlineParallel`: fires for `match.inlineParallelCapable` in the same loop/parallel frequency contexts as `detectBatch`, applying the same `isRealProviderMatch`, guard-window, and `BOUNDED_REPLICATION` checks. Emits: + - `type: "batch"` (reuses the existing suggestion type; acceptance only requires the **text** to differ), + - id prefix `local-inline_parallel-`, + - description: *"This endpoint accepts an `n`/count parameter — request multiple results in a single call instead of issuing one request per item."* +- Register `detectInlineParallel` in the detector's exported finding list alongside `detectBatch`/`detectNPlusOne`. + +### Tests + +- `images.generate` inside a generic `items.map(() => images.generate(...))` (no `Array.from` idiom) → one finding with the inline-parallel text; **no** "batch endpoint" text; **no** `concurrency_control` finding. +- A real batch API (`embeddings.create`, still `batchCapable`) in the same shape → the existing "consolidate into a single batch request" text, unchanged. +- The `Array.from({ length: n }).map(() => images.generate(...))` idiom → still fully suppressed (no finding). + +--- + +## Verification gates + +1. `npm run test:scanner` — all new + existing tests pass, including the AST↔regex parity suite (#76) and the PR #110 regression fixtures (`a3-barrel-reexports`, `a5-factory-di-aliased`). **Gotcha:** `test:scanner` runs an *explicit list* of compiled `dist-test/...` files, not a glob — each new test file (`a3-default-import-threading`, the factory-args test, the #116 detector test) must be added to that chain in `package.json` and compiled via `tsconfig.scanner-tests.json`, or it silently never runs. +2. `npm run build:ext` — clean TypeScript build (new optional field is additive, no breaking type changes). +3. Benchmark gate (`benchmark.yml` → `benchmark/runner.ts`, fails on >1pp drop): **no regression.** `detectionRecall ≥ 51.47%`, `detectionPrecision` within the 1pp threshold, finding precision/recall by type unchanged. The corpus does not yet exercise barrel/factory patterns (Wave 2 / #113), so a positive delta is not expected — the gate is a no-regression guard here, and in-repo unit tests are the correctness proof. + +## Files touched + +- `src/ast/cross-file-resolver.ts` — #114 (`ImportedName`, `extractRelativeImports`, `resolveExportedMatches` signature + filter) and #115 (`extractFactoryCallAssignments` regex). +- `src/scanner/fingerprints/types.ts`, `src/analysis/types.ts`, `src/ast/ast-scanner.ts` — #116 flag definition + propagation. +- `src/scanner/fingerprints/openai.json` — #116 `images.generate` reclassification. +- `src/ast/waste/concurrency-detector.ts`, `src/ast/waste/batch-detector.ts` — #116 detector logic + new `detectInlineParallel`. +- New: `src/test/fixtures/a3-followup/*`, `src/test/a3-default-import-threading.test.ts`, factory-args fixtures + test, #116 detector test. diff --git a/package.json b/package.json index cf18e0e..4165944 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 && node dist-test/test/c1-pr4-rate-limit-tightening.test.js && node dist-test/test/c1-pr4-batch-residual.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 && node dist-test/test/wave6-pr1-submit-filter.test.js && node dist-test/test/scan-publishing-handler.test.js && node dist-test/test/config.test.js && node dist-test/test/scan-id.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/c1-pr4-rate-limit-tightening.test.js && node dist-test/test/c1-pr4-batch-residual.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 && node dist-test/test/wave6-pr1-submit-filter.test.js && node dist-test/test/scan-publishing-handler.test.js && node dist-test/test/config.test.js && node dist-test/test/scan-id.test.js && node dist-test/test/a3-default-import-threading.test.js && node dist-test/test/factory-with-args.test.js && node dist-test/test/ast-inline-parallel.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/analysis/types.ts b/src/analysis/types.ts index 2727ad3..926a5df 100644 --- a/src/analysis/types.ts +++ b/src/analysis/types.ts @@ -17,6 +17,7 @@ export interface ApiCallInput { costModel?: "per_token" | "per_transaction" | "per_request" | "free"; frequencyClass?: "single" | "bounded-loop" | "unbounded-loop" | "parallel" | "polling" | "conditional" | "cache-guarded"; batchCapable?: boolean; + inlineParallelCapable?: boolean; cacheCapable?: boolean; streaming?: boolean; isMiddleware?: boolean; @@ -56,6 +57,7 @@ export interface EndpointRecord { costModel?: "per_token" | "per_transaction" | "per_request" | "free"; frequencyClass?: string; batchCapable?: boolean; + inlineParallelCapable?: boolean; cacheCapable?: boolean; streaming?: boolean; isMiddleware?: boolean; diff --git a/src/ast/ast-scanner.ts b/src/ast/ast-scanner.ts index 50321b3..5c00a31 100644 --- a/src/ast/ast-scanner.ts +++ b/src/ast/ast-scanner.ts @@ -56,6 +56,7 @@ export interface AstCallMatch { enclosingFunction: string | null; streaming?: boolean; batchCapable?: boolean; + inlineParallelCapable?: boolean; cacheCapable?: boolean; /** True when emitted from middleware detection (per-request cost) */ isMiddleware?: boolean; @@ -657,7 +658,7 @@ export async function scanSourceWithAst( ? { kind: "sdk", provider, packageName, methodChain, confidence: 1.0, method: fp.httpMethod, endpoint: fp.endpoint, line, column, span: callInfo.span, frequency, loopContext: inLoop, enclosingFunction: methodName, - streaming: fp.streaming, batchCapable: fp.batchCapable, cacheCapable: fp.cacheCapable } + streaming: fp.streaming, batchCapable: fp.batchCapable, inlineParallelCapable: fp.inlineParallelCapable, cacheCapable: fp.cacheCapable } : { kind: "sdk", provider, packageName, methodChain, confidence: provider ? 0.7 : 0.1, line, column, span: callInfo.span, frequency, loopContext: inLoop, enclosingFunction: methodName } ); @@ -799,7 +800,7 @@ export async function scanSourceWithAst( kind: "sdk", provider, packageName, methodChain, confidence: 1.0, method: fp.httpMethod, endpoint: fp.endpoint, line, column, span: callInfo.span, frequency, loopContext: inLoop, enclosingFunction: fnName, - streaming: fp.streaming, batchCapable: fp.batchCapable, cacheCapable: fp.cacheCapable, + streaming: fp.streaming, batchCapable: fp.batchCapable, inlineParallelCapable: fp.inlineParallelCapable, cacheCapable: fp.cacheCapable, }); } else { matches.push({ kind: "sdk", provider, packageName, methodChain, confidence: provider ? 0.7 : 0.1, line, column, span: callInfo.span, frequency, loopContext: inLoop, enclosingFunction: fnName }); @@ -823,7 +824,7 @@ export async function scanSourceWithAst( ? { kind: "sdk", provider, packageName, methodChain, confidence: 1.0, method: fp.httpMethod, endpoint: fp.endpoint, line, column, span: callInfo.span, frequency: "single", loopContext: false, enclosingFunction: fnName2, - streaming: fp.streaming, batchCapable: fp.batchCapable, cacheCapable: fp.cacheCapable } + streaming: fp.streaming, batchCapable: fp.batchCapable, inlineParallelCapable: fp.inlineParallelCapable, cacheCapable: fp.cacheCapable } : { kind: "sdk", provider, packageName, methodChain, confidence: provider ? 0.7 : 0.1, line, column, span: callInfo.span, frequency: "single", loopContext: false, enclosingFunction: fnName2 } ); diff --git a/src/ast/cross-file-resolver.ts b/src/ast/cross-file-resolver.ts index ac69a90..fd5a315 100644 --- a/src/ast/cross-file-resolver.ts +++ b/src/ast/cross-file-resolver.ts @@ -178,6 +178,7 @@ function matchesInRange( interface ImportedName { localName: string; specifier: string; // import source string + isDefault: boolean; } /** @@ -203,18 +204,22 @@ function extractRelativeImports(source: string): ImportedName[] { // "X as Y" → local name is Y const asMatch = /(\w+)\s+as\s+(\w+)/.exec(trimmed); if (asMatch) { - results.push({ localName: asMatch[2], specifier }); + results.push({ localName: asMatch[2], specifier, isDefault: false }); } else { const name = trimmed.match(/\w+/)?.[0]; - if (name) results.push({ localName: name, specifier }); + if (name) results.push({ localName: name, specifier, isDefault: false }); } } } - // Default import: import Foo from './path' (clause has no braces) - const defaultMatch = /^(\w+)$/.exec(clause.trim()); + // Default import: either standalone `import Foo from './path'` (clause has no + // braces) or mixed `import Foo, { bar } from './path'` (default before comma). + // Strip the named block (if any) from the clause first, then test for a bare + // word to get the default binding name. + const clauseWithoutNamed = clause.replace(/\{[^}]*\}/, "").replace(/,/g, " ").trim(); + const defaultMatch = /^(\w+)$/.exec(clauseWithoutNamed); if (defaultMatch) { - results.push({ localName: defaultMatch[1], specifier }); + results.push({ localName: defaultMatch[1], specifier, isDefault: true }); } } return results; @@ -363,17 +368,20 @@ function resolveExportedMatches( sourceByFile: Map, knownFiles: Set, depth: number, + isDefault: boolean, visited: Set = new Set() ): AstCallMatch[] | null { if (depth > 2) return null; - const visitKey = `${fromFile}::${name}`; + const visitKey = `${fromFile}::${isDefault ? "default" : name}`; if (visited.has(visitKey)) return null; visited.add(visitKey); const fileExports = registry.get(fromFile); if (fileExports) { - const direct = fileExports.get(name); + // For a default import, look up the "default" key; for named, use the symbol name. + const directKey = isDefault ? "default" : name; + const direct = fileExports.get(directKey); if (direct && direct.length > 0) return direct; } @@ -383,24 +391,22 @@ function resolveExportedMatches( const reExports = extractReExports(source); for (const re of reExports) { - // 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; + let follow = false; + let nextName = name; + let nextIsDefault = false; + if (isDefault) { + // A default binding flows ONLY through `export { default } from "./x"`. + if (re.exportedName === "default") { follow = true; nextName = "default"; nextIsDefault = true; } + } else { + // A named binding flows through wildcards and name-matching named + // re-exports, NEVER through `export { default }`. + if (re.exportedName === null) { follow = true; nextName = name; } + else if (re.exportedName === name) { follow = true; nextName = re.originalName ?? name; } + } + if (!follow) continue; const resolved = resolveImportPath(fromFile, re.specifier, knownFiles); if (!resolved) continue; - // 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); + const found = resolveExportedMatches(nextName, resolved, registry, sourceByFile, knownFiles, depth + 1, nextIsDefault, visited); if (found) return found; } @@ -537,7 +543,7 @@ export function runCrossFileResolution( const { caller, callerPath, callerRelative, imports, callSiteLinesByName } = ctx; // ── Regular import propagation ───────────────────────────────────────── - for (const { localName, specifier } of imports) { + for (const { localName, specifier, isDefault } of imports) { const resolvedFile = resolveImportPath(callerPath, specifier, normalizedKnown); if (!resolvedFile) continue; @@ -564,7 +570,8 @@ export function runCrossFileResolution( registry, sourceByFile, normalizedKnown, - 0 + 0, + isDefault ); if (!calleeMatches || calleeMatches.length === 0) continue; @@ -611,7 +618,8 @@ export function runCrossFileResolution( registry, sourceByFile, normalizedKnown, - 0 + 0, + false ); if (!calleeMatches || calleeMatches.length === 0) continue; @@ -671,7 +679,11 @@ 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; + // Also handles calls with arguments: factoryFnName(arg1, arg2) or multi-line + // `[^)]*` matches any single-level argument list (incl. multi-line); it stops at + // the first `)`, which still captures the var + factory names correctly even when + // an argument itself contains parens, e.g. makeClient(getConfig()). + 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]); diff --git a/src/ast/waste/batch-detector.ts b/src/ast/waste/batch-detector.ts index 643765e..17ee28e 100644 --- a/src/ast/waste/batch-detector.ts +++ b/src/ast/waste/batch-detector.ts @@ -159,7 +159,7 @@ function detectNPlusOne( // Polling and parallel fan-out are handled by other detectors. if (!isRealProviderMatch(match)) return null; if (!N_PLUS_ONE_FREQS.has(match.frequency)) return null; - if (match.batchCapable) return null; // batch-detector handles this one + if (match.batchCapable || match.inlineParallelCapable) return null; // batch / inline-parallel detectors handle these if (hasGuardInWindow(source, match.line, BATCH_GUARD)) return null; if (hasGuardInWindow(source, match.line, CONCURRENCY_GUARD)) return null; @@ -262,6 +262,56 @@ function detectSequential( return findings; } +// ── Inline-parallel finding (endpoint has an n/count parameter) ─────────────── + +function detectInlineParallel( + match: AstCallMatch, + source: string, + filePath: string, + isTestLike: boolean +): LocalWasteFinding | null { + if (!isRealProviderMatch(match)) return null; + if (!BATCH_LOOP_FREQS.has(match.frequency)) return null; + if (!match.inlineParallelCapable) return null; + if (hasGuardInWindow(source, match.line, BATCH_GUARD)) return null; + // Array.from({ length: N }) is intentional bounded replication — not naive fan-out. + if (match.frequency === "parallel" && hasGuardInWindow(source, match.line, BOUNDED_REPLICATION)) return null; + + const evidence: string[] = [ + `Call executes in a "${match.frequency}" context — each iteration issues a separate request.`, + "This endpoint accepts an n/count parameter that returns multiple results from a single request.", + ]; + const small = isSmallBounded(source, match.line); + if (small) evidence.push("Loop appears bounded to a small collection (≤5 items)."); + + let score = 1; + if (match.frequency === "unbounded-loop") score += 3; + else if (match.frequency === "bounded-loop") score += 2; + else if (match.frequency === "parallel") score += 2; + // polling is excluded by the BATCH_LOOP_FREQS guard above; branch kept for parity with detectBatch + else if (match.frequency === "polling") score += 4; + if (small) score -= 1; + if (isTestLike) score -= 1; + + let confidence = 0.52 + Math.min(score, 5) * 0.07; + if (small) confidence -= 0.10; + if (isTestLike) confidence -= 0.10; + confidence = clamp(confidence); + if (confidence < 0.35) return null; + + return { + id: `local-inline_parallel-${filePath}:${match.line}`, + type: "batch" as SuggestionType, + severity: scoreToSeverity(score), + confidence, + description: + "This endpoint accepts an n/count parameter — request multiple results in a single call instead of issuing one request per item.", + affectedFile: filePath, + line: match.line, + evidence, + }; +} + // ── Main export ─────────────────────────────────────────────────────────────── /** @@ -290,6 +340,9 @@ export function detectBatchWaste( const n1Finding = detectNPlusOne(match, source, filePath, isTestLike); if (n1Finding) findings.push(n1Finding); + + const inlineFinding = detectInlineParallel(match, source, filePath, isTestLike); + if (inlineFinding) findings.push(inlineFinding); } // Sequential await detection is cross-match, runs once over all matches. diff --git a/src/ast/waste/concurrency-detector.ts b/src/ast/waste/concurrency-detector.ts index 9848190..093ee03 100644 --- a/src/ast/waste/concurrency-detector.ts +++ b/src/ast/waste/concurrency-detector.ts @@ -149,7 +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 + if (match.batchCapable === true || match.inlineParallelCapable === true) return null; // batch / inline-parallel detectors 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 60ca52c..5ef5950 100644 --- a/src/scanner/core-scanner.ts +++ b/src/scanner/core-scanner.ts @@ -150,6 +150,7 @@ function astMatchToApiCallInput(match: AstCallMatch, file: string): ApiCallInput enclosingFunction: match.enclosingFunction, costModel, batchCapable: match.batchCapable, + inlineParallelCapable: match.inlineParallelCapable, cacheCapable: match.cacheCapable, streaming: match.streaming, isMiddleware: match.isMiddleware, diff --git a/src/scanner/fingerprints/openai.json b/src/scanner/fingerprints/openai.json index 511259f..6fb7ad4 100644 --- a/src/scanner/fingerprints/openai.json +++ b/src/scanner/fingerprints/openai.json @@ -54,7 +54,7 @@ "endpoint": "https://api.openai.com/v1/images/generations", "costModel": "per_request", "fixedFee": 0.04, - "batchCapable": true, + "inlineParallelCapable": true, "description": "Image generation (DALL-E 3 1024×1024 standard)" }, { diff --git a/src/scanner/fingerprints/types.ts b/src/scanner/fingerprints/types.ts index 563875e..cafa83c 100644 --- a/src/scanner/fingerprints/types.ts +++ b/src/scanner/fingerprints/types.ts @@ -38,6 +38,8 @@ export interface MethodFingerprint { perRequestCostUsd?: number; streaming?: boolean; batchCapable?: boolean; + /** True for endpoints with an inline n/count parameter (e.g. images.generate) — NOT a real batch API. */ + inlineParallelCapable?: boolean; cacheCapable?: boolean; /** Human-readable description of what this method does */ description?: string; diff --git a/src/test/a3-default-import-threading.test.ts b/src/test/a3-default-import-threading.test.ts new file mode 100644 index 0000000..731e6ca --- /dev/null +++ b/src/test/a3-default-import-threading.test.ts @@ -0,0 +1,52 @@ +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 root = path.resolve(__dirname, "..", "..", "src", "test", "fixtures", "a3-followup"); + + await run("A3-followup: named import in a mixed barrel resolves to its OWN provider, not the default re-export's", async () => { + const calls = await scanFiles(buildFixtureAccess(path.join(root, "mixed-barrel"))); + const consumerCalls = calls.filter((c) => c.file.endsWith("consumer.ts")); + assert.ok( + consumerCalls.some((c) => c.provider === "anthropic"), + `named import leaked to the default's provider: ${JSON.stringify(consumerCalls.map((c) => ({ line: c.line, provider: c.provider })))}` + ); + assert.ok( + consumerCalls.some((c) => c.provider === "openai"), + `default import failed to resolve: ${JSON.stringify(consumerCalls.map((c) => ({ line: c.line, provider: c.provider })))}` + ); + }); +})().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/test/ast-inline-parallel.test.ts b/src/test/ast-inline-parallel.test.ts new file mode 100644 index 0000000..addcd26 --- /dev/null +++ b/src/test/ast-inline-parallel.test.ts @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import { detectBatchWaste } from "../ast/waste/batch-detector"; +import type { AstCallMatch } from "../ast/ast-scanner"; +import { pointSpan } from "../scanner/source-span"; + +function makeMatch(overrides: Partial): AstCallMatch { + const line = overrides.line ?? 10; + const column = overrides.column ?? 0; + return { + kind: "sdk", provider: "openai", packageName: "openai", + methodChain: "openai.images.generate", confidence: 1, method: "POST", + endpoint: "/v1/images/generations", line, column, span: pointSpan(line, column), + frequency: "single", loopContext: false, enclosingFunction: null, + streaming: false, batchCapable: false, inlineParallelCapable: false, + cacheCapable: false, isMiddleware: false, ...overrides, + }; +} + +function run(name: string, fn: () => void): void { + try { fn(); console.log(`PASS ${name}`); } + catch (err) { console.error(`FAIL ${name}`); throw err; } +} + +run("inline-parallel: inlineParallelCapable fan-out → n/count suggestion, NOT batch-endpoint text", () => { + const match = makeMatch({ inlineParallelCapable: true, frequency: "parallel", loopContext: true, line: 4 }); + const source = [ + "import OpenAI from 'openai';", + "const openai = new OpenAI();", + "const prompts = ['a', 'b', 'c'];", + "const imgs = await Promise.all(prompts.map((p) => openai.images.generate({ prompt: p })));", + ].join("\n"); + const findings = detectBatchWaste([match], source, "/project/src/img.ts"); + assert.ok( + findings.some((f) => /n\/count parameter|count parameter|single call/i.test(f.description)), + `expected an inline-parallel (n/count) suggestion, got: ${JSON.stringify(findings.map((f) => f.description))}` + ); + assert.ok( + !findings.some((f) => /batch request|batch endpoint|consolidate into a single batch/i.test(f.description)), + `must not emit batch-endpoint text: ${JSON.stringify(findings.map((f) => f.description))}` + ); +}); + +run("inline-parallel: a real batchCapable API in the same shape still emits batch text", () => { + const match = makeMatch({ methodChain: "client.embeddings.create", batchCapable: true, frequency: "parallel", loopContext: true, line: 4 }); + const source = [ + "import OpenAI from 'openai';", + "const client = new OpenAI();", + "const r = await Promise.all(texts.map((t) => client.embeddings.create({ model: 'text-embedding-3-small', input: t })));", + ].join("\n"); + const findings = detectBatchWaste([match], source, "/project/src/embed.ts"); + assert.ok(findings.some((f) => f.type === "batch" && /batch/i.test(f.description)), "real batch API should still get batch text"); +}); + +run("inline-parallel: Array.from({length:n}) idiom stays fully suppressed", () => { + const match = makeMatch({ inlineParallelCapable: true, frequency: "parallel", loopContext: true, line: 3 }); + const source = [ + "import OpenAI from 'openai';", + "const openai = new OpenAI();", + "const imgs = await Promise.all(Array.from({ length: 4 }).map(() => openai.images.generate({ prompt: 'x' })));", + ].join("\n"); + const findings = detectBatchWaste([match], source, "/project/src/img.ts"); + assert.equal(findings.length, 0, `expected no findings for the Array.from idiom, got: ${JSON.stringify(findings.map((f) => f.description))}`); +}); + +run("inline-parallel: inlineParallelCapable in an unbounded for-loop → n/count suggestion fires", () => { + const match = makeMatch({ inlineParallelCapable: true, frequency: "unbounded-loop", loopContext: true, line: 4 }); + const source = [ + "import OpenAI from 'openai';", + "const openai = new OpenAI();", + "for (const p of prompts) {", + " const img = await openai.images.generate({ prompt: p });", + "}", + ].join("\n"); + const findings = detectBatchWaste([match], source, "/project/src/img.ts"); + assert.ok( + findings.some((f) => /n\/count parameter|count parameter|single call/i.test(f.description)), + `expected an inline-parallel suggestion for the loop case, got: ${JSON.stringify(findings.map((f) => f.description))}` + ); +}); diff --git a/src/test/factory-with-args.test.ts b/src/test/factory-with-args.test.ts new file mode 100644 index 0000000..6f19208 --- /dev/null +++ b/src/test/factory-with-args.test.ts @@ -0,0 +1,33 @@ +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((e) => typeof e === "string" && (e.endsWith(".ts") || e.endsWith(".js"))) + .map((relName) => ({ absolutePath: path.join(fixtureDir, relName), relativePath: relName.replace(/\\/g, "/") })); + return { files, readFile: async (p: string) => fs.readFileSync(p, "utf-8") }; +} + +(async () => { + const root = path.resolve(__dirname, "..", "..", "src", "test", "fixtures", "factory-args"); + await run("A5-followup: factory calls with arguments resolve the assigned client to openai", async () => { + const calls = await scanFiles(buildFixtureAccess(path.join(root))); + const consumerCalls = calls.filter((c) => c.file.endsWith("consumer.ts") && c.provider === "openai"); + assert.ok( + consumerCalls.length >= 5, + `expected >=5 openai calls (one per assigned client), got ${consumerCalls.length}: ${JSON.stringify(consumerCalls.map((c) => ({ line: c.line, provider: c.provider })))}` + ); + }); +})().catch((err) => { console.error(err); process.exit(1); }); diff --git a/src/test/fixtures/a3-followup/mixed-barrel/anthropic-named.ts b/src/test/fixtures/a3-followup/mixed-barrel/anthropic-named.ts new file mode 100644 index 0000000..d648070 --- /dev/null +++ b/src/test/fixtures/a3-followup/mixed-barrel/anthropic-named.ts @@ -0,0 +1,12 @@ +import Anthropic from "@anthropic-ai/sdk"; + +const anthropic = new Anthropic(); + +export async function ask(prompt: string): Promise { + const r = await anthropic.messages.create({ + model: "claude-opus-4-5", + max_tokens: 256, + messages: [{ role: "user", content: prompt }], + }); + return r.content[0]?.type === "text" ? r.content[0].text : ""; +} diff --git a/src/test/fixtures/a3-followup/mixed-barrel/barrel.ts b/src/test/fixtures/a3-followup/mixed-barrel/barrel.ts new file mode 100644 index 0000000..8bb10c1 --- /dev/null +++ b/src/test/fixtures/a3-followup/mixed-barrel/barrel.ts @@ -0,0 +1,2 @@ +export { default } from "./openai-default"; +export { ask } from "./anthropic-named"; diff --git a/src/test/fixtures/a3-followup/mixed-barrel/consumer.ts b/src/test/fixtures/a3-followup/mixed-barrel/consumer.ts new file mode 100644 index 0000000..67f32ff --- /dev/null +++ b/src/test/fixtures/a3-followup/mixed-barrel/consumer.ts @@ -0,0 +1,7 @@ +import gen, { ask } from "./barrel"; + +export async function handle(q: string): Promise { + const a = await gen(q); + const b = await ask(q); + return a + b; +} diff --git a/src/test/fixtures/a3-followup/mixed-barrel/openai-default.ts b/src/test/fixtures/a3-followup/mixed-barrel/openai-default.ts new file mode 100644 index 0000000..cebcfcd --- /dev/null +++ b/src/test/fixtures/a3-followup/mixed-barrel/openai-default.ts @@ -0,0 +1,11 @@ +import OpenAI from "openai"; + +const openai = new OpenAI(); + +export default async function gen(prompt: string): Promise { + const r = await openai.chat.completions.create({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: prompt }], + }); + return r.choices[0]?.message?.content ?? ""; +} diff --git a/src/test/fixtures/factory-args/consumer.ts b/src/test/fixtures/factory-args/consumer.ts new file mode 100644 index 0000000..926fc95 --- /dev/null +++ b/src/test/fixtures/factory-args/consumer.ts @@ -0,0 +1,18 @@ +import { makeClient } from "./factory"; + +const c0 = makeClient(); +const c1 = makeClient(config); +const c2 = makeClient({ apiKey: process.env.KEY }); +const c3 = makeClient(env, options); +const c4 = makeClient( + env, + options, +); + +export async function run() { + await c0.chat.completions.create({ model: "gpt-4o", messages: [] }); + await c1.chat.completions.create({ model: "gpt-4o", messages: [] }); + await c2.chat.completions.create({ model: "gpt-4o", messages: [] }); + await c3.chat.completions.create({ model: "gpt-4o", messages: [] }); + await c4.chat.completions.create({ model: "gpt-4o", messages: [] }); +} diff --git a/src/test/fixtures/factory-args/factory.ts b/src/test/fixtures/factory-args/factory.ts new file mode 100644 index 0000000..c4a2d93 --- /dev/null +++ b/src/test/fixtures/factory-args/factory.ts @@ -0,0 +1,5 @@ +import OpenAI from "openai"; + +export function makeClient(_config?: unknown): OpenAI { + return new OpenAI(); +}