From a36e755e1be0d5418b1ff699d8ac3adb29479c49 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Tue, 18 Aug 2026 17:29:26 +0200 Subject: [PATCH 1/2] Report unresolvable imports so completeness never certifies a blind spot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `coverage.importsComplete` is the one field that licenses a NEGATIVE conclusion — "the package is not imported, so the vulnerability does not apply". It was computed only from environmental failures (unreadable files, unscannable files, unwalked paths), so a tree the scanner structurally could not enumerate still reported as complete. Neither import scanner can see an import whose module is not a literal. `scanFileImports` reads `ts.preProcessFile(...).importedFiles`, which reports resolved literal specifiers only, and `collectFileImports` requires a string literal. A computed specifier therefore produces no entry at all — the import is not recorded as unresolved, it is absent — while every read-and-scan check still passes. `countComputedSpecifiers()` finds those imports by tokenising (not parsing: this runs on files deliberately kept out of the AST pass, and a text match would count `require` in a comment and hold the flag false forever). It reports: - computed specifiers: `require(REGISTRY[kind])`, `import(name)`, a template with substitutions, and `require("a" + b)` — which opens with a string literal and is still computed - escaped loaders: `const r = require`, `(require)(x)`, `module.exports = require` — once the loader is behind another name, following it needs dataflow this scan does not do Three call forms are distinguished, because the deciding token sits in a different place in each: `require(x)`, the optional `require?.(x)` where `?.` separates the name from the paren, and `loader.require(x)` where a dot before the name means it is someone else's method. Missing the optional form leaves the flag true over an unresolvable import; counting a member method makes the flag permanently false for an app that named a method `require`, which trains a reader to ignore it. Both end in an unchecked negative. Escaped loaders are counted conservatively — an alias counts even when every call through it passes a literal — so a UMD-style wrapper reads as incomplete. That withholds a negative conclusion rather than granting a wrong one. `coverage.importCoverageGaps` reports why, splitting environmental gaps (a re-run may resolve them) from inherent ones (a permanent property of the source). `importsComplete` remains the single gate a consumer reads, and any non-zero count makes it false, so the diagnostic can be ignored without ever licensing a wrong negative. Co-Authored-By: Claude Opus 5 (1M context) --- src/map/extract.ts | 28 ++++- src/map/imports.ts | 156 ++++++++++++++++++++++++++ src/map/types.ts | 28 +++++ tests/map/computed-specifiers.test.ts | 149 ++++++++++++++++++++++++ 4 files changed, 358 insertions(+), 3 deletions(-) create mode 100644 tests/map/computed-specifiers.test.ts diff --git a/src/map/extract.ts b/src/map/extract.ts index 9ac3b7d..e24af92 100644 --- a/src/map/extract.ts +++ b/src/map/extract.ts @@ -10,7 +10,7 @@ import { collectLocalSinks } from './sinks.js'; import { createModuleGraph } from './module-graph.js'; import { isProvenFlow } from './coordinates.js'; import { extractFromFile } from './entries.js'; -import { collectFileImports, createImportInventory, readPathAliases, scanFileImports } from './imports.js'; +import { collectFileImports, countComputedSpecifiers, createImportInventory, readPathAliases, scanFileImports } from './imports.js'; import { collectInvocations, createInvocationInventory } from './invocations.js'; // Framework-AGNOSTIC input-flow extractor. It doesn't gate on a specific stack — it walks any JS/TS @@ -45,6 +45,7 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac let parsed = 0; let preFiltered = 0; let importScanFailures = 0; + let unresolvableImports = 0; let sourceBytes = 0; const calls = { total: 0, dependency: 0, local: 0, ambiguous: 0 }; const startedAt = Date.now(); @@ -62,6 +63,7 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac const scanned = scanFileImports(text, ts); if (scanned === null) importScanFailures++; // this file's imports are unknown, not empty else imports.add(relFile, scanned, false); + unresolvableImports += countComputedSpecifiers(text, ts); continue; } parsed++; @@ -70,6 +72,10 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, guessScriptKind(ts, file)); const bindings = buildModuleBindings(sf, ts); imports.add(relFile, collectFileImports(sf, ts), true); + // Counted on this path too. A parsed file is not a covered file: `collectFileImports` requires a + // string-literal specifier, so a computed require in an entry file is just as unattributable as + // one in a pre-filtered file — and this is the path where it is easiest to assume otherwise. + unresolvableImports += countComputedSpecifiers(text, ts); const ctx = { file, owner: relFile, graph }; // The ctx reaches helper summaries too, so a same-file helper using an imported client resolves. // The invocation inventory rides the parse we already did for sinks — no second pass, which is what @@ -131,12 +137,27 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac // An unwalked subtree is the quietest gap of the three: it produces no file, so no counter moves and // the tree just looks smaller. It has to be part of this or the flag certifies an inventory with a // hole in it. - const importsComplete = importScanFailures === 0 && failed.length === 0 && stats.unwalked === 0; + // Two kinds of gap, one flag. ENVIRONMENTAL gaps (unread, unscanned, unwalked) might close on a + // re-run with different permissions; an INHERENT gap — a specifier computed at runtime — never will, + // because no static pass can resolve it. Both make the inventory incomplete, so both clear the flag; + // they are reported apart so a reviewer can tell "try again" from "this app cannot be answered + // statically" instead of re-running against a permanent property of the source. + const importCoverageGaps = { + unreadableFiles: failed.length, + unscannableFiles: importScanFailures, + unwalkedPaths: stats.unwalked, + unresolvableImports, + }; + const environmentalGaps = failed.length + importScanFailures + stats.unwalked; + const importsComplete = environmentalGaps === 0 && unresolvableImports === 0; notes.push('`imports` lists every package the app imports, from ALL source files — not only files holding an entry point. Absence of a SINK for a package is never evidence; absence of the PACKAGE is evidence only when coverage.importsComplete is true.'); notes.push(`${unmodelled} of ${importList.length} imported package(s) have no recognized sink family (recognizedSinkKinds: []). The extractor models a small set of API families, so for those packages it cannot tell whether input reaches them: a vulnerability in one must stay "needs review" and can never be closed as unreachable using this map.`); - if (!importsComplete) { + if (environmentalGaps > 0) { notes.push(`The import inventory is INCOMPLETE: ${failed.length} file(s) could not be read, ${importScanFailures} could not be scanned, and ${stats.unwalked} path(s) could not be walked at all (unreadable directory, broken link, or a symlink leaving the project). A package may therefore be imported without appearing in \`imports\`. Do not read a package's absence as "not imported" while coverage.importsComplete is false.`); } + if (unresolvableImports > 0) { + notes.push(`The import inventory is INCOMPLETE for a reason no re-run can fix: ${unresolvableImports} import(s) do not name a resolvable module — either the specifier is computed at runtime (\`require(REGISTRY[kind])\`) or the loader itself was aliased (\`const r = require\`), so which package is loaded is not knowable from the source. Those imports appear NOWHERE in \`imports\` — not as an unresolved entry, simply absent — so a package's absence is not evidence of it being unused. This is a property of the application's code, not a scan failure. Reported conservatively: an aliased loader counts even if every call through it uses a literal.`); + } const invocationList = invocations.list(); if (invocationList.length > 0 || parsed > 0) { @@ -178,6 +199,7 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac filesSkipped: failed.length, pathsUnwalked: stats.unwalked, importsComplete, + importCoverageGaps, apiInvocations: invocationList.length, callsTotal: calls.total, callsDependency: calls.dependency, diff --git a/src/map/imports.ts b/src/map/imports.ts index 7edb26e..5c38668 100644 --- a/src/map/imports.ts +++ b/src/map/imports.ts @@ -179,6 +179,162 @@ export function scanFileImports(text: string, ts: TsModule): RawImport[] | null return refs.map((r) => ({ specifier: r.fileName, names: [], line: lineAt(lineStarts, r.pos) })); } +/** + * Count `require(…)` / `import(…)` calls whose specifier is NOT a literal string. + * + * `scanFileImports` cannot see these. It reads `ts.preProcessFile(...).importedFiles`, which reports + * resolved literal specifiers only — a computed specifier produces no entry at all, so the import is + * not merely unrecorded, it is invisible. That matters because the inventory's contract says a + * package's ABSENCE is evidence when `importsComplete` is true, and `require(REGISTRY[kind])` makes + * absence unprovable while leaving every read-and-scan check satisfied. + * + * Tokenised rather than parsed: this runs on files deliberately kept out of the AST pass, so it must + * stay at scanner cost. Tokenising (rather than a regex) is what keeps `// require(x)` in a comment + * and `"require(y)"` in a string from counting — a text match would clear the completeness flag on + * prose about `require`, which trades a false negative for a permanent false alarm. + */ +export function countComputedSpecifiers(text: string, ts: TsModule): number { + let scanner: any; + try { + scanner = ts.createScanner(ts.ScriptTarget.Latest, /* skipTrivia */ true, undefined, text); + } catch { + return 0; // fail-open: the caller already treats an unscannable file as incomplete + } + + // `require` does NOT arrive as an identifier: TypeScript scans it as the contextual `RequireKeyword` + // (it appears in `import x = require(…)`). Keying on the token TEXT with a kind allowlist covers both + // spellings, and survives a TS version that classifies it differently — which is worth the small + // looseness here, because the failure mode of a missed kind is a silent zero count, i.e. exactly the + // false "complete" this function exists to prevent. + const CALLEE_KINDS = new Set([ + ts.SyntaxKind.Identifier, + ts.SyntaxKind.RequireKeyword, + ts.SyntaxKind.ImportKeyword, + ].filter((k) => k !== undefined) as number[]); + + // Three tokens of history, because one is not enough to identify the callee. The token that decides + // whether this is CommonJS `require` sits on a different side of the name in each form: + // + // require(x) callee is the previous token + // require?.(x) `?.` sits BETWEEN the name and `(` — the callee is two back + // loader.require(x) an app method that merely shares the name — `.` sits BEFORE the callee + // loader.require?.(x) both at once, so the qualifier is three back + // + // Getting either direction wrong is a real failure, and they fail in opposite ways: missing the + // optional form leaves `importsComplete` true over an unresolvable import, while counting a member + // method makes it permanently false for an app that named a method `require` — which trains a reader + // to ignore the field. Hence a qualifier check rather than a looser name match. + const DOT_KINDS = new Set([ts.SyntaxKind.DotToken, ts.SyntaxKind.QuestionDotToken] + .filter((k) => k !== undefined) as number[]); + + interface Token { kind: number | undefined; text: string } + const EMPTY: Token = { kind: undefined, text: '' }; + // Most recent first: history[0] is the token before the current one. + const history: [Token, Token, Token] = [EMPTY, EMPTY, EMPTY]; + const remember = (kind: number, text: string): void => { + history[2] = history[1]; + history[1] = history[0]; + history[0] = { kind, text }; + }; + + /** + * Did the token just before `next` leave a bare `require` unresolvable? + * + * Deliberately asked about `require` only. `import` is a keyword that legitimately appears without a + * following paren in every ESM file (`import qs from "qs"`, `import.meta.url`), so the same rule there + * would report a gap for ordinary static imports — the exact always-false flag the member-method guard + * exists to prevent. + */ + const escapedLoaderReference = (next: number): boolean => { + const callee = history[0]; + const qualifier = history[1]; + + if (callee.kind === undefined || !CALLEE_KINDS.has(callee.kind) || callee.text !== 'require') return false; + // Someone's method named `require` — not the loader. + if (qualifier.kind !== undefined && DOT_KINDS.has(qualifier.kind)) return false; + + // Called in place (`require(…)` / `require?.(…)`) is the resolvable form the rest of this function + // judges, and `require.resolve` / `require.cache` reach a property of the loader without loading a + // module at all. Anything else means the loader itself became a value. + return next !== ts.SyntaxKind.OpenParenToken + && next !== ts.SyntaxKind.QuestionDotToken + && next !== ts.SyntaxKind.DotToken; + }; + + let count = 0; + + try { + for (let token = scanner.scan(); token !== ts.SyntaxKind.EndOfFileToken; token = scanner.scan()) { + const text = scanner.getTokenText(); + + // `require` used as a VALUE rather than called in place: `const r = require`, `(require)(x)`, + // `module.exports = require`. Once the loader is behind another name, resolving what it loads + // needs dataflow this scan does not do — so the honest answer is that the inventory has a gap, + // and the conservative one is the same answer. Counted at the ESCAPE, not at the later call: + // whatever `r(…)` loads is unknowable from here, literal argument or not. + if (escapedLoaderReference(token)) count++; + + if (token !== ts.SyntaxKind.OpenParenToken) { + remember(token, text); + continue; + } + + // `?.` immediately before the paren shifts both the callee and its qualifier one place back. + const optionalCall = history[0].kind === ts.SyntaxKind.QuestionDotToken; + const callee = optionalCall ? history[1] : history[0]; + const qualifier = optionalCall ? history[2] : history[1]; + + const isRequireCall = callee.kind !== undefined + && CALLEE_KINDS.has(callee.kind) + && (callee.text === 'require' || callee.text === 'import') + // A dot before the name means it is a property of something else, so this is that object's + // method and says nothing about module loading. + && !(qualifier.kind !== undefined && DOT_KINDS.has(qualifier.kind)); + + if (!isRequireCall) { + remember(token, text); + continue; + } + + remember(token, text); + + const argument = scanner.scan(); + const argumentText = scanner.getTokenText(); + remember(argument, argumentText); + + const isLiteral = argument === ts.SyntaxKind.StringLiteral + || argument === ts.SyntaxKind.NoSubstitutionTemplateLiteral; + + if (!isLiteral) { + // A template WITH substitutions (`require(\`./\${name}\`)`) lands here too, correctly: its + // value is not knowable statically either. + count++; + continue; + } + + // Starting with a literal is not the same as BEING one: `require("node-" + pkg)` opens with a + // string and is still computed. The literal is the whole specifier only if the argument ends + // right here — a `)`, or a `,` for `import("./m", { with: … })`, where the literal is complete + // and only import attributes follow. Anything else continues the expression. + const following = scanner.scan(); + remember(following, scanner.getTokenText()); + + const argumentEnded = following === ts.SyntaxKind.CloseParenToken + || following === ts.SyntaxKind.CommaToken; + + if (!argumentEnded) count++; + } + + // The loop exits on EOF without processing it, so a file ENDING in a bare `require` + // (`module.exports = require`) would otherwise escape unnoticed. + if (escapedLoaderReference(ts.SyntaxKind.EndOfFileToken)) count++; + } catch { + return count; // whatever was counted before the scanner gave up still clears the flag + } + + return count; +} + /** * Aggregates per-file import edges into the per-package inventory. Order-independent: the output is * sorted, so two runs over the same tree produce byte-identical documents. diff --git a/src/map/types.ts b/src/map/types.ts index fe3b5c6..5c153cb 100644 --- a/src/map/types.ts +++ b/src/map/types.ts @@ -332,6 +332,34 @@ export interface Coverage { * true, not merely that it isn't false. */ importsComplete?: boolean; + /** + * WHY the inventory is incomplete, for a reader deciding what to do about it. Diagnostic only: + * `importsComplete` remains the single gate a consumer reads, and any non-zero count here makes it + * false — so this can be ignored entirely without ever licensing a wrong negative. + * + * The split that matters is durability. The first three are environmental: a permission, a broken + * link, a symlink out of the project — a re-run in a different context may resolve them. + * `unresolvableImports` is inherent: the application does not name the module in a way any static pass + * can resolve. Collapsed into one number, a reviewer re-runs the scan against a permanent property of + * the source and reads the identical result as a flake. + */ + importCoverageGaps?: { + /** Files present but unreadable. */ + unreadableFiles: number; + /** Files read but whose imports could not be scanned. */ + unscannableFiles: number; + /** Paths that produced no file at all — the quietest gap, since no per-file counter moves. */ + unwalkedPaths: number; + /** + * Imports whose module cannot be determined statically: a computed specifier (`require(expr)`, + * `import(expr)`) or an aliased loader (`const r = require`, `(require)(x)`). + * + * Counted conservatively — an aliased loader counts even when every call through it passes a + * literal, because following the alias needs dataflow this scan does not do. That biases toward a + * false "incomplete", which withholds a negative conclusion rather than granting a wrong one. + */ + unresolvableImports: number; + }; /** Source roots analyzed, repo-relative. */ roots: string[]; /** Honest notes on what static analysis could not resolve (dynamic dispatch, indirection, …). */ diff --git a/tests/map/computed-specifiers.test.ts b/tests/map/computed-specifiers.test.ts new file mode 100644 index 0000000..60d76c4 --- /dev/null +++ b/tests/map/computed-specifiers.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect } from 'vitest'; +import ts from 'typescript'; +import { countComputedSpecifiers } from '../../src/map/imports.js'; + +// Detection of imports whose module name is computed at runtime. +// +// This exists to clear `coverage.importsComplete`, which is the one field licensing a NEGATIVE +// conclusion — "the package is not imported, so the vulnerability does not apply". A computed +// specifier is invisible to both import scanners, so without this the map reports a complete +// inventory for an app whose dependencies it could not enumerate. +// +// Both directions are load-bearing, in opposite ways. A miss (undercount) is silent and unsafe: the +// flag stays true and a real finding is closed. A false positive is loud and permanent: the flag can +// never become true for an app that merely mentions `require` in a comment, so every negative +// conclusion is blocked forever and the field becomes noise a consumer learns to ignore. That is why +// this tokenises instead of matching text. + +const count = (src: string) => countComputedSpecifiers(src, ts as never); + +describe('a computed specifier is detected', () => { + it.each([ + ['a property lookup', 'const p = require(REGISTRY[kind]);'], + ['a bare identifier', 'const p = require(name);'], + ['a template with a substitution', 'const p = require(`./plugins/${name}`);'], + ['a call result', 'const p = require(resolveName());'], + ['string concatenation', 'const p = require("node-" + "serialize");'], + ['a dynamic import expression', 'const m = await import(specifier);'], + ])('%s', (_label, src) => { + // `require("node-" + "serialize")` is genuinely computed, even though a human can read the value: + // the scanner does not constant-fold, and treating it as knowable would mean claiming an import + // this map never resolved. + expect(count(src)).toBe(1); + }); +}); + +describe('a resolvable specifier is not', () => { + it.each([ + ['a string literal require', 'const e = require("express");'], + ['a literal dynamic import', 'const m = await import("./routes.js");'], + ['a template with no substitution', 'const e = require(`express`);'], + ['a static ESM import', 'import qs from "qs";\nimport { z } from "zod";'], + ['a require in a line comment', '// call require(whatever) to load a plugin\nconst e = require("qs");'], + ['a require in a block comment', '/* require(name) is the dynamic form */\nconst e = require("qs");'], + ['a require inside a string', 'const doc = "use require(name) to load";'], + ['an unrelated call', 'const x = compute(REGISTRY[kind]);'], + ])('%s', (_label, src) => { + // The comment and string cases are the reason this is tokenised. A regex over source text counts + // prose, and a project with one such comment could never report a complete inventory again. + expect(count(src)).toBe(0); + }); +}); + +// The three call forms, each tested in both polarities. One token of history cannot tell them apart: +// the deciding token is between the name and the paren in the optional form, and before the name in the +// member form. Both mistakes are real and they fail in opposite directions — a missed optional require +// leaves the inventory certified complete over an import nobody can resolve, while a counted app method +// makes completeness unreachable for that project forever. +describe('call forms are distinguished', () => { + it.each([ + ['optional require, computed', 'const p = require?.(REGISTRY[kind]);', 1], + ['optional require, literal', 'const e = require?.("express");', 0], + ['optional import, computed', 'const m = await import?.(name);', 1], + ['direct require, computed', 'const p = require(REGISTRY[kind]);', 1], + ['direct require, literal', 'const e = require("express");', 0], + ])('%s', (_label, src, want) => { + expect(count(src)).toBe(want); + }); + + it.each([ + ['a method named require', 'const p = loader.require(name);'], + ['an optional method named require', 'const p = loader?.require(name);'], + ['an optional call on a method named require', 'const p = loader.require?.(name);'], + ['a nested method named require', 'const p = app.loaders.require(name);'], + ['a method named import', 'const p = registry.import(name);'], + ])('%s is not module loading', (_label, src) => { + // An app method that happens to share the name says nothing about which module is loaded. Counting + // it would hold `importsComplete` false on evidence of nothing, and a flag that is always false + // stops being read — costing the true positives it exists to carry. + expect(count(src)).toBe(0); + }); + + it('counts a real computed require in a file that also has a require-named method', () => { + const src = ` + const a = loader.require(name); + const b = require?.(REGISTRY[kind]); + `; + + // Both rules at once, which is the case a single-token implementation cannot get right in either + // direction: exactly one of these two lines is an unresolvable module specifier. + expect(count(src)).toBe(1); + }); +}); + +// An aliased loader is a gap, not a clean file. +// +// Once `require` is behind another name, following it needs dataflow this scan does not do. The choice +// here is deliberate and conservative: report the gap at the point the loader ESCAPES, rather than try +// to track the alias. It costs a false "incomplete" on code that aliases `require` and only ever calls +// it with literals — accepted, because the alternative is certifying an inventory as complete when a +// single line of indirection could be loading anything. +describe('an aliased loader is reported as a gap', () => { + it.each([ + ['assigned to a variable', 'const r = require;\nr(REGISTRY[kind]);'], + ['parenthesised callee', '(require)(REGISTRY[kind]);'], + ['assigned then called with a literal', 'const r = require;\nr("express");'], + ['re-exported', 'module.exports = require;'], + ['passed to a function', 'register(require, exports);'], + ['stored on an object', 'const box = { load: require };'], + ])('%s', (_label, src) => { + // Reported once, at the escape. The later `r(…)` is not counted again: it is the same single piece + // of missing knowledge, and the count is a diagnostic a human reads, not a tally of call sites. + expect(count(src)).toBe(1); + }); + + it('does not fire on loader properties that load nothing', () => { + // `require.resolve` returns a path and `require.cache` is a map — neither pulls in a module, so + // neither leaves the inventory unable to answer. + expect(count('const p = require.resolve("express");\ndelete require.cache[p];')).toBe(0); + }); + + it('does not fire on a static ESM import or import.meta', () => { + // The reason this rule is asked about `require` only: `import` appears without a following paren in + // ordinary ESM, so applying it there would report a gap for every normal file and make the flag + // useless in the other direction. + expect(count('import qs from "qs";\nconst here = import.meta.url;')).toBe(0); + }); +}); + +describe('counting', () => { + it('reports each computed specifier, not just whether one exists', () => { + const src = ` + const a = require("qs"); + const b = require(FIRST[k]); + const c = require(second); + const d = await import("./ok.js"); + `; + + // The count reaches the reader as a diagnostic, so it has to be the real number: "2 imports could + // not be resolved" is actionable in a way "some import could not be resolved" is not. + expect(count(src)).toBe(2); + }); + + it('survives source it cannot tokenise cleanly', () => { + // Fail-open, like every other path in the extractor: unbalanced source must not throw out of the + // scan. Whatever was counted before the scanner gave up still counts, since a partial count clears + // the completeness flag just as a full one does. + expect(() => count('const a = require(X[k]); function ( { unterminated `')).not.toThrow(); + }); +}); From 6d8eda7fa172ea58859a0c156655f10f6df2c402 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Tue, 18 Aug 2026 17:32:28 +0200 Subject: [PATCH 2/2] Name the counter for what it counts `countComputedSpecifiers` gained escaped-loader detection (`const r = require`) but kept a name and docblock describing only computed specifiers, so the function no longer matched either its own behaviour or the `unresolvableImports` field it feeds. Renamed to `countUnresolvableImports`, with the test file and the docblock following. No behaviour change. Terminology drift is worth a commit of its own here: the emitted field is what a consumer reads to decide whether a package's absence is evidence, and a name that describes half the behaviour invites the next reader to add the other half again somewhere else. Co-Authored-By: Claude Opus 5 (1M context) --- src/map/extract.ts | 6 +++--- src/map/imports.ts | 11 +++++++++-- ...pecifiers.test.ts => unresolvable-imports.test.ts} | 4 ++-- 3 files changed, 14 insertions(+), 7 deletions(-) rename tests/map/{computed-specifiers.test.ts => unresolvable-imports.test.ts} (98%) diff --git a/src/map/extract.ts b/src/map/extract.ts index e24af92..c19a5cb 100644 --- a/src/map/extract.ts +++ b/src/map/extract.ts @@ -10,7 +10,7 @@ import { collectLocalSinks } from './sinks.js'; import { createModuleGraph } from './module-graph.js'; import { isProvenFlow } from './coordinates.js'; import { extractFromFile } from './entries.js'; -import { collectFileImports, countComputedSpecifiers, createImportInventory, readPathAliases, scanFileImports } from './imports.js'; +import { collectFileImports, countUnresolvableImports, createImportInventory, readPathAliases, scanFileImports } from './imports.js'; import { collectInvocations, createInvocationInventory } from './invocations.js'; // Framework-AGNOSTIC input-flow extractor. It doesn't gate on a specific stack — it walks any JS/TS @@ -63,7 +63,7 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac const scanned = scanFileImports(text, ts); if (scanned === null) importScanFailures++; // this file's imports are unknown, not empty else imports.add(relFile, scanned, false); - unresolvableImports += countComputedSpecifiers(text, ts); + unresolvableImports += countUnresolvableImports(text, ts); continue; } parsed++; @@ -75,7 +75,7 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac // Counted on this path too. A parsed file is not a covered file: `collectFileImports` requires a // string-literal specifier, so a computed require in an entry file is just as unattributable as // one in a pre-filtered file — and this is the path where it is easiest to assume otherwise. - unresolvableImports += countComputedSpecifiers(text, ts); + unresolvableImports += countUnresolvableImports(text, ts); const ctx = { file, owner: relFile, graph }; // The ctx reaches helper summaries too, so a same-file helper using an imported client resolves. // The invocation inventory rides the parse we already did for sinks — no second pass, which is what diff --git a/src/map/imports.ts b/src/map/imports.ts index 5c38668..93bbc74 100644 --- a/src/map/imports.ts +++ b/src/map/imports.ts @@ -180,7 +180,14 @@ export function scanFileImports(text: string, ts: TsModule): RawImport[] | null } /** - * Count `require(…)` / `import(…)` calls whose specifier is NOT a literal string. + * Count imports whose module cannot be determined statically. Two shapes, one number — both feed + * `coverage.importCoverageGaps.unresolvableImports`: + * + * 1. a COMPUTED specifier — `require(REGISTRY[kind])`, `import(name)`, `require("a" + b)` + * 2. an ESCAPED loader — `const r = require`, `(require)(x)`, `module.exports = require` + * + * The second is reported at the escape rather than at the later call: once the loader is behind another + * name, what it loads is unknowable here whether or not the argument is a literal. * * `scanFileImports` cannot see these. It reads `ts.preProcessFile(...).importedFiles`, which reports * resolved literal specifiers only — a computed specifier produces no entry at all, so the import is @@ -193,7 +200,7 @@ export function scanFileImports(text: string, ts: TsModule): RawImport[] | null * and `"require(y)"` in a string from counting — a text match would clear the completeness flag on * prose about `require`, which trades a false negative for a permanent false alarm. */ -export function countComputedSpecifiers(text: string, ts: TsModule): number { +export function countUnresolvableImports(text: string, ts: TsModule): number { let scanner: any; try { scanner = ts.createScanner(ts.ScriptTarget.Latest, /* skipTrivia */ true, undefined, text); diff --git a/tests/map/computed-specifiers.test.ts b/tests/map/unresolvable-imports.test.ts similarity index 98% rename from tests/map/computed-specifiers.test.ts rename to tests/map/unresolvable-imports.test.ts index 60d76c4..d0fc3fc 100644 --- a/tests/map/computed-specifiers.test.ts +++ b/tests/map/unresolvable-imports.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import ts from 'typescript'; -import { countComputedSpecifiers } from '../../src/map/imports.js'; +import { countUnresolvableImports } from '../../src/map/imports.js'; // Detection of imports whose module name is computed at runtime. // @@ -15,7 +15,7 @@ import { countComputedSpecifiers } from '../../src/map/imports.js'; // conclusion is blocked forever and the field becomes noise a consumer learns to ignore. That is why // this tokenises instead of matching text. -const count = (src: string) => countComputedSpecifiers(src, ts as never); +const count = (src: string) => countUnresolvableImports(src, ts as never); describe('a computed specifier is detected', () => { it.each([