fix(typescript): resolve imports against declared config, not path suffixes (#2953) (#2956)

* fix(typescript): resolve imports against declared config, not path suffixes (#2953)

TypeScript/JavaScript/Vue import resolution ended in `suffixResolve`, which
answers "does any file in this repo have a path ending in this specifier?" and
answers it by dropping leading segments until something matches. That is not
module resolution, and it failed in both directions at once:

  - `@acme/telemetry/nest`, a registry dependency with no in-repo file, landed
    on the repo's only path ending in `nest/index.ts` — a false IMPORTS edge at
    confidence 1.0, indistinguishable downstream from a real one. The reporter
    measured 44 of 74 `apps/ -> packages/` edges landing on two such files.
  - `@repo/utils`, a first-party workspace package, resolved to nothing: its
    name lives in `packages/utils/package.json` and appears in no file path, so
    a path matcher cannot find it. Zero CALLS from 75 import statements.

Both come from the same missing input — nothing read the config that says what
exists — so both are fixed by reading it.

Replaces the suffix matcher on this path with the algorithm tsc and Node
actually run, in their order: relative/absolute, `#imports`, tsconfig `paths`
(longest literal prefix wins, every target tried), tsconfig `baseUrl`, then the
workspace package's own `exports`/`main`. A specifier none of those declare is
external, and resolves to nothing. There is deliberately no fallback.

New:
  - `typescript/tsconfig.ts` — every tsconfig/jsconfig in the repo with
    `extends` chains resolved, nearest-config-wins per file. The old loader read
    three filenames at the repo root, required `paths` to exist, and kept only
    `targets[0]` — none of which describes a monorepo, where `apps/web/
    tsconfig.json` is what governs `apps/web/src/main.ts`.
  - `typescript/module-resolution.ts` — the algorithm.
  - `typescript/file-candidates.ts` — 11 TS-family extensions, replacing a
    shared 39-entry list spanning every indexed language, so a TypeScript
    import can no longer resolve to a `.py` file.
  - `import-resolvers/node-workspace-packages.ts` — in-repo manifests, with
    `exports` subpath maps, patterns, condition nesting, and the restriction
    that a package declaring `exports` exposes only what it lists.

The per-pass `SuffixIndex` is gone from these three adapters: real resolution
derives nothing from the file list — every candidate comes from a declared
source and is checked with one `Set.has` — so there is nothing left to cache.
Their `*-import-index-reuse` guards and the JS index-vs-scan differential are
deleted with the mechanism they measured; the cross-language contract test
moves the three languages to its existing `KNOWN_UNINDEXED` channel, and pins
the exemption as a list so a fourth arrival is deliberate.

Python, Ruby, Java, Go and the rest still route through `suffixResolve` and are
untouched here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* test(scope-resolution): assert every resolver refuses external imports (#2953)

One property, for all 16 registered resolvers: a specifier naming something
outside the repository must not resolve to a file inside it.

That is the property #2953 was filed against, and its violation is not a
missing edge but a fabricated one — an IMPORTS edge at full confidence between
two files with no relationship, which `impact` then reports as blast radius.
The mechanism is shared (`suffixResolve`), so the guard is too.

Every case pairs an external specifier with a DECOY: an unrelated in-repo file
whose path ends the way the specifier does. Without one a resolver that merely
found nothing would pass while holding no property at all, so each case also
asserts the decoy is reachable by the spelling that SHOULD find it — a typo in
a fixture cannot manufacture a pass.

Two fixtures had to be corrected before the results meant anything, and both
would have recorded a false gap:

  - C# reads its #1881 gate from scanned namespace evidence and fails OPEN
    without any, so passing `undefined` measured nothing. Armed, C# holds.
  - C++ was posting a pass on an extension mismatch (`vector` could never match
    `src/vector.hpp` whatever the resolver did). Given the header spelling, it
    does not hold.

Result: six hold it — TypeScript, JavaScript and Vue because they resolve
against declared config only (#2953); Python (#898) and C# (#1881) because they
gate the fallback on in-repo evidence; Rust because `::` never decomposes into
a path suffix, which the decoy-reachability arm confirms is a real pass rather
than a vacuous one.

Ten do not, and are recorded in KNOWN_GAPS with what each currently answers:
Java, Kotlin, Go, Ruby, PHP, Dart, Swift, C, C++, COBOL. The map is a work
list, not an allowance — the entries are ASSERTED, so a language that starts
holding the property fails here and its line gets deleted deliberately rather
than rotting into a lie.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* fix(typescript): admit only declared workspace packages, and fix four resolver defects (#2953)

Review of #2956 found one boundary bug and four correctness defects. The
boundary one is the same defect class this PR exists to fix, arriving from a
different direction.

## The workspace boundary (review)

`loadNodeWorkspacePackages` registered every `package.json` the repo-wide scan
found, and never read `pnpm-workspace.yaml` or a root `workspaces`
declaration. Finding a manifest is not the same as the workspace admitting one:
an app importing registry package `foo` would bind to an excluded fixture or
example that happens to declare `name: "foo"` — the false-positive half of
#2953, from a new source of evidence. This repository is the example, since
`test/fixtures/**` declares `@repo/utils` among others.

The admitted set now comes from the declaration — `workspaces` (array and yarn
object form), `pnpm-workspace.yaml`, `lerna.json`, with `!` exclusions and
`*`/`**` — plus the root package itself. A repo that declares no workspace has
exactly one package: the root. A negative fixture pins it, with a named package
outside the declared globs that must not resolve.

## Four defects

  - tsconfig `paths` targets were resolved against the config's own directory
    when it declared `paths` but inherited `baseUrl`. tsc resolves them against
    the EFFECTIVE base, so an extending config loaded the right alias pattern
    and pointed every target at the wrong directory.
  - two configs in one directory were ranked by directory-listing order, so
    `tsconfig.base.json` could govern instead of `tsconfig.json` and a config's
    own `paths` went invisible. Found by the test written for the fix above.
  - an unexported package subpath also tried `<dir>/src/<subpath>`. Nothing
    declares that mapping; it is the same kind of guess this PR removes, and
    the import it "resolved" is broken in the real project too.
  - `imports` pattern keys (`"#internal/*"`) were looked up exactly, so a valid
    `#internal/foo` never matched. `exports` and `imports` now share one
    matcher, which is where they should never have diverged.
  - a relative specifier climbing past the repo root was silently clamped, so
    `../../../secret` from `src/main.ts` became `secret` and could resolve a
    root file it never named.

## Test rigor

The conformance suite asserted less than it claimed. The decoy-reachability arm
only checked non-empty, so five cases paired `reachesDecoy` with a different
file than `decoy` and passed while establishing nothing; the KNOWN_GAPS arm
likewise accepted any in-repo answer instead of the recorded one. Both now
assert the exact file. The reachability arm runs only for languages that HOLD
the property — for a gap language the recorded-answer assertion IS that proof,
and for Swift and COBOL no other spelling exists, since `Foundation` and
`EXTERNAL` name the in-repo directory and copybook as well as the external
module, which is precisely why those resolvers cannot tell them apart.

## Benchmarks

Both `--check` guards were red, and both were reporting something true.

`import-target`: the ts-family arms resolved 0 of 3200 imports. Their corpus is
bare specifiers with no config, which the deleted `suffixResolve` answered
without one — so the arms measured an empty branch while printing a clean
scaling ratio. Each now carries the config its corpus is spelled for, and the
`deep` arm's uniform prefix reaches it. THE FINGERPRINTS THEN MATCHED THE
RECORDED BASELINES EXACTLY: same corpus, same targets, once the config it
always implied is passed explicitly. Retained per-pass index went from
26 745 296 B (js, ts) and 28 884 016 B (vue) at 32 000 files to 0-16 B, because
these resolvers no longer build one; they move to the `HEAP_BOUNDED` tier rust
already occupies for the same reason. Depth ratio moved 2.0 -> ~2.2 and the
budget goes to 2.6: candidates now carry the 16-segment baseUrl prefix, so each
`Set.has` hashes a longer string — linear in path LENGTH, independent of file
COUNT.

`scope-capture`: TypeScript capture fingerprint drift, caused by this PR's 12
new `.ts` fixtures entering the corpus. Attribution is exact rather than
inferred — moving that one fixture directory aside returns the fingerprint to
`f719163e…` byte-for-byte with `fixture_count` back at 155 and all 15 languages
passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* fix(typescript): honour exports fallback arrays, paths precedence and package extends (#2953)

Second review round. Four findings, judged against what this tool is: a static
analyser building a code graph, not a compiler. The bar is resolving what the
project DECLARES, on a checkout that may never have been built or installed,
and never inventing an edge.

  - `exports` and `imports` ARRAYS were skipped. An array is Node's ordered
    fallback list, and `{"./feature": ["./dist/feature.js", "./src/feature.ts"]}`
    is exactly what a workspace package publishes to mean "built output, or
    source". Skipping it dropped the declaration entirely and left the package
    looking as though it exported no subpaths. The source arm is the one that
    matters here, because `dist/` is build output and is not indexed — and for
    a static analyser the build need not have run at all.
  - an exact `paths` pattern did not reliably outrank a wildcard. `a` and `a*`
    both match `a` with the same literal prefix length, so sorting on length
    alone left tsc's exact-wins rule to declaration order.
  - package-form `extends` (`"@acme/tsconfig"`) was refused outright. Not
    indexing `node_modules` is different from not READING it, and a shared
    internal base is where a monorepo puts the `paths` its packages import
    through. It is now read from disk, walking `node_modules` up from the
    extending config the way Node does, and absent on an un-installed checkout
    it degrades to whatever that config declared itself.

    The test pins what tsc actually does with such a base rather than what one
    might hope: `extends` never rebases `baseUrl`, so a package base's paths
    point at the package's own directory. That is why a published base rarely
    contributes aliases a repo's files resolve through, and why the
    `@tsconfig/*` family — which sets `target` and `lib`, never `paths` — is a
    no-op here either way.
  - CodeQL flagged `String.replace('*', …)` in two places as replacing only the
    first occurrence. Node subpath patterns and tsconfig `paths` both allow AT
    MOST one `*`, so that IS the specified behaviour — but the spelling states
    it by accident and reads as the replace-all footgun. `substituteStar`
    slices at the known index and says the rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

* fix(typescript): treat `exports` as the whole interface, and keep empty tsconfig scopes (#2953)

Third review round. Two findings, both valid, both cases of this resolver being
laxer than the thing it models — which is the direction that fabricates edges.

  - `exports`, when a manifest declares it, is the package's ENTIRE public
    interface: Node ignores `main` outright and refuses any subpath the map
    does not list. This resolver already honoured that restriction for
    SUBPATHS and not for the package ROOT, which is the same rule. A manifest
    exporting only `"./feature"` therefore still answered a bare `@repo/pkg`
    with `main` or `src/index` — an edge for an import that does not resolve in
    the real project. Legacy and conventional root candidates are now offered
    only when there is no `exports` field at all.

  - a tsconfig declaring neither `baseUrl` nor `paths` was dropped rather than
    kept as an empty scope, so `tsconfigFor` fell through to an enclosing
    config. A package whose own tsconfig declares no `baseUrl` — meaning its
    non-relative specifiers are package lookups — silently inherited the repo
    root's aliases instead. An empty scope is the accurate answer for such a
    file, and only a scope can express it.

Both are pinned at the level they broke: the manifest arms assert what
`readManifest` produces, not a hand-built package, since the resolver honouring
empty entries and the loader producing them are different claims.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Px638Zyqa9CJMUU7DsJoB

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Magyar 2026-08-14 10:13:04 +01:00 • committed by GitHub
parent 77a8e3895d
commit 4a7de2af8d
43 changed files with 2846 additions and 1683 deletions

File diff suppressed because one or more lines are too long

View file

@ -82,12 +82,27 @@
* component of the IMPORTER, so per-import cost is quadratic in path depth:
* measured depth_ratio 7.39, by far the largest here, and the reason its
* depth budget is 11 rather than the ~2 most languages carry.
* - javascript, typescript, vue: one resolver (`resolveTsTarget`) behind
* - javascript, typescript, vue: one resolver (`resolveTsModule`) behind
* three adapters, so the three corpora are the same shape and differ only
* in what actually differs — the extension list (`.js` vs `.ts`) and, for
* Vue, the tsconfig alias branch (see `VUE_TSCONFIG`). All three are
* miss-dominated bare specifiers, because a relative import resolves by
* exact `Set.has` and never reaches the leg that had no index.
* in what actually differs — the extension list (`.js` vs `.ts`) and which
* config leg the arm exercises (`tsBaseUrlConfig` vs `vueTsconfig`). All
* three are miss-dominated bare specifiers.
*
* What they measure CHANGED with #2953. The leg used to be `suffixResolve`,
* a repo-wide search for a path ending in the specifier; these three no
* longer have it, and resolve only against a declared tsconfig mapping or a
* package manifest. Two consequences the numbers show:
*
* - the arms need a `resolutionConfig` to resolve anything at all. With
* none they all reported `resolved: 0` — every import correctly
* external — while still printing a clean scaling ratio, which is a
* bench measuring an empty branch and passing exactly like one
* measuring a full one.
* - `depth_ratio` is now structurally flat for them, and that is the
* result rather than a weakened arm: declared resolution never walks
* path components, so the `deep` arm's uniform prefix reaches the
* config (see `tsBaseUrlFor`) and its cost is the same keyed lookup the
* other arms pay.
* - c, cpp: `resolveCppImportTarget` delegates to `resolveCImportTarget`, so
* the two share a resolver and differ in extension set and in which adapter
* builds the augmented set. Cost is a basename bucket walk with a
@ -196,9 +211,10 @@
* - of the eight added later, swift (3.28) and c/cpp (2.54/2.64) are the two
* that scan a bucket, and they scan DIFFERENT buckets: swift's is the
* module's own file list, which it returns, and C's is the basename bucket
* its suffix fallback walks. python, javascript, typescript and vue answer
* from keyed maps and sit at 1.03-1.10, so they keep the linear budget and
* that immunity is their assertion, exactly as for ruby and kotlin;
* its suffix fallback walks. python answers from keyed maps and sits at
* 1.03-1.10, and javascript, typescript and vue answer from a declared
* config (#2953) — so all four keep the linear budget and that immunity is
* their assertion, exactly as for ruby and kotlin;
* - rust's collide arm is the one that is NOT a shared-leaf layout, and the
* reason is in the list above: file count is not an axis its cost has, so a
* shared-leaf rust arm would have been an arm that cannot fail. Its collide
@ -556,7 +572,6 @@ const HEAP_BUDGETED = [
'ruby',
'php',
'java',
'javascript',
'python',
'c',
// Promoted once every language was actually measured. Each retains a real
@ -580,10 +595,21 @@ const HEAP_BUDGETED = [
'kotlin',
'dart',
'go',
'typescript',
'vue',
'cpp',
];
// javascript, typescript and vue were budgeted here until #2953 and are now
// BOUNDED, which is a demotion in gate strength and a promotion in what the
// number means. They retained ~26.7 MiB each because they built a per-pass
// `SuffixIndex` over the whole file list; they no longer build one at all,
// because declared resolution derives nothing from the file set — a candidate
// comes from a tsconfig mapping or a manifest and is checked with one
// `Set.has`. The readings are 0-16 B.
//
// A floor over a reading at or below its own noise gates the noise, which is
// the same reason rust sits in this tier at 16 B — so they take a bound and no
// floor. The bound is what still matters: it catches these three growing an
// index again, which is the re-entry condition for the cost #2911 and #1918
// were about.
/**
* The arms handed the fifth `context` argument — `{ parsedFiles, parsedImport }`
@ -649,17 +675,57 @@ const CSPROJ_CONFIGS = [
{ rootNamespace: 'Lib', projectDir: '' },
];
/**
* The `tsconfigPaths` the Vue arm threads as `resolutionConfig`.
* The `resolutionConfig` the ts-family arms thread (#2953).
*
* The Vue adapter is `resolveTsTarget` with `language: TypeScript` and nothing
* else, so with a null config its arm would be a byte-for-byte re-run of the
* TypeScript one over a differently-spelled corpus. The alias branch
* (`standard.ts:57-70`) is the one leg of the shared resolver that neither the
* `javascript` arm (which pins `tsconfigPaths: null`) nor the `typescript` arm
* here reaches, so wiring it is what makes this a third measurement rather than
* a third copy — and every local Vue import below is spelled `@/…`.
* These three used to run with `tsconfigPaths: null` for javascript and
* typescript and an alias map for vue, because the leg being measured was
* `suffixResolve` — a repo-wide search for a path ending in the specifier,
* which needs no configuration to answer and answered even when nothing
* declared the import. #2953 deleted that leg for the ts family: a specifier
* now resolves only against a declared tsconfig mapping or a package manifest.
*
* With no config, therefore, all three arms resolve NOTHING — every import is
* correctly external — and the bench measures an empty branch while reporting a
* perfect scaling ratio. A bench that measures nothing passes exactly like one
* that measures something, so each arm is given the config its corpus is
* spelled for, and the two configs cover the two legs the new resolver has:
*
* - `TS_BASE_URL` — `baseUrl` at the repo root, so `src/mod3/file7` resolves
* the way a `baseUrl` project's absolute import does. Used by javascript and
* typescript.
* - `vueTsconfig` — a `paths` PATTERN, which is a different branch:
* longest-prefix selection and `*` substitution, then a candidate probe per
* target. Every local Vue import below is spelled `@/…`, so the vue arm
* stays a third measurement rather than a third copy — the same role it had
* before, now against the branch that replaced the alias rewrite.
*
* Not covered here: the workspace-manifest leg
* (`node-workspace-packages.ts`), which is a `Map.get` on a package name and
* does not scale with the file set.
*/
const VUE_TSCONFIG = { tsconfigPaths: { aliases: new Map([['@/', 'src/']]), baseUrl: '.' } };
const tsBaseUrlConfig = (baseUrl) => ({
tsconfigs: { scopes: [{ dir: '', baseUrl, paths: [] }] },
nodeWorkspacePackages: null,
});
const vueTsconfig = (baseUrl) => ({
tsconfigs: {
scopes: [
{ dir: '', baseUrl, paths: [{ pattern: '@/*', targets: [joinBase(baseUrl, 'src/*')] }] },
],
},
nodeWorkspacePackages: null,
});
const joinBase = (baseUrl, rest) => (baseUrl === '' ? rest : `${baseUrl}/${rest}`);
/**
* The `deep` arm prepends a UNIFORM `d0/…/d15/` prefix to every path
* (`buildFiles`), and the import spellings do not change. Under the old suffix
* matcher that was the point: the resolver walked path components, so depth was
* the cost. Declared resolution never walks — the config names an exact base —
* so the prefix has to reach the config or the whole arm resolves nothing and
* measures the miss path at depth instead of the hit path at depth.
*/
const tsBaseUrlFor = (pad) =>
pad === 0 ? '' : Array.from({ length: pad }, (_, n) => `d${n}`).join('/');
/** Keyed by LAYOUT name, so there is no `csharp_csproj` row: `buildFiles`
* aliases that arm to `csharp` before this table is read. */
const EXTENSION = {
@ -1541,14 +1607,22 @@ function buildRepo(lang, fileCount, pad = 0, shape = 'unique') {
* `perFileSet` memos keyed on this ARRAY's identity, so reusing one array would
* hide their build from rep 2 onward and `fastest()` reports the minimum.
*/
function newPass(lang, files) {
function newPass(lang, files, pad = 0) {
if (HEADER_EXTENSION[lang] !== undefined) {
const sources = [];
const headers = [];
for (const f of files) (f.endsWith(HEADER_EXTENSION[lang]) ? headers : sources).push(f);
return { allFilePaths: new Set(sources), config: new Set(headers) };
}
if (lang === 'vue') return { allFilePaths: new Set(files), config: VUE_TSCONFIG };
if (lang === 'vue') {
return { allFilePaths: new Set(files), config: vueTsconfig(tsBaseUrlFor(pad)) };
}
// javascript and typescript resolve their `src/mod{d}/file{j}` locals through
// `baseUrl`; without a config every arm would correctly resolve nothing and
// measure an empty branch (#2953 — see `tsBaseUrlConfig`).
if (lang === 'javascript' || lang === 'typescript') {
return { allFilePaths: new Set(files), config: tsBaseUrlConfig(tsBaseUrlFor(pad)) };
}
if (CONTEXT_LANGS.includes(lang)) {
const parsedFiles = buildParsedFiles(lang, files);
return {
@ -1573,8 +1647,8 @@ const contextFor = (pass, parsedImport) =>
/** The timed loop. One `newPass` per pass, so every pass pays exactly one index
* build — see `newPass`. */
function resolveAll(lang, files, imports) {
const pass = newPass(lang, files);
function resolveAll(lang, files, imports, pad = 0) {
const pass = newPass(lang, files, pad);
let sink = 0;
for (const [from, target] of imports) {
const hit = resolveOne(lang, from, target, pass);
@ -1665,7 +1739,9 @@ function resolveOne(lang, from, target, pass) {
{ fromFile: from, allFilePaths, parsedFiles: pass.parsedFiles },
);
}
if (lang === 'javascript') return jsResolveImportTarget(target, from, allFilePaths);
if (lang === 'javascript') {
return jsResolveImportTarget(target, from, allFilePaths, pass.config);
}
if (lang === 'vue') return vueResolveImportTarget(target, from, allFilePaths, pass.config);
// TypeScript, C and C++ go through the registered `ScopeResolver` hook rather
// than an inner resolver, because for all three the thing under test lives IN
@ -1674,7 +1750,7 @@ function resolveOne(lang, from, target, pass) {
// is private to theirs. Calling past it would benchmark a copy of the adapter
// instead of the adapter.
if (lang === 'typescript') {
return typescriptScopeResolver.resolveImportTarget(target, from, allFilePaths, undefined);
return typescriptScopeResolver.resolveImportTarget(target, from, allFilePaths, pass.config);
}
if (lang === 'c') {
return cScopeResolver.resolveImportTarget(target, from, allFilePaths, pass.config);
@ -1708,8 +1784,8 @@ function resolveOne(lang, from, target, pass) {
* Deliberately NOT shared with `resolveAll`, which is the TIMED loop: the memo
* that makes this pass cheap is exactly what would hide the cost that loop
* exists to measure. */
function identityPass(lang, files, imports) {
const pass = newPass(lang, files);
function identityPass(lang, files, imports, pad = 0) {
const pass = newPass(lang, files, pad);
const outcomes = new Set();
const wasNullByKey = new Map();
let resolved = 0;
@ -1747,12 +1823,12 @@ function fastest(values) {
return Math.min(...values);
}
function timeResolution(lang, files, imports, reps) {
for (let w = 0; w < WARMUP; w++) resolveAll(lang, files, imports);
function timeResolution(lang, files, imports, reps, pad = 0) {
for (let w = 0; w < WARMUP; w++) resolveAll(lang, files, imports, pad);
const samples = [];
for (let r = 0; r < reps; r++) {
const t0 = performance.now();
resolveAll(lang, files, imports);
resolveAll(lang, files, imports, pad);
samples.push(performance.now() - t0);
}
return fastest(samples);
@ -1774,10 +1850,10 @@ function timeResolution(lang, files, imports, reps) {
* reads several times high, which would push the expensive languages to
* `REPS_MIN` for the wrong reason.
*/
function probeMs(lang, files, imports) {
for (let w = 0; w < WARMUP; w++) resolveAll(lang, files, imports);
function probeMs(lang, files, imports, pad = 0) {
for (let w = 0; w < WARMUP; w++) resolveAll(lang, files, imports, pad);
const t0 = performance.now();
resolveAll(lang, files, imports);
resolveAll(lang, files, imports, pad);
return performance.now() - t0;
}
@ -1813,8 +1889,8 @@ function probeMs(lang, files, imports) {
* Set, which is part of what they hold; for every language it includes the one
* or two resolve-cache entries the probe leaves behind.
*/
function retainedPassBytes(lang, files, probeTarget) {
const pass = newPass(lang, files);
function retainedPassBytes(lang, files, probeTarget, pad = 0) {
const pass = newPass(lang, files, pad);
// See `HEAP_RETAINED`: nothing built for this language is released until the
// next one starts, so no deferred collection can land between the two samples
// below and cancel part of the delta.
@ -2169,8 +2245,8 @@ for (const lang of LANGS) {
let reps = null;
for (const [name, fileCount, pad, shape] of ARMS) {
const { files, imports } = buildRepo(lang, fileCount, pad, shape);
const { outcomes, resolved } = identityPass(lang, files, imports);
if (reps === null) reps = repsFor(probeMs(lang, files, imports));
const { outcomes, resolved } = identityPass(lang, files, imports, pad);
if (reps === null) reps = repsFor(probeMs(lang, files, imports, pad));
scales[name] = {
files: files.length,
imports: imports.length,
@ -2178,7 +2254,7 @@ for (const lang of LANGS) {
// resolved share would still produce a "valid" fingerprint over far less.
resolved,
distinct_outcomes: outcomes.size,
ms: Number(timeResolution(lang, files, imports, reps).toFixed(3)),
ms: Number(timeResolution(lang, files, imports, reps, pad).toFixed(3)),
fingerprint: fingerprint(outcomes),
};
}

View file

@ -6,11 +6,11 @@
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a -> 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a; scaling 1.058 < 1.5.",
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: provider-owned callable assignment/copy/formal/argument/invoke facts with invocation/constructor-result suppression. Prior 09ecd94911b830f52fa8807560abcbd79f163d02a2072870c1a59297e9a326e1 -> 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a; scaling 1.039 < 1.5.",
"_rebaselined": "#1976: F33 generic composite literal constructor inference adds generic_type captures in composite_literal patterns; fingerprint drift expected.",
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a -> 5d6c59c2f2c0dd937c53bf5d736e0f8376b2899a381e488a33aec23524823efb.",
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a -> 5d6c59c2f2c0dd937c53bf5d736e0f8376b2899a381e488a33aec23524823efb.",
"_rebaselined_2766_go_pointer_receiver_fixture": "#2766: added test/fixtures/lang-resolution/go-pointer-receiver-field-chain/ (2 Go files) as the committed regression fixture for pointer-receiver base resolution. Go fixture_count 100 -> 102. Prior 5d6c59c2f2c0dd937c53bf5d736e0f8376b2899a381e488a33aec23524823efb -> 8cba537ff211fab3bac5fb4456cd1ffba14d6a2db75c40acae28ab8bf29f3d2e. FIXTURE-CORPUS GROWTH, NOT A CAPTURE CHANGE: the accompanying fix is a resolution-time lookup fallback (stripTypePreservingDecoration) and cannot move capture output; go was the ONLY language whose fingerprint drifted, and every other language matched its baseline on the same run.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 8cba537ff211fab3bac5fb4456cd1ffba14d6a2db75c40acae28ab8bf29f3d2e -> 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f.",
"_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 — the two whose fixture corpora contain such receivers. Prior 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f -> c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9.",
"_rebaselined_2766_phantom_callee_read_site": "#2766: Go's `@reference.read` pattern matches EVERY selector_expression, so a member call `h.dep.Work()` minted THREE sites — the call, the genuine `h.dep` field read, and a PHANTOM read on the callee `h.dep.Work`. The phantom resolved through findOwnedMember (which prefers methods over fields) and emitted an ACCESSES edge to the METHOD duplicating the CALLS edge at the same position; visible today on any receiver the text cascade can type (`RunFromValueReceiver -> DoWork`). The emitter now drops a read match whose selector is in FUNCTION position. FEWER capture matches for Go, no other language affected — go was the only fingerprint of 15 that moved. A method VALUE (`f := h.dep.Work`) is not in function position and is untouched. Prior c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9 -> 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 8cba537ff211fab3bac5fb4456cd1ffba14d6a2db75c40acae28ab8bf29f3d2e -> 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f.",
"_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 \u2014 the two whose fixture corpora contain such receivers. Prior 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f -> c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9.",
"_rebaselined_2766_phantom_callee_read_site": "#2766: Go's `@reference.read` pattern matches EVERY selector_expression, so a member call `h.dep.Work()` minted THREE sites \u2014 the call, the genuine `h.dep` field read, and a PHANTOM read on the callee `h.dep.Work`. The phantom resolved through findOwnedMember (which prefers methods over fields) and emitted an ACCESSES edge to the METHOD duplicating the CALLS edge at the same position; visible today on any receiver the text cascade can type (`RunFromValueReceiver -> DoWork`). The emitter now drops a read match whose selector is in FUNCTION position. FEWER capture matches for Go, no other language affected \u2014 go was the only fingerprint of 15 that moved. A method VALUE (`f := h.dep.Work`) is not in function position and is untouched. Prior c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9 -> 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3.",
"_rebaselined_2766_callee_position_marker": "#2766 review fix: a call's callee selector is no longer DROPPED at capture. An earlier commit on this branch dropped it outright, which also deleted the genuine field read on a func-typed struct field (`h.dep.Work()` where `Work func() error`) - callback/hook/mock structs lost their only ACCESSES evidence. The match is now emitted carrying `@reference.callee-position`, and the phantom is suppressed at EMIT by the resolved target's kind instead. Go only: the other 14 languages' fingerprints are byte-identical, which is the check that this is not a cross-language capture change. Prior 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3 -> e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3; scaling 1.001 < 1.5; fixtures 102 (unchanged), capture_groups_fp 2103.",
"_rebaselined_2813_interface_field_dispatch_fixture": "#2813: added test/fixtures/lang-resolution/go-interface-field-dispatch/ (8 Go files) as the committed regression fixture for calls through an interface-typed struct field. Go fixture_count 102 -> 110. FIXTURE-CORPUS GROWTH, NOT A CAPTURE CHANGE: the accompanying fixes are a detection-time method-set change (interface-impls.ts) and a resolution-time fan-out in the shared receiver pass, neither of which emits captures; go/query.ts and go/captures.ts are untouched. Go was the ONLY language whose fingerprint drifted, and every other language matched its baseline on the same run - the same check used for the #2766 fixture growth above. Prior e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3 -> cffee41cadbf350855d99bd5aee7c015b1e8b31d1c343d02f113540abe86c765; scaling 1.074 < 1.5, capture_groups_fp 2303.",
"_rebaselined_2837": "#2837: Go struct/interface captures re-anchored from the type_declaration onto the type_spec (@scope.class/@declaration.struct/@declaration.interface in languages/go/query.ts, @definition.struct/@definition.interface in GO_QUERIES). A grouped `type (...)` block used to yield ONE scope and ONE node for every type in it, so each type after the first lost its field typeBindings and every field-receiver call in the file emitted nothing. Capture COUNT is unchanged; only ranges moved, plus the new go-grouped-type-decl fixture. Prior c27fb803598581fa4eb7ddf5ef6f8369b9e3a150082d11362e7aa3ec8faaa832 -> e386598526e502d131e52a17d219635b3a4196d94f1ebdd25922a2582c985d18; scaling 1.054 < 1.5.",
@ -18,11 +18,11 @@
},
"cobol": {
"fingerprint": "c8c00b56a7da24e04080eb885714fbbf45e3903324f0cf9df0754f5b5a92e3aa",
"_rebaselined_2813_exact_method_sets": "#2813: Go embedded fields now emit `@reference.embedded-pointer` when spelled `*T` rather than `T`. A CAPTURE-EMISSION CHANGE, not fixture growth: fixture_count is unchanged at 110 and capture_groups_fp moves 2303 -> 2339 (+36), which is the new marker plus the WrongSigRepo/Recount rows added to two existing fixture files. The marker is required for exactness — Go gives `struct{ Base }` and `struct{ *Base }` different method sets, so structural interface satisfaction cannot be correct without knowing which was written (go.dev/ref/spec#Struct_types). Go was the ONLY language of 15 whose fingerprint moved, which is the check that this is a Go capture change and not a cross-language regression. Accompanied by SCHEMA_BUMP 39 -> 43 (skipping 40/41/42, taken by origin/main during review) so a warm cache cannot replay the pre-marker capture set. Prior cffee41cadbf350855d99bd5aee7c015b1e8b31d1c343d02f113540abe86c765 -> c27fb803598581fa4eb7ddf5ef6f8369b9e3a150082d11362e7aa3ec8faaa832; scaling 0.987 < 1.5.",
"_rebaselined_2813_exact_method_sets": "#2813: Go embedded fields now emit `@reference.embedded-pointer` when spelled `*T` rather than `T`. A CAPTURE-EMISSION CHANGE, not fixture growth: fixture_count is unchanged at 110 and capture_groups_fp moves 2303 -> 2339 (+36), which is the new marker plus the WrongSigRepo/Recount rows added to two existing fixture files. The marker is required for exactness \u2014 Go gives `struct{ Base }` and `struct{ *Base }` different method sets, so structural interface satisfaction cannot be correct without knowing which was written (go.dev/ref/spec#Struct_types). Go was the ONLY language of 15 whose fingerprint moved, which is the check that this is a Go capture change and not a cross-language regression. Accompanied by SCHEMA_BUMP 39 -> 43 (skipping 40/41/42, taken by origin/main during review) so a warm cache cannot replay the pre-marker capture set. Prior cffee41cadbf350855d99bd5aee7c015b1e8b31d1c343d02f113540abe86c765 -> c27fb803598581fa4eb7ddf5ef6f8369b9e3a150082d11362e7aa3ec8faaa832; scaling 0.987 < 1.5.",
"scaling_budget": 1.5,
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: COBOL procedure-pointer callable flow facts; multi-topic extraction now consumes each grouped scope/declaration match once instead of requiring a duplicate declaration-only match. Prior 68ee0e95eb9f86f2d92ca35f730f4c2d4d83abc1b5241ae767ff3437780ec8d1 -> d45bb091b0893d0de4fae2486b31ba21719c9377bf35a0908fd3a36fa1c3bf4e; scaling 0.853 < 1.5.",
"_note": "Updated for F17-F23 fixes (P2: TIMES guard, ADD GIVING, SQL AS alias). See PR #1959.",
"_rebaselined_2793_declaratives": "PR #2793: corpus-only re-baseline. `cobol-declaratives` was added to test/fixtures/lang-resolution to reproduce the `Namespace→Record` analyze abort (DECLARATIVES / USE AFTER STANDARD ERROR ON <file>), and this bench globs `lang-resolution/cobol-*`, so the corpus grew 14 -> 15 files. Verified capture-neutral: with that one fixture moved aside the fingerprint is byte-identical to the prior d45bb091b0893d0de4fae2486b31ba21719c9377bf35a0908fd3a36fa1c3bf4e. No COBOL capture code changed in that PR. Scaling 0.677 < 1.5."
"_rebaselined_2793_declaratives": "PR #2793: corpus-only re-baseline. `cobol-declaratives` was added to test/fixtures/lang-resolution to reproduce the `Namespace\u2192Record` analyze abort (DECLARATIVES / USE AFTER STANDARD ERROR ON <file>), and this bench globs `lang-resolution/cobol-*`, so the corpus grew 14 -> 15 files. Verified capture-neutral: with that one fixture moved aside the fingerprint is byte-identical to the prior d45bb091b0893d0de4fae2486b31ba21719c9377bf35a0908fd3a36fa1c3bf4e. No COBOL capture code changed in that PR. Scaling 0.677 < 1.5."
},
"c": {
"fingerprint": "3418cded9f7072152f68992f0a426f43ae7d9d553579a47075fc0cab185848a5",
@ -30,15 +30,15 @@
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 57fee292147ae6d2db7062da1e07d17122cf355207c8967fa85fd2ec9ca398a4 -> 3418cded9f7072152f68992f0a426f43ae7d9d553579a47075fc0cab185848a5; scaling 1.073 < 1.5.",
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: C function-pointer signatures plus direct-callee argument metadata and invocation-result suppression. Prior 75bcdbbf006bf9bd263c0f5857461b118f39b164e9f821cb0651ad0ec46ef6ae -> 57fee292147ae6d2db7062da1e07d17122cf355207c8967fa85fd2ec9ca398a4; scaling 1.035 < 1.5.",
"_rebaselined_callable_flow": "Callable-value-flow facts for C function pointers, copies, pointer-to-pointer cells, arguments, and indirect invokes. Prior 12a196b2d6249c8d86a931b12ecebc2a0cdf8d6f47683acdd0d8e9d8bc7657f5 -> 75bcdbbf006bf9bd263c0f5857461b118f39b164e9f821cb0651ad0ec46ef6ae; measured scaling ratio 0.980 < 1.5.",
"_added": "#1956: c added to the scope-capture bench (was UNBENCHED). C has no inheritance — flat scale source. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in c/captures.ts (threaded c.node, byte-identical over c-* fixtures); scaling 3.475 -> 0.96.",
"_note": "#1983: + c-static-linkage-worker fixture (caller.c/lib.c/lib.h/local.c — worker-path static-linkage side-channel test). Pure fixture-corpus drift: no c/captures.ts or query change branch-vs-main, existing fixtures' captures byte-identical (c-captures.test.ts 45/45), scaling stays linear (~0.97). The baseline was missed when the fixture landed; regenerated here. fingerprint 0de009b->39f3a83.",
"_added": "#1956: c added to the scope-capture bench (was UNBENCHED). C has no inheritance \u2014 flat scale source. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in c/captures.ts (threaded c.node, byte-identical over c-* fixtures); scaling 3.475 -> 0.96.",
"_note": "#1983: + c-static-linkage-worker fixture (caller.c/lib.c/lib.h/local.c \u2014 worker-path static-linkage side-channel test). Pure fixture-corpus drift: no c/captures.ts or query change branch-vs-main, existing fixtures' captures byte-identical (c-captures.test.ts 45/45), scaling stays linear (~0.97). The baseline was missed when the fixture landed; regenerated here. fingerprint 0de009b->39f3a83.",
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression)."
},
"cpp": {
"fingerprint": "bf3587674267be1759e7c45abef143c3b81fe8629cfd17da5f8af40e83cc39ec",
"scaling_budget": 1.5,
"_rebaselined_2833_qualified_member_fields": "#2833 follow-up: the six per-qualifier-depth `field_declaration` type-binding rules for a QUALIFIED generic member are replaced by three depth-agnostic ones that match the outer `qualified_identifier` itself, with the qualifier reduced to its top-level tail in `interpret.ts` (`cppQualifiedTail`). This is a CAPTURE-LOGIC change and it moves the fingerprint in two places at once. (1) A qualified NON-generic member (`ns::Address addr;`, `std::string name;`) was captured by nothing at all and now binds — that is the whole +24 on the fixture corpus, every one of them a `std::string` member. (2) Qualifier depth is no longer enumerated, so `a::b::c::Repo<User>` (depth 3+) is captured where the old rules stopped at 2. Capture-name histogram, cpp-* corpus (278 files): `@type-binding.field` 8 -> 32, `@type-binding.name` and `@type-binding.type` 401 -> 425; synthetic DAO-20: `@type-binding.field` 40 -> 60, `@type-binding.name` and `@type-binding.type` 61 -> 81 (= 20 entities x the one `std::string name;` member the DAO unit already declared). NO OTHER TAG MOVED in either set — not one `@declaration.*`, `@scope.*` or `@reference.*` count — which is the property that says three rules replaced six without widening what a field_declaration matches. Measured over the 13 cpp-* fixture repos whose sources gained a binding, the distinct CALLS edge set is byte-identical before and after (32 edges): a reduced tail that names no workspace class binds nothing. Prior bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a -> db1156d81b3e3341faf5e938a4a34417f4fd246588b6150b4686481823262529; scaling 1.04 < 1.5.",
"_rebaselined_2833_generic_member_fields": "#2833 review follow-up: the cpp DAO generator's unit gains two GENERIC member fields — `Repo<Entity_n> repo;` (bare template_type) and `std::vector<Entity_n> items;` (qualified_identifier wrapping a template_type) — plus the header declaring `template <typename T> class Repo`. CORPUS CHANGE, NOT A CAPTURE-LOGIC CHANGE: no extractor edit accompanies it. It exists because the corpus had ZERO template-typed member fields and, across 279 cpp-* fixtures, not one qualified generic member either, so BOTH rounds of new `field_declaration` type-binding rules landed with a byte-identical cpp fingerprint — the gate was structurally blind to the exact thing being changed. Measured under the new corpus, the three states now differ: pre-#2833 query 0e7cbda71360b7ff35dd76091c77f288d6af6a5cfa9185ad85a372aae8c85191 (4521 groups) -> the three template_type field rules de07d8b5300ed867b460918e16b4d80259c7eb6efc1034d32bebe9ff7cab126d (4541) -> the six qualified rules bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a (4561); under the OLD corpus all three were 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc. Capture-name histogram over the synthetic DAO-20: `@type-binding.field` 0 -> 40, `@declaration.field` 40 -> 80, `@type-binding.type`/`@type-binding.name` 20 -> 61, `@declaration.name` 104 -> 147 — 40 = 20 entities x 2 fields, with the residual +1/+2/+3 attributable to the one-off header declaration; every `@reference.*` count is unchanged. Prior 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc -> bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a; scaling 1.058 < 1.5. `c` is unaffected (3418cded..., unchanged).",
"_rebaselined_2833_qualified_member_fields": "#2833 follow-up: the six per-qualifier-depth `field_declaration` type-binding rules for a QUALIFIED generic member are replaced by three depth-agnostic ones that match the outer `qualified_identifier` itself, with the qualifier reduced to its top-level tail in `interpret.ts` (`cppQualifiedTail`). This is a CAPTURE-LOGIC change and it moves the fingerprint in two places at once. (1) A qualified NON-generic member (`ns::Address addr;`, `std::string name;`) was captured by nothing at all and now binds \u2014 that is the whole +24 on the fixture corpus, every one of them a `std::string` member. (2) Qualifier depth is no longer enumerated, so `a::b::c::Repo<User>` (depth 3+) is captured where the old rules stopped at 2. Capture-name histogram, cpp-* corpus (278 files): `@type-binding.field` 8 -> 32, `@type-binding.name` and `@type-binding.type` 401 -> 425; synthetic DAO-20: `@type-binding.field` 40 -> 60, `@type-binding.name` and `@type-binding.type` 61 -> 81 (= 20 entities x the one `std::string name;` member the DAO unit already declared). NO OTHER TAG MOVED in either set \u2014 not one `@declaration.*`, `@scope.*` or `@reference.*` count \u2014 which is the property that says three rules replaced six without widening what a field_declaration matches. Measured over the 13 cpp-* fixture repos whose sources gained a binding, the distinct CALLS edge set is byte-identical before and after (32 edges): a reduced tail that names no workspace class binds nothing. Prior bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a -> db1156d81b3e3341faf5e938a4a34417f4fd246588b6150b4686481823262529; scaling 1.04 < 1.5.",
"_rebaselined_2833_generic_member_fields": "#2833 review follow-up: the cpp DAO generator's unit gains two GENERIC member fields \u2014 `Repo<Entity_n> repo;` (bare template_type) and `std::vector<Entity_n> items;` (qualified_identifier wrapping a template_type) \u2014 plus the header declaring `template <typename T> class Repo`. CORPUS CHANGE, NOT A CAPTURE-LOGIC CHANGE: no extractor edit accompanies it. It exists because the corpus had ZERO template-typed member fields and, across 279 cpp-* fixtures, not one qualified generic member either, so BOTH rounds of new `field_declaration` type-binding rules landed with a byte-identical cpp fingerprint \u2014 the gate was structurally blind to the exact thing being changed. Measured under the new corpus, the three states now differ: pre-#2833 query 0e7cbda71360b7ff35dd76091c77f288d6af6a5cfa9185ad85a372aae8c85191 (4521 groups) -> the three template_type field rules de07d8b5300ed867b460918e16b4d80259c7eb6efc1034d32bebe9ff7cab126d (4541) -> the six qualified rules bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a (4561); under the OLD corpus all three were 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc. Capture-name histogram over the synthetic DAO-20: `@type-binding.field` 0 -> 40, `@declaration.field` 40 -> 80, `@type-binding.type`/`@type-binding.name` 20 -> 61, `@declaration.name` 104 -> 147 \u2014 40 = 20 entities x 2 fields, with the residual +1/+2/+3 attributable to the one-off header declaration; every `@reference.*` count is unchanged. Prior 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc -> bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a; scaling 1.058 < 1.5. `c` is unaffected (3418cded..., unchanged).",
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature/cv metadata. Prior dde874d2c30bda9f634f9799281a66de800cad9f76cf65e7c31839e2ae9da9ff -> 57860dd2a8d4b06c6d2dd0d854c08b781faee3da8f2b6c42ba0c68a9f70e5ccb; scaling 1.090 < 1.5.",
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: C++ overload-aware function/reference/member-pointer flow facts with invocation/constructor-result suppression. Prior 3a503a1513e7eede3f7a223dcce0896c06d15bdfa920445224c9025848c0d710 -> dde874d2c30bda9f634f9799281a66de800cad9f76cf65e7c31839e2ae9da9ff; scaling 1.034 < 1.5.",
"_rebaselined_callable_flow": "Callable-value-flow facts for C++ function pointers/references, reference aliases, contextual arity, arguments, and member-pointer syntax. Prior 6ab657c8f9bfe988a3759098c2cffdcc0443def75ff263f1282b82c21d96e931 -> 3a503a1513e7eede3f7a223dcce0896c06d15bdfa920445224c9025848c0d710; measured scaling ratio 1.069 < 1.5.",
@ -46,11 +46,11 @@
"_note_1899_followup": "#1899 follow-up: braced-init metadata now carries element count, intentionally changing C++ capture output; CI benchmark scaling remains linear (1.129 < 1.5).",
"_added": "#1956: cpp added to the scope-capture bench (was UNBENCHED). Heritage-bearing scale source (: public Base, public Mixin) drives emitCppInheritanceCaptures at scale. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in cpp/captures.ts (~12 sites, threaded c.node, byte-identical over 263 cpp-* fixtures); scaling 2.30 -> 1.12.",
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression). #2094: deleted C++ declarations retain @declaration.is-deleted metadata; deleted operator and pointer-return shapes plus the expanded deleted-overload fixture are included. Intended capture drift; scaling remains linear (1.139 < 1.5).",
"_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift — no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures — pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture — pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae. #2077 review follow-up: cpp-member-lattice adds cross-file, qualified-base, nested-template, inherited-using, this-receiver, and non-virtual-override regressions; fixture_count 274->275. Capture scaling remains linear (1.134 < 1.5). #1899: braced-init call arguments emit a conservative parameter-type capture; fixture_count 277, scaling remains linear (1.141 < 1.5).",
"_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift \u2014 no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures \u2014 pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture \u2014 pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae. #2077 review follow-up: cpp-member-lattice adds cross-file, qualified-base, nested-template, inherited-using, this-receiver, and non-virtual-override regressions; fixture_count 274->275. Capture scaling remains linear (1.134 < 1.5). #1899: braced-init call arguments emit a conservative parameter-type capture; fixture_count 277, scaling remains linear (1.141 < 1.5).",
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: outermost-chain passing modes; ->* ERROR-recovery role order; member-store visibility. Prior 57860dd2a8d4b06c6d2dd0d854c08b781faee3da8f2b6c42ba0c68a9f70e5ccb -> f29bc3f7b1622954d6f6b7647bc9cf6c7a2629ffcc0fe00ac7918e4925876b65; scaling ratio re-verified within budget.",
"_rebaselined_2522_prototype_value_cells": "Plain function/method prototypes no longer index as callable value cells (only pointer/parenthesized variable declarators do) — removes the spurious indirect-invoke facts that leaked phantom CALLS past two-phase suppression. Prior f29bc3f7b1622954d6f6b7647bc9cf6c7a2629ffcc0fe00ac7918e4925876b65 -> a70625bb0a9ef74e760d9d79cc5557485d0f0d3fb935e8a22a0c9556c65b5bb1; scaling re-verified within budget.",
"_rebaselined_2522_prototype_value_cells": "Plain function/method prototypes no longer index as callable value cells (only pointer/parenthesized variable declarators do) \u2014 removes the spurious indirect-invoke facts that leaked phantom CALLS past two-phase suppression. Prior f29bc3f7b1622954d6f6b7647bc9cf6c7a2629ffcc0fe00ac7918e4925876b65 -> a70625bb0a9ef74e760d9d79cc5557485d0f0d3fb935e8a22a0c9556c65b5bb1; scaling re-verified within budget.",
"_rebaselined_receiver_chain_2747": "#2747: additionally adds the `cpp-receiver-chain-arrow` fixture, the behavioural proof for a `->` BASE receiver (`svc->getUser()->save()`) that the rollout fixed and that `cpp-chain-call/` could never catch because it uses the value `.` form. Prior a70625bb0a9ef74e760d9d79cc5557485d0f0d3fb935e8a22a0c9556c65b5bb1 -> 7e27aea46f3e17f33c41babbe0ddd982d1ab5920f143864763e0a1c6aef882a5.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 7e27aea46f3e17f33c41babbe0ddd982d1ab5920f143864763e0a1c6aef882a5 -> 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 7e27aea46f3e17f33c41babbe0ddd982d1ab5920f143864763e0a1c6aef882a5 -> 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc.",
"capture_groups_small": 5021,
"capture_groups_large": 16021,
"capture_groups_fp": 4605,
@ -64,8 +64,8 @@
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: C# method-group/delegate callable flow facts with invocation-result suppression. Prior 2bb5bc8c19cb8eb08c9590545ad8a1968a7152951f7e12746e2d7901d542fed9 -> f31544530924748f9aa37d11cec570bc10c3ddf9d9b237e6df7a17623fd2bb3a; scaling 1.115 < 1.5.",
"_note": "#2046: F35 qualified-constructor captures now emit @reference.qualified-name + a simple-name @reference.name on `new Ns.Foo()`/`new A.B.Foo()`; namespace_declaration/file_scoped_namespace_declaration now emit @declaration.namespace name captures (feeding the non-destructive namespacePrefix sidecar for `new B.Foo()` same-tail disambiguation). + csharp-interface-only-base and csharp-namespace-qualified-ctor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.11).",
"_rebaselined_2563_instance_ownership": "#2563: csharp-using-static adds same-file ownership, local-function, overload, partial-class, and cross-namespace same-name coverage. Prior 75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1 -> e05dc27456bde8175948586c9e7689033a378fa40e9ca4ce78cce41fbea0f2f8; scaling 1.058 < 1.5.",
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 05a85bae70cf9c94f42459c843cfc36e3e81c872e5dcc7d77bc42fbc390f4bfe -> 8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855 -> 476d98a7cc659951c315d63319c8077bbcf0e5f3ec12d32ed773992a1f3a2adc.",
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 05a85bae70cf9c94f42459c843cfc36e3e81c872e5dcc7d77bc42fbc390f4bfe -> 8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855 -> 476d98a7cc659951c315d63319c8077bbcf0e5f3ec12d32ed773992a1f3a2adc.",
"capture_groups_small": 4259,
"capture_groups_large": 13609,
"capture_groups_fp": 2657,
@ -74,18 +74,18 @@
"rust": {
"fingerprint": "e61653008ff2de506cfd47f905fa9eb22d82fbbfe94d2a1d8190c358211b57b7",
"scaling_budget": 1.5,
"_rebaselined_generic_instantiation_2912": "#2912: RUST_SCOPE_QUERY tags trait-impl heritage with the instantiation the impl was written with (`impl Validator<String> for V`), so interface dispatch can prune implementors of an instantiation the receiver cannot hold. Additive capture text on existing impl matches — the same matches are minted, carrying one more field — so this is digest drift, not a capture-set change: capture_groups_fp (3556) and fixture_count (202) are both unchanged, which is the check that no match appeared or vanished. Prior 116a971fee0004f340477aff69fa110a1d92bd8ba882d7c926483c6b1e8ca2b9 -> e61653008ff2de506cfd47f905fa9eb22d82fbbfe94d2a1d8190c358211b57b7; scaling 1.018 < 1.5. Only rust and dart move; the other 13 languages are byte-identical.",
"_rebaselined_mod_node_identity_2745_review": "#2745 review: added rust-2742-mod-members, rust-2742-nested-mods and rust-2742-type-vs-module under lang-resolution for the container/owner-edge fix, nested inline modules, and the imported-type-vs-module precedence. emitRustScopeCaptures is unchanged — verified by removing ONLY those three fixture dirs and re-running, which reproduces the prior fingerprint exactly, so the shift is purely corpus growth (fixture_count 196 -> 202, capture_groups_fp 3432 -> 3556). Prior 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5 -> 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300; scaling 1.022 local / 1.057 CI < 1.5. NOTE for the next fixture author: a new rust-* fixture drifts BOTH this bench baseline and the rust-captures-golden snapshot. Updating only the golden is how this reached CI red.",
"_rebaselined_generic_instantiation_2912": "#2912: RUST_SCOPE_QUERY tags trait-impl heritage with the instantiation the impl was written with (`impl Validator<String> for V`), so interface dispatch can prune implementors of an instantiation the receiver cannot hold. Additive capture text on existing impl matches \u2014 the same matches are minted, carrying one more field \u2014 so this is digest drift, not a capture-set change: capture_groups_fp (3556) and fixture_count (202) are both unchanged, which is the check that no match appeared or vanished. Prior 116a971fee0004f340477aff69fa110a1d92bd8ba882d7c926483c6b1e8ca2b9 -> e61653008ff2de506cfd47f905fa9eb22d82fbbfe94d2a1d8190c358211b57b7; scaling 1.018 < 1.5. Only rust and dart move; the other 13 languages are byte-identical.",
"_rebaselined_mod_node_identity_2745_review": "#2745 review: added rust-2742-mod-members, rust-2742-nested-mods and rust-2742-type-vs-module under lang-resolution for the container/owner-edge fix, nested inline modules, and the imported-type-vs-module precedence. emitRustScopeCaptures is unchanged \u2014 verified by removing ONLY those three fixture dirs and re-running, which reproduces the prior fingerprint exactly, so the shift is purely corpus growth (fixture_count 196 -> 202, capture_groups_fp 3432 -> 3556). Prior 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5 -> 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300; scaling 1.022 local / 1.057 CI < 1.5. NOTE for the next fixture author: a new rust-* fixture drifts BOTH this bench baseline and the rust-captures-golden snapshot. Updating only the golden is how this reached CI red.",
"_rebaselined_dyn_trait_object_2604": "#2604: RUST_SCOPE_QUERY now captures function_signature_item (abstract trait methods, no body) as a scope + declaration, so a &dyn Trait receiver can dispatch a CALLS edge to the trait's own method. Additive capture shift across every bench fixture with a required trait method. Prior df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29 -> f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846; scaling 1.033 < 1.5.",
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 65e5bca66bb1ca117949409e8fb5c80ee69d6f1b5318908eaaecf08da0482e5c -> df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29; scaling 1.065 < 1.5.",
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Rust fn-value callable flow facts with invocation/constructor-result suppression. Prior ac610bbe97666bf285923479dd7b43a2fe4c5354aae8df1bcbafdc04fb220f82 -> 65e5bca66bb1ca117949409e8fb5c80ee69d6f1b5318908eaaecf08da0482e5c; scaling 1.024 < 1.5.",
"_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04). #1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls) — legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target; rust scope-extractor captures byte-identical. | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
"_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED — @declaration.macro/@reference.macro + MacroRegistry → USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures — pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f.",
"_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04). #1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls) \u2014 legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target; rust scope-extractor captures byte-identical. | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
"_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED \u2014 @declaration.macro/@reference.macro + MacroRegistry \u2192 USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures \u2014 pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f.",
"_rebaselined_import_disambiguation_2514": "#2514: added rust-import-* and rust-dup-* fixtures under lang-resolution for the range-binding ambiguity latch + import-disambiguated resolution (for-loops / struct destructuring across explicit/aliased/glob use imports). emitRustScopeCaptures is unchanged; the corpus fingerprint shifts purely because the fixture set grew (130 -> 174). Prior f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846 -> 655aed01cf1b6b84fa0c64d48dfb2526ecb67f47d90f0a91edabacd269a212db; scaling 1.06 < 1.5.",
"_rebaselined_self_type_binding_2714": "#2714: a Rust `Self` type binding now records the enclosing impl's type instead of the literal 'Self'. `let fresh = Self { .. }` inside `impl User` binds `fresh: User`; recorded verbatim it bound `fresh: Self`, which resolves to nothing. The type-env channel already substituted this (type-extractors/rust.ts findEnclosingImplType); the scope-resolution channel did not, so the two disagreed. The gap was invisible while lookupCore Step 1 still walked the lexical chain for NAMED receivers — the impl scope binds the method by name, so fresh.validate() resolved by accident — and became a lost CALLS edge when #2714 stopped that walk. Only the rust fingerprint moves; the other 14 languages are byte-identical.",
"_rebaselined_self_type_binding_2714": "#2714: a Rust `Self` type binding now records the enclosing impl's type instead of the literal 'Self'. `let fresh = Self { .. }` inside `impl User` binds `fresh: User`; recorded verbatim it bound `fresh: Self`, which resolves to nothing. The type-env channel already substituted this (type-extractors/rust.ts findEnclosingImplType); the scope-resolution channel did not, so the two disagreed. The gap was invisible while lookupCore Step 1 still walked the lexical chain for NAMED receivers \u2014 the impl scope binds the method by name, so fresh.validate() resolved by accident \u2014 and became a lost CALLS edge when #2714 stopped that walk. Only the rust fingerprint moves; the other 14 languages are byte-identical.",
"_rebaselined_module_tree_2730": "#2730 + #2741 review: RUST_SCOPE_QUERY captures mod_item as @declaration.namespace (a Rust module is an item, mirroring the C++ namespace_definition capture) and tags scoped call sites with @reference.qualified-name so the written path survives to resolution. Both are additive captures: every bench fixture holding a mod block or a Foo::bar() call gains groups, and the corpus also grew by the rust-2730-* fixtures added for the fix and its review (workspace-crates, type-qualified, gaps, samename-wrapper, crate-layout). Prior 7f1240b38457468f06b7931e0c2c578f218f922774d0dc7e2ee6ef3b08d4d689 -> 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5; scaling 1.061 < 1.5; fixture_count 196. Only the rust fingerprint moves; the other 14 languages are byte-identical. The earlier revision of this note cited 655aed01... as the prior value, which was two rebaselines stale (it predates #2604 and #2714); the CI gate compares live fingerprints, not this prose, so nothing caught it.",
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300 -> 83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c -> 6174889b8c98e0af430fa54c268dc781989ca9a8172d690eebae37a95f77e809.",
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300 -> 83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c -> 6174889b8c98e0af430fa54c268dc781989ca9a8172d690eebae37a95f77e809.",
"capture_groups_small": 5507,
"capture_groups_large": 17607,
"capture_groups_fp": 3556,
@ -97,9 +97,9 @@
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior df7b1565f9115d66b1ae32e4a408d651afb2521b14e5ca615f3be426c29af618 -> 4a688fa5a7016546f7f3c6d44de023608ae80c5b0e3670c16f6e61b3632608fd; scaling 1.078 < 1.5.",
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: PHP first-class callable and variable-invocation flow facts with invocation-result suppression. Prior 31c9e3f3cb7094a2bf9021cf9db859036e002f8b44605cd993b470fc600e97cb -> df7b1565f9115d66b1ae32e4a408d651afb2521b14e5ca615f3be426c29af618; scaling 1.074 < 1.5.",
"_rebaselined": "#1956: heritage-bearing scale source (class extends Base + use trait); both forms gated at scale; linear (~1.04). | #2481/#2482: PHP imports carry a symbol-kind capture so function/constant imports resolve by declaring file; capture shape changes, scaling remains linear (~1.04).",
"_note": "PR #1931: F53 import multi-clause, F54 enum_case, F55 anonymous_class — fixture count 138→140, fingerprint drift expected.",
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 4a688fa5a7016546f7f3c6d44de023608ae80c5b0e3670c16f6e61b3632608fd -> 3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28 -> b213a872342da2d866b04681dede988770e4d3dfdc0d6e9f62212ec5b59cdc2c."
"_note": "PR #1931: F53 import multi-clause, F54 enum_case, F55 anonymous_class \u2014 fixture count 138\u2192140, fingerprint drift expected.",
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 4a688fa5a7016546f7f3c6d44de023608ae80c5b0e3670c16f6e61b3632608fd -> 3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28 -> b213a872342da2d866b04681dede988770e4d3dfdc0d6e9f62212ec5b59cdc2c."
},
"ruby": {
"fingerprint": "1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57",
@ -107,10 +107,10 @@
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior cff273ae6cb7232c977d9241581834a2a2fa8bcf6369f7bd8f2471cd4419a6ef -> bf50ec6a53c8c91680dc6feac63a8956e78b1059249232dc25a0cfed25f31236; scaling 1.103 < 1.5.",
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Ruby Method/Proc callable flow facts with invocation/constructor-result suppression. Prior b5ea93bb3d0469c3821a8c70f5d5991c6f326e41097c119ad691154301dcc753 -> cff273ae6cb7232c977d9241581834a2a2fa8bcf6369f7bd8f2471cd4419a6ef; scaling 1.086 < 1.5.",
"_rebaselined": "#1956 synth-widening: + ruby-qualified-base fixture; synth now reduces a scope_resolution superclass (class C < Mod::Super) to its trailing constant (matching the #1940 legacy leg), at parity. Linear (~1.03). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
"_note": "F62: + scope_resolution class/module declaration captures — fixture count 78→81, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) — pure fixture-corpus drift, scope-extractor captures unchanged; 81→82. #1991: + ruby-nested-mixin-tail-collision fixture (85→86). Recomputed on the #942 merge (fixture-comment rewording shifts capture byte-positions, capture LOGIC unchanged): bf6b13a -> b5ea93bb.",
"_note": "F62: + scope_resolution class/module declaration captures \u2014 fixture count 78\u219281, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) \u2014 pure fixture-corpus drift, scope-extractor captures unchanged; 81\u219282. #1991: + ruby-nested-mixin-tail-collision fixture (85\u219286). Recomputed on the #942 merge (fixture-comment rewording shifts capture byte-positions, capture LOGIC unchanged): bf6b13a -> b5ea93bb.",
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: bare identifiers are calls, not callable references (bareNamesAreCalls). Prior bf50ec6a53c8c91680dc6feac63a8956e78b1059249232dc25a0cfed25f31236 -> 070e4e11502442998ddf4048c2981cf1b2b735a87362ff854c5d14d71f98f4e2; scaling ratio re-verified within budget.",
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior fea3edf82f521995147874b7f6c5f9e2eb88efdebf6365668f3260e913f0b558 -> fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83 -> 1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57."
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior fea3edf82f521995147874b7f6c5f9e2eb88efdebf6365668f3260e913f0b558 -> fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83 -> 1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57."
},
"swift": {
"fingerprint": "adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9",
@ -119,14 +119,14 @@
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Swift function-value callable flow facts with invocation-result suppression. Prior 180ac68e780bdf6f9089d53f51cbb9a66aed3e7774631cc3fcbaae5020213998 -> 5f923c6604d825d12b249f31c155b0f4d13a8379d532e5dde64a0f9b15cf4725; scaling 1.043 < 1.5.",
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression).",
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: assignment target:/result: fields join the shared fallback. Prior 7687ee2466e16020a12440a03fbda53e63aa05f94b4481f6133c09867a0d560d -> 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248; scaling ratio re-verified within budget.",
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248 -> a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b -> 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7.",
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248 -> a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b -> 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7.",
"_rebaselined_inferred_field_receiver_2807": "#2807: optional property annotations (`var a: Outer?`) now emit a type binding. The prior pattern required the `user_type` to be a DIRECT child of the annotation, so an `optional_type` wrapper meant an optional field was never typed at all and its receiver could not resolve. ADDS @type-binding.annotation captures on the optional form only; no capture is removed. Prior 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7 -> adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9; scaling 1.023 < 1.5."
},
"dart": {
"fingerprint": "3a8ddabbeb1cba47a4757451d4f79d726ca230fd15e860772b11526fbb1c6687",
"scaling_budget": 1.5,
"_rebaselined_generic_instantiation_2912": "#2912: the Dart heritage marker carries a fourth field — the type arguments the clause was written with (`implements Validator<String>`) — so interface dispatch can prune implementors of a mismatched instantiation. Additive marker text on existing heritage matches rather than a new match, so this is digest drift only; a marker from a pre-#2912 cache simply has no fourth field and reads as unknown. Prior ba93c90dcd341259e8e088816bc8c76ad27882419f665e35c056dc22fa54cf73 -> 3a8ddabbeb1cba47a4757451d4f79d726ca230fd15e860772b11526fbb1c6687; scaling 1.027 < 1.5.",
"_rebaselined_generic_instantiation_2912": "#2912: the Dart heritage marker carries a fourth field \u2014 the type arguments the clause was written with (`implements Validator<String>`) \u2014 so interface dispatch can prune implementors of a mismatched instantiation. Additive marker text on existing heritage matches rather than a new match, so this is digest drift only; a marker from a pre-#2912 cache simply has no fourth field and reads as unknown. Prior ba93c90dcd341259e8e088816bc8c76ad27882419f665e35c056dc22fa54cf73 -> 3a8ddabbeb1cba47a4757451d4f79d726ca230fd15e860772b11526fbb1c6687; scaling 1.027 < 1.5.",
"_rebaselined_2538": "#2538: Dart extension type headers are preprocessed into normal extension declarations before scope capture, so extension type symbols and their methods are now emitted. Intentional Dart-only capture fingerprint drift; CI measured scaling 1.042 < 1.5.",
"_rebaselined_2538_implements": "#2538 tri-review follow-up: Dart extension type implements clauses now emit heritage markers and fixture coverage asserts IMPLEMENTS edges, including multi-arg generic interfaces. Prior committed baseline 66a46d5ff09f3d11b2771db0f48596fe7057e95c5bc8f56241fdb911137298c3 -> ba93c90dcd341259e8e088816bc8c76ad27882419f665e35c056dc22fa54cf73; scaling 0.945 < 1.5.",
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 29ce2bfe70b246b1c9d5e99c0ec11e850c22e9672737592207242b7f4cc824b8 -> 66a46d5ff09f3d11b2771db0f48596fe7057e95c5bc8f56241fdb911137298c3; scaling 1.054 < 1.5.",
@ -142,7 +142,7 @@
"_rebaselined_2900_record_heritage": "#2900 review follow-up: the Java scale unit now includes a record implementing Marker, so the record-declaration @reference.inherits path is fingerprinted and exercised at scale. Prior b29e263524f55151dcb7cfc4c929d3d1d7bb360355cee4e832158f927857f663 -> 36d689c58526c4482fbd701d1d9ca156623a3970734ead145717858712271ab5; scaling 1.042 < 1.5.",
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata; same-name lexical regions use an O(ancestor-depth) ID-set lookup. Prior d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a -> 004a3592998dca1193bd1429a8284513725de7764f2a3eceedaaa984cfd763b4; scaling 0.992 < 1.5.",
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Java method-reference/SAM callable flow facts with invocation-result suppression. Prior 062d754764aaa8a6772fb90875c710502a63e3e7a300e633942381ed914faada -> d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a; scaling 1.074 < 1.5.",
"_rebaselined": "#2357 (supersedes #2353): + java-cast-receiver, java-this-field-chain, java-this-dispatch fixtures (cast-wrapped receivers, this.field chains incl. initializer contexts, bare-this dispatch pinning). Drift is purely fixture-additive: with the three new dirs parked, the fingerprint reproduces the prior baseline byte-identically — no emit/capture change. #1956 synth-widening: + java-iface-extends fixture; synthesizeJavaInheritanceReferences now ALSO walks interface_declaration extends_interfaces (interface IA extends IB, IC<T>), matching the #1940 legacy leg. (Earlier U2+review: java-qualified-base fixture covers 2- AND 3-segment qualified bases guarding the legacy end-anchor; synth tail-resolves scoped bases.) Linear (~1.03). (Earliest: java added to bench, exposed+fixed the O(n^2) findNodeAtRange root-walk; 3.09 -> ~0.99.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
"_rebaselined": "#2357 (supersedes #2353): + java-cast-receiver, java-this-field-chain, java-this-dispatch fixtures (cast-wrapped receivers, this.field chains incl. initializer contexts, bare-this dispatch pinning). Drift is purely fixture-additive: with the three new dirs parked, the fingerprint reproduces the prior baseline byte-identically \u2014 no emit/capture change. #1956 synth-widening: + java-iface-extends fixture; synthesizeJavaInheritanceReferences now ALSO walks interface_declaration extends_interfaces (interface IA extends IB, IC<T>), matching the #1940 legacy leg. (Earlier U2+review: java-qualified-base fixture covers 2- AND 3-segment qualified bases guarding the legacy end-anchor; synth tail-resolves scoped bases.) Linear (~1.03). (Earliest: java added to bench, exposed+fixed the O(n^2) findNodeAtRange root-walk; 3.09 -> ~0.99.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
"_note": "#1928 / #2045: F35 adds qualified + qualified-generic constructor query captures (`new pkg.Foo()`, `new a.b.Foo()`, `new pkg.Box<T>()`); F38 synthesizes `@reference.call.constructor` on `super(...)`/`this(...)` explicit_constructor_invocation nodes; F41 generic-aware stripQualifier in interpret (type-binding normalization). + java-qualified-constructor and java-explicit-constructor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.06).",
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: get/test dropped from callableProtocolMethods. Prior 004a3592998dca1193bd1429a8284513725de7764f2a3eceedaaa984cfd763b4 -> f3b4f4b6610e07c3ac90deb1c53d3572b6ad55a36e5d7134984876d30031ff67; scaling ratio re-verified within budget.",
"_rebaselined_2550_instance_model": "PR #2549 (#2550): anonymous class bodies emit synthesized @declaration.class/@declaration.name (Worker$N), an @reference.inherits to the constructed type, and receiver @type-binding.* captures; six new java-* fixtures joined the corpus. Prior f3b4f4b6610e07c3ac90deb1c53d3572b6ad55a36e5d7134984876d30031ff67 -> d79c3b92acfc866094981499b977388ca14f90839bca0c040342ab1cec00aa90; scaling 1.058 < 1.5.",
@ -150,8 +150,8 @@
"_rebaselined_2564_record_capture": "PR for #2564: JAVA_QUERIES gained a (record_declaration name: (identifier) @name) @definition.record capture, previously entirely missing (record_declaration had no structure-phase capture at all, unlike class/interface/enum) - a record's methods existed as ownerless Method nodes with no HAS_METHOD edge. Two new java-* fixtures (java-record-methods, java-new-expr-chain-call) joined the corpus. Prior 975b68aaac6d06094260fb0c67f9b1bc03692ba7220669d192aca9dccd5fc0ca -> 85fc7af9c3c1bceac76cb4f27214410b04967682a2eaa7e468e26efd1f4e2537; scaling 1.059 < 1.5.",
"_rebaselined_2561_enum_constant_receiver": "PR for #2561: synthesizeJavaAnonymousClassDeclarations now emits a class-scope @type-binding.annotation/name/type per enum constant (constant simple name -> its E$N synthesized class when bodied, else the host enum) so E.CONST.method() resolves through the existing compound-receiver chain walk. Two drivers of the drift, both in the java-enum-constant-body fixture (this bench's corpus IS test/fixtures/lang-resolution): (1) one extra type-binding match per enum_constant from the capture change; (2) review follow-up added a body-less Plain.java enum + EnumConst.dispatchToConstant/dispatchInherited methods (bodied-override, inherited-via-MRO, and body-less dispatch call sites). The review's fail-safe hardening (bodied constant binds ONLY to E$N, never the host enum, when name synthesis fails on a malformed tree) is output-neutral on this well-formed corpus (verified: fingerprint identical with and without it). Prior 85fc7af9c3c1bceac76cb4f27214410b04967682a2eaa7e468e26efd1f4e2537 -> d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686; scaling < 1.5.",
"_rebaselined_2562_local_classes": "#2562: Java block-local classes, enums, records, and interfaces use source-type-relative JLS 13.1 Host$NLocal identities with javac-compatible per-(host, simple-name) numbering; anonymous numbering remains separate. Lexical aliases begin at each declaration and end with its immediate block. Expanded java-local-class-naming fixtures cover declaration order, disjoint blocks, initializers, lambdas, local type kinds, and recursive local/member/anonymous host chains. Prior d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686 -> 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197; scaling 1.204 < 1.5.",
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197 -> 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee -> a9943355e945e03ddb87c800f4cc1f62b3d04feefb3ec64c258d8e0bb3b3fcd9.",
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197 -> 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee -> a9943355e945e03ddb87c800f4cc1f62b3d04feefb3ec64c258d8e0bb3b3fcd9.",
"capture_groups_small": 6255,
"capture_groups_large": 20005,
"capture_groups_fp": 3560,
@ -163,33 +163,34 @@
"_rebaselined_2935_synthetic_declarations": "PR #2935 review follow-up: the local-type stress corpus includes synthesized anonymous declarations, which now carry the presence-only @declaration.is-synthetic sidecar. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE. Prior 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633 -> 560734cd053fb4f4b23aa04bc7870c22089a8deedb0217fa9c1b4db689e02a97; CI scaling 1.002 < 1.5.",
"_rebaselined_2917_record_component_accessors": "#2917: the focused local-type fixture corpus contains local records, so their implicit component accessors add the same bounded scope/declaration captures as the general Java corpus. No local-type naming logic changed. Prior 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633 -> 3e22f368a4ee139be7cb91ff4fb77ddadf60c55efe8d66955ec81f366a46e460; scaling 1.032 < 1.5, capture_groups_fp 680. Re-measured on top of #2935's is-synthetic sidecar after merging origin/main: 560734cd053fb4f4b23aa04bc7870c22089a8deedb0217fa9c1b4db689e02a97 -> bdde823fa725e636e257940efb4c8655aa23124c1727cbaa8856d1ad8f71729e; scaling 0.997 < 1.5, capture_groups_fp 680.",
"_added": "#2562 performance follow-up: co-scales same-host, same-name local classes and anonymous classes to gate JLS binary-name ordinal allocation. Precomputed per-sequence ordinals reduce the focused 100->800 workload from 176->6655ms to 141->752ms; normalized 250->800 scaling is 1.054.",
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior a9ad88de21ca6747a923260dbdf677fb74a004abbf9d57781f745e3a9027530b -> 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236 -> 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633.",
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior a9ad88de21ca6747a923260dbdf677fb74a004abbf9d57781f745e3a9027530b -> 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236 -> 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633.",
"capture_groups_fp": 680
},
"typescript": {
"fingerprint": "f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f",
"fingerprint": "05d1dadd6c9ef35c74079fa50f341b1b36e4fb02c9a89dd1b59f32b7cfd5e633",
"scaling_budget": 1.5,
"_rebaselined_2934_import_type_only": "#2934: `import-decomposer.ts` attaches a presence-only `@import.type-only` synthetic capture to specifiers `tsc` erases, so `check --cycles` can stop counting type-only edges as initialization cycles. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE — the tag is added to import matches that already existed, never a new match, the same shape as the #2747 receiver-chain rebaseline. Every count is unchanged: capture_groups_fp 2414, fixture_count 155, capture_groups_small/large 4503/14403 (those measure the SYNTHETIC scaling source, which has no imports at all). The fingerprint moves because `canonicalizeMatch` in measure.mjs hashes every TAG on every match, synthetics included, so one extra presence-only tag on an existing match rewrites that match's canonical string. Attribution is exact, not inferred: neutralizing ONLY the `m['@import.type-only'] = …` assignment in import-decomposer.ts and re-running returns the fingerprint to c2fbf8a89e5686dd… byte-for-byte, so nothing else in the TypeScript capture stream moved. All 14 other languages report ok. Scaling 0.997 < 1.5. NOTE ON THE CONTROL: javascript did not move (2026993b…, 43 fixtures), but it is a WEAK control here — `import type` is TypeScript-only syntax, so a JS corpus cannot express the construct and could not have drifted either way. It evidences no collateral damage, not the correctness of the TS change; the exact-attribution check above is what does that. Prior c2fbf8a89e5686dd1ff3659b20d41d8b05ebcc9790356e3653ee0c8ca5d365c8 -> f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f.",
"_rebaselined_2934_import_type_only": "#2934: `import-decomposer.ts` attaches a presence-only `@import.type-only` synthetic capture to specifiers `tsc` erases, so `check --cycles` can stop counting type-only edges as initialization cycles. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE \u2014 the tag is added to import matches that already existed, never a new match, the same shape as the #2747 receiver-chain rebaseline. Every count is unchanged: capture_groups_fp 2414, fixture_count 155, capture_groups_small/large 4503/14403 (those measure the SYNTHETIC scaling source, which has no imports at all). The fingerprint moves because `canonicalizeMatch` in measure.mjs hashes every TAG on every match, synthetics included, so one extra presence-only tag on an existing match rewrites that match's canonical string. Attribution is exact, not inferred: neutralizing ONLY the `m['@import.type-only'] = \u2026` assignment in import-decomposer.ts and re-running returns the fingerprint to c2fbf8a89e5686dd\u2026 byte-for-byte, so nothing else in the TypeScript capture stream moved. All 14 other languages report ok. Scaling 0.997 < 1.5. NOTE ON THE CONTROL: javascript did not move (2026993b\u2026, 43 fixtures), but it is a WEAK control here \u2014 `import type` is TypeScript-only syntax, so a JS corpus cannot express the construct and could not have drifted either way. It evidences no collateral damage, not the correctness of the TS change; the exact-attribution check above is what does that. Prior c2fbf8a89e5686dd1ff3659b20d41d8b05ebcc9790356e3653ee0c8ca5d365c8 -> f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f.",
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 27f937bfb47d4bded316ea3c785ff659c8cd88a5761d928f113477a08c802c78 -> e05446620c5b80b7aae291cfdf32f693580fada2ae687124769b04a0c03bfe63; scaling 0.983 < 1.5.",
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: lexical callable bindings, direct-callee argument metadata, and invocation-result suppression. Prior db5933cc6760234ed7d495123410feba6de243646d583f20d43032b9459f81fd -> 27f937bfb47d4bded316ea3c785ff659c8cd88a5761d928f113477a08c802c78; scaling 0.975 < 1.5.",
"_rebaselined_callable_flow": "Callable assignment/copy/formal/argument/invoke facts (also consumed by Vue script blocks). Prior 25de86fd3377132c4e35d3d98f4f94a58e0cfeb7c22948a8ea3be4e793be74fd -> db5933cc6760234ed7d495123410feba6de243646d583f20d43032b9459f81fd; measured scaling ratio 0.951 < 1.5.",
"_rebaselined": "#1962: F44 (class scope@), F85 (enum member declarations), F87 (optional_parameter type annotations) add new captures — fingerprint drift expected.",
"_note": "#1968: F44, F85, F87 — fingerprint drift expected.",
"_rebaselined": "#1962: F44 (class scope@), F85 (enum member declarations), F87 (optional_parameter type annotations) add new captures \u2014 fingerprint drift expected.",
"_note": "#1968: F44, F85, F87 \u2014 fingerprint drift expected.",
"_rebaselined_2522": "#2522 intentional @reference.value-ref/property-key capture additions. GitHub Actions run 29553361660 job 87800394279: prior 3f44a4a6892698df2d145c8ff2812c3b318807648983c88aca28fbd694f172f9 -> 25de86fd3377132c4e35d3d98f4f94a58e0cfeb7c22948a8ea3be4e793be74fd; scaling ratio 0.987 < 1.5.",
"_rebaselined_2550_instance_model": "PR #2549 (#2545/#2551): object literals emit @scope.object (was unscoped, then @scope.block during development). Prior e05446620c5b80b7aae291cfdf32f693580fada2ae687124769b04a0c03bfe63 -> 3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4; scaling 0.981 < 1.5.",
"_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) — every other capture count is byte-identical, so no existing capture moved. Prior 3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4 -> 281e95484203b481094729ca249ef0423c41273eac35e424cdfd032a0dac7699.",
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior cad25be9f81d6e021ebae8dcb166bc0af3a1ba8021f1506f6ca93fd4c2649000 -> 9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc -> cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff.",
"_rebaselined_inferred_field_receiver_2807": "#2807: inference-typed class fields now emit a type binding — `public_field_definition` with a `new_expression` value, and `this.<field> = new ...` carrying a @type-binding.this-field marker. ADDS @type-binding.constructor captures only; no capture is removed, and the annotated form is unchanged because annotation outranks constructor-inferred in typeBindingStrength. Prior cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff -> 248b56f0d7a0a6fc7a949dc7afb8611e135ed642bccc2631b96ebb9d686bb965; scaling 0.994 < 1.5.",
"_rebaselined_ts_heritage_2842": "#2842 review: TypeScript heritage capture now emits `@reference.inherits` for `interface_declaration` (bases on `extends_type_clause`) and `abstract_class_declaration` (bases on `class_heritage`), which were both silently skipped — so `interface B extends A` and `abstract class X implements I` produced no edge and every interface-dispatch walk dead-ended on a bodiless declaration. Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus (145 files) with and without the change: the ONLY deltas are @reference.inherits 17 -> 20 (+3) and its paired @reference.name 245 -> 248 (+3), emitted together by emitTsInheritanceBase. Every other capture count is byte-identical, so no existing capture moved. The +3 is the three `interface X extends BasePayload` declarations in typescript-generic-calls/src/{auth,admin,guest}.ts. javascript is unchanged (no interfaces in the language). Prior 248b56f0d7a0a6fc7a949dc7afb8611e135ed642bccc2631b96ebb9d686bb965 -> 7a960908031331360ce582f5b55b7681e1cd7f8a2eabfd73c00982cb17f2a949.",
"_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) \u2014 every other capture count is byte-identical, so no existing capture moved. Prior 3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4 -> 281e95484203b481094729ca249ef0423c41273eac35e424cdfd032a0dac7699.",
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior cad25be9f81d6e021ebae8dcb166bc0af3a1ba8021f1506f6ca93fd4c2649000 -> 9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc -> cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff.",
"_rebaselined_inferred_field_receiver_2807": "#2807: inference-typed class fields now emit a type binding \u2014 `public_field_definition` with a `new_expression` value, and `this.<field> = new ...` carrying a @type-binding.this-field marker. ADDS @type-binding.constructor captures only; no capture is removed, and the annotated form is unchanged because annotation outranks constructor-inferred in typeBindingStrength. Prior cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff -> 248b56f0d7a0a6fc7a949dc7afb8611e135ed642bccc2631b96ebb9d686bb965; scaling 0.994 < 1.5.",
"_rebaselined_ts_heritage_2842": "#2842 review: TypeScript heritage capture now emits `@reference.inherits` for `interface_declaration` (bases on `extends_type_clause`) and `abstract_class_declaration` (bases on `class_heritage`), which were both silently skipped \u2014 so `interface B extends A` and `abstract class X implements I` produced no edge and every interface-dispatch walk dead-ended on a bodiless declaration. Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus (145 files) with and without the change: the ONLY deltas are @reference.inherits 17 -> 20 (+3) and its paired @reference.name 245 -> 248 (+3), emitted together by emitTsInheritanceBase. Every other capture count is byte-identical, so no existing capture moved. The +3 is the three `interface X extends BasePayload` declarations in typescript-generic-calls/src/{auth,admin,guest}.ts. javascript is unchanged (no interfaces in the language). Prior 248b56f0d7a0a6fc7a949dc7afb8611e135ed642bccc2631b96ebb9d686bb965 -> 7a960908031331360ce582f5b55b7681e1cd7f8a2eabfd73c00982cb17f2a949.",
"capture_groups_small": 4503,
"capture_groups_large": 14403,
"capture_groups_fp": 2414,
"fixture_count": 155,
"_rebaselined_blind_spots_2856": "#2856 blind-spots series: the JS/TS SCOPE queries gained capture rules, so fingerprint drift is expected and additive. Verified before re-baselining by diffing the capture-name sets in both scope queries against origin/main: TypeScript gained exactly @reference.read.identifier (A2 bare-identifier reads in value positions) and @reference.type (R2-2 type references, so a declared contract stops reporting incoming:{}); JavaScript gained exactly @reference.read.identifier, @reference.read.destructured (R2-1c) and @reference.write.property-key (R2-1b record-construction writes). NOTHING was removed on either side — the delta is a pure superset, which is the check that no existing capture moved. capture_groups_small/large are unchanged (4503/14403) because those measure the SYNTHETIC scaling source, which this branch does not touch; only the fixture-corpus count moves. capture_groups_fp 2097 -> 2338 and fixture_count 146 -> 151 from 21 new lang-resolution fixtures. Scaling stayed linear and inside budget: typescript 1.116 < 1.5, javascript 1.010 < 1.5. Prior typescript ed92588e0fc7b28b3a0174339ac378b4dd85965fe007db1208dea97a65ce0571 -> f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7; prior javascript 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594 -> 2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3.",
"_rebaselined_type_parameter_shadowing_w2_8": "W2-8: `@declaration.type-parameters` is now captured on generic FUNCTIONS, generator functions and type ALIASES, not only on class/interface declarations. NO NEW CAPTURE NAME — verified by diffing the capture-name sets against the wave-1 branch, which returns empty; the tag already existed and simply fires on more declarations. That is the whole delta: capture_groups_fp 2338 -> 2371 (+33 occurrences of an existing tag) and fixture_count 151 -> 152 (one new fixture, typescript-type-parameters). capture_groups_small/large unchanged at 4503/14403, since those measure the synthetic scaling source this does not touch. Scaling 1.06 < 1.5. JavaScript is untouched — it has no type parameters — and its fingerprint does not move, which is the check that this is the TS declaration rules and not something broader. Prior f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7 -> 62c7f1bfbe568eed927fb78f00061ed5e49d12511fd8260648b876df386f3b4c.",
"_rebaselined_2899_review_type_parameter_scope_fixtures": "PR #2899 review follow-up: FIXTURE-CORPUS GROWTH ONLY — no query rule changed and no capture name was added or removed. `typescript/query.ts` is byte-identical to the previous baseline; the type-parameter shadowing defect was fixed on the RESOLUTION side (`walkers.ts` gains a `declarationOpenedScope` gate so a declaration's `typeParameters` bind only inside the scope that declaration opened, and the `USES` guard moved from `graph-bridge/references-to-edges.ts` to `resolve-references.ts` where the spelled `site.name` is in hand). The fingerprint moves because measure.mjs fingerprints the whole `lang-resolution/typescript-*` fixture corpus and the regression tests add three files to `typescript-type-parameters/src/` (values.ts, aliased.ts, namespaced.ts) plus two scope-less generic aliases in shapes.ts. Per-file accounting sums exactly to the delta: shapes.ts 33->35 (+2), values.ts +11, aliased.ts +10, namespaced.ts +20 = +43. capture_groups_fp 2371 -> 2414; fixture_count 152 -> 155. capture_groups_small/large unchanged at 4503/14403 (they measure the SYNTHETIC scaling source, untouched). JAVASCRIPT IS THE CONTROL AND DID NOT MOVE (fingerprint 2026993b..., 43 fixtures) — which is the check that this is corpus growth and not a capture regression; all 14 other languages report `ok`. Scaling 0.976 < 1.5. Prior 62c7f1bfbe568eed927fb78f00061ed5e49d12511fd8260648b876df386f3b4c -> c2fbf8a89e5686dd1ff3659b20d41d8b05ebcc9790356e3653ee0c8ca5d365c8."
"capture_groups_fp": 2465,
"fixture_count": 167,
"_rebaselined_blind_spots_2856": "#2856 blind-spots series: the JS/TS SCOPE queries gained capture rules, so fingerprint drift is expected and additive. Verified before re-baselining by diffing the capture-name sets in both scope queries against origin/main: TypeScript gained exactly @reference.read.identifier (A2 bare-identifier reads in value positions) and @reference.type (R2-2 type references, so a declared contract stops reporting incoming:{}); JavaScript gained exactly @reference.read.identifier, @reference.read.destructured (R2-1c) and @reference.write.property-key (R2-1b record-construction writes). NOTHING was removed on either side \u2014 the delta is a pure superset, which is the check that no existing capture moved. capture_groups_small/large are unchanged (4503/14403) because those measure the SYNTHETIC scaling source, which this branch does not touch; only the fixture-corpus count moves. capture_groups_fp 2097 -> 2338 and fixture_count 146 -> 151 from 21 new lang-resolution fixtures. Scaling stayed linear and inside budget: typescript 1.116 < 1.5, javascript 1.010 < 1.5. Prior typescript ed92588e0fc7b28b3a0174339ac378b4dd85965fe007db1208dea97a65ce0571 -> f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7; prior javascript 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594 -> 2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3.",
"_rebaselined_type_parameter_shadowing_w2_8": "W2-8: `@declaration.type-parameters` is now captured on generic FUNCTIONS, generator functions and type ALIASES, not only on class/interface declarations. NO NEW CAPTURE NAME \u2014 verified by diffing the capture-name sets against the wave-1 branch, which returns empty; the tag already existed and simply fires on more declarations. That is the whole delta: capture_groups_fp 2338 -> 2371 (+33 occurrences of an existing tag) and fixture_count 151 -> 152 (one new fixture, typescript-type-parameters). capture_groups_small/large unchanged at 4503/14403, since those measure the synthetic scaling source this does not touch. Scaling 1.06 < 1.5. JavaScript is untouched \u2014 it has no type parameters \u2014 and its fingerprint does not move, which is the check that this is the TS declaration rules and not something broader. Prior f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7 -> 62c7f1bfbe568eed927fb78f00061ed5e49d12511fd8260648b876df386f3b4c.",
"_rebaselined_2899_review_type_parameter_scope_fixtures": "PR #2899 review follow-up: FIXTURE-CORPUS GROWTH ONLY \u2014 no query rule changed and no capture name was added or removed. `typescript/query.ts` is byte-identical to the previous baseline; the type-parameter shadowing defect was fixed on the RESOLUTION side (`walkers.ts` gains a `declarationOpenedScope` gate so a declaration's `typeParameters` bind only inside the scope that declaration opened, and the `USES` guard moved from `graph-bridge/references-to-edges.ts` to `resolve-references.ts` where the spelled `site.name` is in hand). The fingerprint moves because measure.mjs fingerprints the whole `lang-resolution/typescript-*` fixture corpus and the regression tests add three files to `typescript-type-parameters/src/` (values.ts, aliased.ts, namespaced.ts) plus two scope-less generic aliases in shapes.ts. Per-file accounting sums exactly to the delta: shapes.ts 33->35 (+2), values.ts +11, aliased.ts +10, namespaced.ts +20 = +43. capture_groups_fp 2371 -> 2414; fixture_count 152 -> 155. capture_groups_small/large unchanged at 4503/14403 (they measure the SYNTHETIC scaling source, untouched). JAVASCRIPT IS THE CONTROL AND DID NOT MOVE (fingerprint 2026993b..., 43 fixtures) \u2014 which is the check that this is corpus growth and not a capture regression; all 14 other languages report `ok`. Scaling 0.976 < 1.5. Prior 62c7f1bfbe568eed927fb78f00061ed5e49d12511fd8260648b876df386f3b4c -> c2fbf8a89e5686dd1ff3659b20d41d8b05ebcc9790356e3653ee0c8ca5d365c8.",
"_rebaselined_2953_workspace_fixture": "#2953 adds test/fixtures/lang-resolution/typescript-pnpm-workspace-imports, a pnpm monorepo of 12 .ts files, and the TypeScript capture corpus is collected from test/fixtures. CORPUS GROWTH ONLY, NOT A CAPTURE CHANGE: fixture_count 155 -> 167 and capture_groups_fp 2414 -> 2465 are the 12 new files' own matches; capture_groups_small/large are unchanged at 4503/14403 because those measure the SYNTHETIC scaling source, which the fixture corpus does not feed. Attribution is exact rather than inferred: moving that one fixture directory aside and re-running returns typescript to f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f byte-for-byte with fixture_count back at 155, and [scope-capture --check] PASSES for all 15 languages - so nothing in the TypeScript capture stream moved. #2953 changes import RESOLUTION, which runs after capture and feeds no capture tag. Prior f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f -> 05d1dadd6c9ef35c74079fa50f341b1b36e4fb02c9a89dd1b59f32b7cfd5e633."
},
"javascript": {
"fingerprint": "2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3",
@ -201,10 +202,10 @@
"_rebaselined": "#1956 synth-widening: + javascript-qualified-base fixture; synthesizeJsInheritanceReferences now handles a member_expression base (class S extends ns.Base -> Base), matching the #1940 legacy leg + the TS terminalTsTypeNameNode property_identifier case, at parity. Linear (~1.05). | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
"_rebaselined_2522": "#2522 intentional @reference.value-ref/property-key capture additions. GitHub Actions run 29553361660 job 87800394279: prior d72f03c6c502235d2d4b74d66baa5c7d361f040d7a1b72e84acad61210d05ae8 -> 5567dd47e7ba29821a518c4a9852adc3b774e25ef3e7a6e2b3ecb7b59ddab73c; scaling ratio 1.031 < 1.5.",
"_rebaselined_2550_instance_model": "PR #2549 (#2545/#2551): object literals emit @scope.object. Prior 479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b -> f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c; scaling 1.096 < 1.5.",
"_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) — every other capture count is byte-identical, so no existing capture moved. Prior f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c -> 90601494695b834d3a9af7ac4844eac603f4f432809a05554cc59de0674a4354.",
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 1c71ef628eb75a3b111afa8c2a7c351c16a7f5aab9fac2f098f82b2866312aa8 -> 83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc -> 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594.",
"_rebaselined_blind_spots_2856": "#2856 blind-spots series: the JS/TS SCOPE queries gained capture rules, so fingerprint drift is expected and additive. Verified before re-baselining by diffing the capture-name sets in both scope queries against origin/main: TypeScript gained exactly @reference.read.identifier (A2 bare-identifier reads in value positions) and @reference.type (R2-2 type references, so a declared contract stops reporting incoming:{}); JavaScript gained exactly @reference.read.identifier, @reference.read.destructured (R2-1c) and @reference.write.property-key (R2-1b record-construction writes). NOTHING was removed on either side — the delta is a pure superset, which is the check that no existing capture moved. capture_groups_small/large are unchanged (4503/14403) because those measure the SYNTHETIC scaling source, which this branch does not touch; only the fixture-corpus count moves. capture_groups_fp 2097 -> 2338 and fixture_count 146 -> 151 from 21 new lang-resolution fixtures. Scaling stayed linear and inside budget: typescript 1.116 < 1.5, javascript 1.010 < 1.5. Prior typescript ed92588e0fc7b28b3a0174339ac378b4dd85965fe007db1208dea97a65ce0571 -> f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7; prior javascript 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594 -> 2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3."
"_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) \u2014 every other capture count is byte-identical, so no existing capture moved. Prior f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c -> 90601494695b834d3a9af7ac4844eac603f4f432809a05554cc59de0674a4354.",
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 1c71ef628eb75a3b111afa8c2a7c351c16a7f5aab9fac2f098f82b2866312aa8 -> 83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc -> 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594.",
"_rebaselined_blind_spots_2856": "#2856 blind-spots series: the JS/TS SCOPE queries gained capture rules, so fingerprint drift is expected and additive. Verified before re-baselining by diffing the capture-name sets in both scope queries against origin/main: TypeScript gained exactly @reference.read.identifier (A2 bare-identifier reads in value positions) and @reference.type (R2-2 type references, so a declared contract stops reporting incoming:{}); JavaScript gained exactly @reference.read.identifier, @reference.read.destructured (R2-1c) and @reference.write.property-key (R2-1b record-construction writes). NOTHING was removed on either side \u2014 the delta is a pure superset, which is the check that no existing capture moved. capture_groups_small/large are unchanged (4503/14403) because those measure the SYNTHETIC scaling source, which this branch does not touch; only the fixture-corpus count moves. capture_groups_fp 2097 -> 2338 and fixture_count 146 -> 151 from 21 new lang-resolution fixtures. Scaling stayed linear and inside budget: typescript 1.116 < 1.5, javascript 1.010 < 1.5. Prior typescript ed92588e0fc7b28b3a0174339ac378b4dd85965fe007db1208dea97a65ce0571 -> f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7; prior javascript 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594 -> 2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3."
},
"kotlin": {
"fingerprint": "a184f8ff0ae40d246db855b63f7ff26bda3afac03e5f4c76e4593c7e2cefce54",
@ -213,13 +214,13 @@
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Kotlin callable-reference flow facts with invocation-result suppression. Prior 4900431791f2b9280009deb2b82659c26ead8aa6fb8731190a7c505dec5a9041 -> bddba25d5a88152bbbee8d70e82c944b5302accb4b625df782adb1d4f7a7ac12; scaling 0.880 < 1.5.",
"_added": "#1951: bench coverage added (was ungated); scale source heritage-bearing (: Base()); js/kotlin O(n^2) findNodeAtRange-per-match fixed to threaded captured node, now linear.",
"_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0.",
"_rebaselined_2271": "PR #2271: re-vendored tree-sitter-kotlin 0.3.8 -> unreleased fwcd main c8ac3d26 for `fun interface` support + new kotlin-fun-interface fixture in the corpus. Drift is both corpus-additive (the fixture) and grammar-driven (the new grammar parses `fun interface` as a class_declaration, not an ERROR node). Baselined to the NEW grammar's fingerprint, so this --check passes only once the regenerated prebuilds land — until then CI loads the committed 0.3.8 binary and the bench is red, same as the kotlin fun-interface integration tests. scaling ~0.83 (linear).",
"_rebaselined_2271": "PR #2271: re-vendored tree-sitter-kotlin 0.3.8 -> unreleased fwcd main c8ac3d26 for `fun interface` support + new kotlin-fun-interface fixture in the corpus. Drift is both corpus-additive (the fixture) and grammar-driven (the new grammar parses `fun interface` as a class_declaration, not an ERROR node). Baselined to the NEW grammar's fingerprint, so this --check passes only once the regenerated prebuilds land \u2014 until then CI loads the committed 0.3.8 binary and the bench is red, same as the kotlin fun-interface integration tests. scaling ~0.83 (linear).",
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: fieldless assignment nodes decomposed positionally. Prior e856951c2a779163d555dadc8e1bf59304a86caed78ac1f450d9caa2b50f63d1 -> 4b31f46cfb004ba769a96feeb06ae4ef109c77410f54e7aaab4a688df599b112; scaling ratio re-verified within budget.",
"_rebaselined_2550_instance_model": "PR #2549 (#2545): anonymous object expressions (object_literal) emit @scope.class, and the kotlin-object-literal-scope fixture joined the corpus. Prior 4b31f46cfb004ba769a96feeb06ae4ef109c77410f54e7aaab4a688df599b112 -> a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091; scaling 0.951 < 1.5.",
"_rebaselined_2563_instance_ownership": "#2563: kotlin-instance-ownership adds unrelated, inherited, outer-instance, and anonymous-object coverage. Prior a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091 -> 9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195; scaling 1.257 < 1.5.",
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195 -> d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1 -> c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b.",
"_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 — the two whose fixture corpora contain such receivers. Prior c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b -> efd5dbf80ffcd3bab2834d1010f6fe2b239dcc5d58229938dea9cff8d0f380f2.",
"_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195 -> d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1.",
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1 -> c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b.",
"_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 \u2014 the two whose fixture corpora contain such receivers. Prior c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b -> efd5dbf80ffcd3bab2834d1010f6fe2b239dcc5d58229938dea9cff8d0f380f2.",
"capture_groups_small": 4753,
"capture_groups_large": 15203,
"capture_groups_fp": 2334,

View file

@ -0,0 +1,527 @@
/**
* In-repo `package.json` manifests, as module-resolution input (#2953).
*
* A bare specifier (`@acme/telemetry/nest`, `@repo/utils`, `lodash/fp`) names a
* PACKAGE, not a path, and the manifest is the only thing that says which
* packages exist and where their entry points are. Without it a resolver can do
* nothing but guess — which is what the old suffix matcher did, landing
* `@acme/telemetry/nest` on the repo's only path ending in `nest/index.ts`
* while `@repo/utils`, a real first-party package, resolved to nothing because
* its name appears in no file path at all.
*
* Both directions come from the same missing input, so both are fixed by
* reading it: every in-repo `package.json` contributes its `name`, its `exports`
* map (including subpath patterns), its legacy entry fields, and its `imports`
* map for `#`-prefixed specifiers.
*/
import fs from 'fs/promises';
import path from 'path';
import { createRequire } from 'node:module';
import { isHardcodedIgnoredDirectory } from '../../../config/ignore-service.js';
import { logger } from '../../logger.js';
import { resolveFile } from '../languages/typescript/file-candidates.js';
// `js-yaml` is CJS; the rest of this repository reaches it the same way
// (`core/group/config-parser.ts`, `cli/group.ts`).
const _require = createRequire(import.meta.url);
const yaml = _require('js-yaml') as typeof import('js-yaml');
/** One in-repo package. */
export interface NodeWorkspacePackage {
/** Repo-relative directory holding the `package.json` (`''` for the root). */
readonly dir: string;
/**
* Repo-relative entry stems for the package root (`import '@repo/utils'`),
* best first: declared `exports["."]`, then `module` / `main` / `types`, then
* the conventional `src/index` and `index`.
*
* A published `dist/...` entry simply fails to match an indexed source file
* (build output is not indexed) and the next candidate is tried, which is why
* the conventional fallbacks stay at the end rather than being a guess: they
* are what the package resolves to when it is consumed from source, which in
* a workspace it always is.
*/
readonly entries: readonly string[];
/**
* Declared `exports` subpaths, specifier suffix -> repo-relative stems.
* Keys are as written minus the leading `./`, so `"./nest"` is stored `nest`;
* a pattern key keeps its `*` (`"./features/*"` -> `features/*`).
*/
readonly subpathExports: ReadonlyMap<string, readonly string[]>;
/** Declared `imports` map, `#name` -> repo-relative stems. */
readonly subpathImports: ReadonlyMap<string, readonly string[]>;
}
export interface NodeWorkspacePackages {
/** Package name (`@repo/utils`, `utils`) -> that package. */
readonly byName: ReadonlyMap<string, NodeWorkspacePackage>;
}
const SCAN_MAX_DIRS = 20_000;
const SCAN_MAX_DEPTH = 24;
/**
* The package name a bare specifier addresses, or `null` when the specifier
* names a path rather than a package.
*
* `@acme/telemetry/nest` -> `@acme/telemetry`, `lodash/fp` -> `lodash`.
*/
export function nodePackageNameOf(specifier: string): string | null {
if (specifier === '' || specifier.startsWith('.') || specifier.startsWith('/')) return null;
if (specifier.startsWith('#')) return null;
if (specifier.startsWith('@')) {
const parts = specifier.split('/');
return parts.length >= 2 && parts[0].length > 1 && parts[1] !== ''
? `${parts[0]}/${parts[1]}`
: null;
}
return specifier.split('/')[0] || null;
}
/** The in-repo package whose directory most closely contains `filePath`. */
export function owningPackage(
filePath: string,
packages: NodeWorkspacePackages | null | undefined,
): NodeWorkspacePackage | null {
if (!packages) return null;
let best: NodeWorkspacePackage | null = null;
for (const pkg of packages.byName.values()) {
const inside = pkg.dir === '' || filePath.startsWith(`${pkg.dir}/`);
if (inside && (best === null || pkg.dir.length > best.dir.length)) best = pkg;
}
return best;
}
/**
* Resolve a bare specifier that names an in-repo package.
*
* `null` means the specifier names no in-repo package — an external dependency,
* whose correct in-repo resolution is nothing — or names one that does not
* export the requested subpath.
*/
export function resolveNodeWorkspaceImport(
specifier: string,
packages: NodeWorkspacePackages | null | undefined,
allFiles: ReadonlySet<string>,
): string | null {
if (!packages) return null;
const packageName = nodePackageNameOf(specifier);
if (packageName === null) return null;
const pkg = packages.byName.get(packageName);
if (pkg === undefined) return null;
const subpath = specifier.slice(packageName.length).replace(/^\//, '');
for (const stem of entryStemsFor(pkg, subpath)) {
const hit = resolveFile(stem, allFiles);
if (hit !== null) return hit;
}
return null;
}
/**
* Look a specifier up in a subpath map — `exports` or `imports`, which share
* Node's matching rule exactly: an exact key wins, otherwise the pattern with
* the longest literal prefix does, and its `*` takes whatever the specifier put
* there.
*
* Shared because they diverged once: the `imports` side did an exact lookup
* only, so a declared `"#internal/*"` could never match `#internal/foo`.
*/
export function matchSubpathMap(
map: ReadonlyMap<string, readonly string[]>,
specifier: string,
): readonly string[] | null {
const exact = map.get(specifier);
if (exact !== undefined) return exact;
const patterns = [...map.entries()]
.filter(([key]) => key.includes('*'))
.map(([key, stems]) => {
const star = key.indexOf('*');
return { prefix: key.slice(0, star), suffix: key.slice(star + 1), stems };
})
.filter(
({ prefix, suffix }) =>
specifier.startsWith(prefix) &&
specifier.endsWith(suffix) &&
specifier.length >= prefix.length + suffix.length,
)
.sort((a, b) => b.prefix.length - a.prefix.length);
for (const { prefix, suffix, stems } of patterns) {
const stem = specifier.slice(prefix.length, specifier.length - suffix.length);
return stems.map((target) => substituteStar(target, stem));
}
return null;
}
/**
* Substitute a subpath pattern's single `*`.
*
* Node's subpath patterns and TypeScript's `paths` both allow AT MOST one `*`,
* so replacing the first occurrence is the specified behaviour rather than a
* partial one — but `String.replace` with a string needle says that only by
* accident, and reads as a bug to anyone (CodeQL included) who has met the
* replace-all footgun. Slicing at the known index states the rule instead.
*/
export function substituteStar(target: string, stem: string): string {
const star = target.indexOf('*');
return star === -1 ? target : target.slice(0, star) + stem + target.slice(star + 1);
}
/** Candidate stems for one specifier into `pkg`, best first. */
function entryStemsFor(pkg: NodeWorkspacePackage, subpath: string): readonly string[] {
if (subpath === '') return pkg.entries;
const declared = matchSubpathMap(pkg.subpathExports, subpath);
if (declared !== null) return declared;
// A package with NO `exports` map is not restricted: Node resolves any
// subpath against the package DIRECTORY, and only against it. A package WITH
// one exposes only what it lists, so an unlisted subpath resolves to nothing.
//
// Both restrictions are real, and neither is softened here. An earlier draft
// also tried `<dir>/src/<subpath>`, on the theory that a workspace package is
// consumed from source — but nothing declares that mapping, so it is the same
// kind of guess this module exists to remove: it would resolve
// `@repo/utils/deep/thing` to `packages/utils/src/deep/thing.ts` for a
// package whose manifest never said `deep/thing` lives under `src/`, and the
// import would be broken in the real project too.
if (pkg.subpathExports.size > 0) return [];
return [joinRepoPath(pkg.dir, subpath)];
}
/**
* The directories the workspace ADMITS as packages.
*
* `null` means the repository declares no workspace at all, in which case the
* only package is the one at the root — a nested `package.json` somewhere in
* `examples/` or `test/fixtures/` is not a member of anything and its name is
* not addressable by an import.
*
* This gate is the difference between reading manifests and trusting them.
* Without it, finding a `package.json` anywhere in the tree was enough to
* register its name, which recreates the false-positive half of #2953 from a
* different source: an app importing registry package `foo` would bind to an
* excluded fixture that happens to declare `name: "foo"`. THIS repository is
* the example — `test/fixtures/**` alone declares `@repo/utils` (added by this
* very change) among others.
*/
interface WorkspaceScope {
/** Positive patterns, repo-relative, as declared. */
readonly include: readonly string[];
/** `!`-prefixed patterns, with the `!` stripped. */
readonly exclude: readonly string[];
}
/** Whether `dir` (repo-relative, `''` for the root) is an admitted package. */
function admits(scope: WorkspaceScope | null, dir: string): boolean {
// The root package is always itself, workspace or not.
if (dir === '') return true;
if (scope === null) return false;
if (scope.exclude.some((pattern) => globToRegExp(pattern).test(dir))) return false;
return scope.include.some((pattern) => globToRegExp(pattern).test(dir));
}
/**
* Match one workspace glob.
*
* The subset npm, pnpm, yarn and lerna actually use in `workspaces` /
* `packages`: `*` within a segment, `**` across segments, `?`, and a leading
* `!` for exclusion (handled by the caller). Deliberately not a general glob
* engine — the patterns are a documented, narrow dialect, and `minimatch` is
* only present here transitively through `glob`.
*/
function globToRegExp(pattern: string): RegExp {
const normalized = pattern.replace(/^\.\//, '').replace(/\/$/, '');
let out = '';
for (let i = 0; i < normalized.length; i++) {
const ch = normalized[i];
if (ch === '*') {
if (normalized[i + 1] === '*') {
// `**/` may match nothing at all, so `packages/**/x` also matches
// `packages/x`; a trailing `**` matches any depth below.
if (normalized[i + 2] === '/') {
out += '(?:.*/)?';
i += 2;
} else {
out += '.*';
i += 1;
}
} else {
out += '[^/]*';
}
continue;
}
if (ch === '?') {
out += '[^/]';
continue;
}
out += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
}
return new RegExp(`^${out}$`);
}
/**
* Read the repository's workspace declaration.
*
* All three spellings are read and merged, because a repo may carry more than
* one (a pnpm workspace whose root `package.json` also lists `workspaces` for
* tooling that does not read pnpm's file).
*/
async function loadWorkspaceScope(repoRoot: string): Promise<WorkspaceScope | null> {
const patterns: string[] = [];
const rootManifest = await readJsonFile(path.join(repoRoot, 'package.json'));
const workspaces = rootManifest?.workspaces;
if (Array.isArray(workspaces)) {
patterns.push(...workspaces.filter((w): w is string => typeof w === 'string'));
} else if (workspaces !== null && typeof workspaces === 'object') {
// Yarn's object form: `{ "packages": [...], "nohoist": [...] }`.
const nested = (workspaces as { packages?: unknown }).packages;
if (Array.isArray(nested)) {
patterns.push(...nested.filter((w): w is string => typeof w === 'string'));
}
}
patterns.push(...(await readYamlPackages(path.join(repoRoot, 'pnpm-workspace.yaml'))));
patterns.push(...(await readYamlPackages(path.join(repoRoot, 'pnpm-workspace.yml'))));
const lerna = await readJsonFile(path.join(repoRoot, 'lerna.json'));
if (Array.isArray(lerna?.packages)) {
patterns.push(...lerna.packages.filter((w): w is string => typeof w === 'string'));
}
if (patterns.length === 0) return null;
return {
include: patterns.filter((p) => !p.startsWith('!')),
exclude: patterns.filter((p) => p.startsWith('!')).map((p) => p.slice(1)),
};
}
async function readJsonFile(filePath: string): Promise<Record<string, unknown> | null> {
try {
return JSON.parse(await fs.readFile(filePath, 'utf-8')) as Record<string, unknown>;
} catch {
return null;
}
}
async function readYamlPackages(filePath: string): Promise<string[]> {
let raw: string;
try {
raw = await fs.readFile(filePath, 'utf-8');
} catch {
return [];
}
try {
const parsed = yaml.load(raw) as { packages?: unknown } | null;
const packages = parsed?.packages;
return Array.isArray(packages)
? packages.filter((p): p is string => typeof p === 'string')
: [];
} catch {
return [];
}
}
/**
* Collect the `package.json` of every ADMITTED workspace package.
*
* Directory-only BFS: the sole files opened are manifests and the workspace
* declaration, so this is far cheaper than the C# namespace scan next door,
* which reads every `.cs` file.
*/
export async function loadNodeWorkspacePackages(
repoRoot: string,
): Promise<NodeWorkspacePackages | null> {
const scope = await loadWorkspaceScope(repoRoot);
const byName = new Map<string, NodeWorkspacePackage>();
const queue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }];
let dirsScanned = 0;
while (queue.length > 0) {
if (dirsScanned >= SCAN_MAX_DIRS) {
logger.warn(
`[node] package.json scan of ${repoRoot} hit the ${SCAN_MAX_DIRS}-directory cap; workspace packages below it will not resolve`,
);
break;
}
const { dir, depth } = queue.shift()!;
dirsScanned++;
let entries: import('fs').Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (isHardcodedIgnoredDirectory(entry.name)) continue;
if (depth < SCAN_MAX_DEPTH) {
queue.push({ dir: path.join(dir, entry.name), depth: depth + 1 });
}
continue;
}
if (!entry.isFile() || entry.name !== 'package.json') continue;
const relDir = repoRelativeDir(repoRoot, dir);
// Found is not the same as admitted. A manifest outside the declared
// workspace belongs to something this repository does not build — a
// fixture, an example, a vendored copy — and its name is not addressable.
if (!admits(scope, relDir)) continue;
const pkg = await readManifest(path.join(dir, entry.name), repoRoot, dir);
// First declaration wins: BFS visits shallower directories first, so a
// top-level package outranks a nested one that reuses the name.
if (pkg !== null && !byName.has(pkg.name)) byName.set(pkg.name, pkg.package);
}
}
return byName.size === 0 ? null : { byName };
}
async function readManifest(
manifestPath: string,
repoRoot: string,
dir: string,
): Promise<{ name: string; package: NodeWorkspacePackage } | null> {
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(await fs.readFile(manifestPath, 'utf-8')) as Record<string, unknown>;
} catch {
return null;
}
const name = typeof parsed.name === 'string' ? parsed.name : '';
if (name === '') return null;
const packageDir = repoRelativeDir(repoRoot, dir);
const rebase = (raw: string): string => joinRepoPath(packageDir, stripEntryPrefixes(raw));
const subpathExports = new Map<string, readonly string[]>();
const rootExports: string[] = [];
collectExports(parsed.exports, subpathExports, rootExports, rebase);
// `exports`, when present, is the package's ENTIRE public interface: Node
// ignores `main` outright and refuses any subpath the map does not list. This
// resolver already honoured that restriction for subpaths (`entryStemsFor`)
// and not for the ROOT, which is the same rule — so a manifest exporting only
// `"./feature"` still answered a bare `@repo/pkg` with `src/index`, an edge
// for an import that does not resolve in the real project.
const declaresExports = parsed.exports !== undefined && parsed.exports !== null;
const entries: string[] = [...rootExports];
if (!declaresExports) {
for (const field of ['module', 'main', 'types', 'typings']) {
const value = parsed[field];
if (typeof value === 'string') push(entries, rebase(value));
}
for (const conventional of ['src/index', 'index', 'lib/index']) {
push(entries, joinRepoPath(packageDir, conventional));
}
}
const subpathImports = new Map<string, readonly string[]>();
collectImports(parsed.imports, subpathImports, rebase);
return { name, package: { dir: packageDir, entries, subpathExports, subpathImports } };
}
/**
* Walk an `exports` value into the root-entry list and the subpath map.
*
* `exports` nests three ways at once — a bare string, a subpath map, and
* condition maps (`import` / `require` / `types` / `default`) at any depth — so
* this collects string leaves per subpath rather than assuming a shape.
*/
function collectExports(
node: unknown,
subpaths: Map<string, readonly string[]>,
rootStems: string[],
rebase: (raw: string) => string,
currentSubpath: string | null = '',
): void {
if (typeof node === 'string') {
if (currentSubpath === null) return;
if (currentSubpath === '') {
push(rootStems, rebase(node));
return;
}
subpaths.set(currentSubpath, [...(subpaths.get(currentSubpath) ?? []), rebase(node)]);
return;
}
// An array is an ordered FALLBACK LIST, not an opaque value: Node tries each
// entry in turn. `{"./feature": ["./dist/feature.js", "./src/feature.ts"]}` is
// the shape a workspace package publishes to say "built output, or source" —
// and the source arm is the one that matters here, because `dist/` is build
// output and is not indexed. Skipping arrays dropped the declaration entirely
// and left the package looking as though it declared no subpath exports.
if (Array.isArray(node)) {
for (const element of node)
collectExports(element, subpaths, rootStems, rebase, currentSubpath);
return;
}
if (node === null || typeof node !== 'object') return;
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
if (key.startsWith('.')) {
// A subpath key: `"."` is the package root, `"./nest"` the subpath `nest`.
collectExports(
value,
subpaths,
rootStems,
rebase,
key === '.' ? '' : key.replace(/^\.\//, ''),
);
} else {
// A condition key — stays on whatever subpath we were already resolving.
collectExports(value, subpaths, rootStems, rebase, currentSubpath);
}
}
}
/** Walk an `imports` map (`"#env": "./src/env.node.ts"`) into stems. */
function collectImports(
node: unknown,
out: Map<string, readonly string[]>,
rebase: (raw: string) => string,
currentKey: string | null = null,
): void {
if (typeof node === 'string') {
if (currentKey === null) return;
out.set(currentKey, [...(out.get(currentKey) ?? []), rebase(node)]);
return;
}
// Same ordered-fallback rule as `exports` — see `collectExports`.
if (Array.isArray(node)) {
for (const element of node) collectImports(element, out, rebase, currentKey);
return;
}
if (node === null || typeof node !== 'object') return;
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
collectImports(value, out, rebase, key.startsWith('#') ? key : currentKey);
}
}
/** `"./src/index.ts"` -> `"src/index"`; leaves an extension-less path alone. */
function stripEntryPrefixes(entry: string): string {
const withoutDot = entry.replace(/^\.\//, '').replace(/^\//, '');
return withoutDot.replace(/\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|vue)$/, '');
}
function push(list: string[], value: string): void {
if (value !== '' && !list.includes(value)) list.push(value);
}
/** `/repo/packages/utils` -> `packages/utils`; the root -> `''`. */
function repoRelativeDir(repoRoot: string, dir: string): string {
const rel = path.relative(repoRoot, dir).split(path.sep).join('/');
return rel === '.' ? '' : rel;
}
function joinRepoPath(dir: string, rest: string): string {
return dir === '' ? rest : `${dir}/${rest}`;
}

View file

@ -1,118 +1,50 @@
/**
* Import-target resolver for JavaScript.
*
* Delegates to the TypeScript `resolveTsTarget` standard-strategy resolver
* with `language: SupportedLanguages.JavaScript` so the resolver tries
* `.js` / `.jsx` extensions in addition to (or instead of) `.ts` / `.tsx`.
* Delegates to the TypeScript resolver, which is correct rather than merely
* convenient: `jsconfig.json` is a tsconfig by another name, `package.json`
* governs both languages identically, and Node's algorithm does not branch on
* which of the two wrote the file. The extension list already carries the JS
* family, so a `.js`/`.jsx`/`.mjs`/`.cjs` source resolves the same way.
*
* The `TsResolveContext.language` flag already exists in `import-target.ts`
* and the resolver (`resolveImportPath`) already branches on it — this
* adapter just wires the right value in.
* CJS `require()` calls reference the same module-path strings as ESM `import`
* statements, so the resolver handles them uniformly with no CJS-specific
* logic here.
*
* CJS `require()` calls reference the same module-path strings as ESM
* `import` statements, so the resolver handles them uniformly without any
* CJS-specific logic here.
* ## What #2953 removed
*
* No `tsconfig.json` path-alias support (JavaScript projects don't use
* `tsconfig.json` compilerOptions.paths in general). Projects that DO use
* tsconfig-based aliases alongside JavaScript can still resolve via the
* standard extension-suffix fallback; the alias branch is a no-op when
* `tsconfigPaths` is null.
*
* ## The suffix index changes bare-specifier answers (PR #2911)
*
* Supplying `index` is not only a speed-up: `suffixResolve` answers a different
* question with one than without. Without an index it tests
* `filePath.endsWith('/' + suffix)`, so only a PROPER suffix can match; with
* one it reads `buildSuffixIndex`, which indexes `j = 0` and therefore matches
* WHOLE paths too. Two classes of answer move, both only on the bare/absolute
* specifier leg (relative imports resolve by exact `Set.has` and never reach
* it), and both toward what TypeScript and Vue have always answered:
*
* 1. a repo-root file becomes reachable at all — `require('config')` now
* finds `config.js`, where before no proper suffix existed and the answer
* was null;
* 2. a whole-path candidate outranks a proper-suffix candidate found at a
* SHORTER path suffix or a later extension — `import 'app/main'` resolved
* to `node_modules/dep/lib/main.js` (the first `/main.js` in file order)
* and now resolves to `app/main.js`.
*
* Measured over 211 200 old-vs-new pairs there is no third class: the index
* never loses a match the scan found, and its answer is never matched at a less
* specific (path-part, extension) position. `test/unit/scope-resolution/
* javascript-import-target-parity.test.ts` is that differential, and pins both
* classes by witness.
* This adapter used to reach `resolveImportPath`, whose last step was
* `suffixResolve` — a search for any repo file whose path ends in the
* specifier, retried with each leading segment dropped. The header this
* replaces recorded the symptom without naming it a defect: `import 'app/main'`
* resolving to `node_modules/dep/lib/main.js`, "the first `/main.js` in file
* order". A bare specifier now resolves only through a declared tsconfig
* mapping or a package manifest, and otherwise not at all.
*/
import { SupportedLanguages } from 'gitnexus-shared';
import { resolveTsTarget, type TsResolveContext } from '../typescript/import-target.js';
import { buildImportPassCache } from '../../import-resolvers/pass-cache.js';
import { perFileSet } from '../../import-resolvers/per-file-set.js';
import type { NodeWorkspacePackages } from '../../import-resolvers/node-workspace-packages.js';
import { resolveTsTarget } from '../typescript/import-target.js';
import type { TsconfigIndex } from '../typescript/tsconfig.js';
export type JsResolveContext = TsResolveContext;
interface JsResolutionConfig {
readonly tsconfigs?: TsconfigIndex | null;
readonly nodeWorkspacePackages?: NodeWorkspacePackages | null;
}
/**
* Everything `resolveTsTarget` derives from one workspace file set, built once
* per set rather than once per import.
*
* `index` is not optional, and its absence was the defect (PR #2911). The
* TypeScript adapter has carried a `SuffixIndex` since #1918; this one did not,
* so every JavaScript import reached `suffixResolve` with `index === undefined`
* and took its linear-`findIndex` fallback — one pass over `normalizedFileList`
* per path part per extension, and `EXTENSIONS` has ~39 entries. Measured on
* mostly-missing bare specifiers (imports scaling with files, as in
* `bench/import-target/`): 6448.9 µs per import at 2000 files and 25972.6 µs at
* 8000 — 4.12x the per-import cost for 4x the files, which is O(imports ×
* files) — against 25.0 / 27.0 µs for TypeScript over the identical corpus.
* With the index it is 28.5 / 27.4 µs and the scaling factor is 1.09x.
*
* No instrument on the #2901-#2909 branch could see it: `CountingSet` counts
* traversals of the SET, and this scan walks the materialized array behind it.
* See `test/integration/javascript-import-index-reuse.test.ts` for the guard
* that can.
*
* Memoized on the `allFilePaths` Set identity, like every other language's
* import index (`import-resolvers/workspace-file-index.ts` and friends).
*
* A single-slot `let cached` keyed on `cached.key !== allFilePaths` — what this
* adapter used before — is correct for one file set and degenerate for two:
* alternating calls across two sets rebuild everything every time. Measured on
* the TypeScript adapter at 4000 files × 400 imports: 12.0 ms for one set,
* 1438.2 ms alternating between two (120x). A `WeakMap` has no such state to
* thrash, which is also what lets this adapter carry the standard
* `expectDistinctFileSetsGetOwnIndex` guard every other indexed adapter
* carries.
*
* The Set must be passed THROUGH by the caller, never copied: a defensive
* `new Set(allFilePaths)` at the adapter boundary hands a fresh key per import
* and restores the per-import rebuild (PR #1918 review P1).
*/
const passCacheFor = perFileSet(buildImportPassCache);
/**
* Build a memoized `resolveImportTarget` adapter for JavaScript.
* Caches the derived arrays, the suffix index and the per-pass resolve cache
* across `resolveImportTarget` calls over one workspace file set.
*/
/** Build the JavaScript `resolveImportTarget` adapter. */
export function makeJsResolveImportTarget(): (
targetRaw: string,
fromFile: string,
allFilePaths: ReadonlySet<string>,
resolutionConfig?: unknown,
) => string | readonly string[] | null {
return (targetRaw, fromFile, allFilePaths) => {
const cached = passCacheFor(allFilePaths);
const ws: JsResolveContext = {
return (targetRaw, fromFile, allFilePaths, resolutionConfig) => {
const cfg = resolutionConfig as JsResolutionConfig | undefined;
return resolveTsTarget(targetRaw, {
fromFile,
language: SupportedLanguages.JavaScript,
allFilePaths: cached.allFilePaths,
allFileList: cached.allFileList,
normalizedFileList: cached.normalizedFileList,
index: cached.index,
resolveCache: cached.resolveCache,
tsconfigPaths: null,
};
return resolveTsTarget(targetRaw, ws);
allFilePaths,
tsconfigs: cfg?.tsconfigs ?? null,
nodeWorkspacePackages: cfg?.nodeWorkspacePackages ?? null,
});
};
}

View file

@ -42,6 +42,8 @@ import { javascriptProvider } from '../typescript.js';
import { jsMergeBindings } from './merge-bindings.js';
import { jsArityCompatibility } from './arity.js';
import { makeJsResolveImportTarget } from './import-target.js';
import { loadTsconfigIndex } from '../typescript/tsconfig.js';
import { loadNodeWorkspacePackages } from '../../import-resolvers/node-workspace-packages.js';
const javascriptScopeResolver: ScopeResolver = {
// Construction is keyword-prefixed: `new Service(db).doWork()` (#2708).
@ -52,6 +54,15 @@ const javascriptScopeResolver: ScopeResolver = {
resolveImportTarget: makeJsResolveImportTarget(),
// JavaScript resolution reads the same declared inputs TypeScript does —
// `jsconfig.json` is a tsconfig by another name, and `package.json` is shared
// outright. Without them a bare specifier used to fall through to suffix
// matching (#2953); now it simply does not resolve.
loadResolutionConfig: async (repoPath: string) => ({
tsconfigs: await loadTsconfigIndex(repoPath),
nodeWorkspacePackages: await loadNodeWorkspacePackages(repoPath),
}),
// JavaScript LEGB — same tier ordering as TypeScript; no declaration-
// merging across type/value/namespace spaces.
mergeBindings: (existing, incoming) => [...jsMergeBindings([...existing, ...incoming])],

View file

@ -0,0 +1,70 @@
/**
* Turning a resolved stem into a real file, the way TypeScript does (#2953).
*
* Shared by `module-resolution.ts` and the package-manifest resolver so both
* try the same three shapes — exact path, extension, directory index — and, as
* importantly, the same NARROW extension list. The repo-wide `EXTENSIONS` in
* `import-resolvers/utils.ts` carries ~39 entries spanning every language the
* indexer supports; a TypeScript import cannot resolve to a `.py` or `.rb`
* file, and letting it try was part of how the old suffix matcher found files
* that had nothing to do with the import.
*/
/** Extension candidates, in the order TypeScript tries them. */
export const TS_EXTENSIONS = [
'.ts',
'.tsx',
'.d.ts',
'.mts',
'.cts',
'.js',
'.jsx',
'.mjs',
'.cjs',
'.vue',
'.json',
] as const;
/**
* JS-family extensions a specifier may carry for a TypeScript source file.
*
* TypeScript ESM requires the specifier to name the EMITTED file (`./m.js`)
* while the file on disk is `./m.ts`, so a resolver that only tried the literal
* extension would miss every ESM-style relative import in a modern codebase.
*/
export const JS_TO_TS: ReadonlyMap<string, readonly string[]> = new Map([
['.js', ['.ts', '.tsx', '.d.ts']],
['.jsx', ['.tsx']],
['.mjs', ['.mts']],
['.cjs', ['.cts']],
]);
/**
* A repo-relative stem resolved to a real indexed file, or `null`.
*
* Exact match, then the ESM `.js` → `.ts` rewrite, then each extension, then
* the directory-index form. Nothing here searches: every candidate is derived
* from the stem the caller already resolved from a declared source.
*/
export function resolveFile(stem: string, allFiles: ReadonlySet<string>): string | null {
if (stem === '') return null;
if (allFiles.has(stem)) return stem;
const dot = stem.lastIndexOf('.');
const ext = dot === -1 ? '' : stem.slice(dot);
const tsEquivalents = JS_TO_TS.get(ext);
if (tsEquivalents !== undefined) {
const stripped = stem.slice(0, -ext.length);
for (const candidate of tsEquivalents) {
if (allFiles.has(stripped + candidate)) return stripped + candidate;
}
}
for (const candidate of TS_EXTENSIONS) {
if (allFiles.has(stem + candidate)) return stem + candidate;
}
for (const candidate of TS_EXTENSIONS) {
if (allFiles.has(`${stem}/index${candidate}`)) return `${stem}/index${candidate}`;
}
return null;
}

View file

@ -1,44 +1,35 @@
/**
* Adapter from `(ParsedImport, WorkspaceIndex)` → concrete file path.
*
* Delegates to the existing standard-strategy resolver
* (`resolveImportPath`) so tsconfig path aliases (`@/`, `~/`, …) and
* suffix-based resolution follow the same rules as the legacy path.
* Delegates to `module-resolution.ts`, which runs the algorithm `tsc` and Node
* actually run. It used to delegate to the shared `resolveImportPath`, whose
* final step was `suffixResolve` — a repo-wide search for any file path ending
* in the specifier. That is what #2953 removed: this path now resolves only
* against declared inputs (real paths, tsconfig `paths`/`baseUrl`, package
* manifests) and answers `null` for everything else.
*
* The `WorkspaceIndex` is opaque at the shared contract layer; we
* narrow it to a TypeScript-shaped context that carries `fromFile` +
* the full `allFilePaths` set + the optional `tsconfigPaths` the
* resolver reads.
* The `WorkspaceIndex` is opaque at the shared contract layer; we narrow it to
* a TypeScript-shaped context carrying `fromFile`, the workspace file set, and
* the two config indexes the algorithm reads.
*
* Returning `null` lets the finalize algorithm mark the edge as
* `linkStatus: 'unresolved'`.
* `linkStatus: 'unresolved'` — which for an external package is the correct
* and complete answer.
*/
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
import { SupportedLanguages } from 'gitnexus-shared';
import { resolveImportPath } from '../../import-resolvers/standard.js';
import type { SuffixIndex } from '../../import-resolvers/utils.js';
import type { TsconfigPaths } from '../../language-config.js';
import type { NodeWorkspacePackages } from '../../import-resolvers/node-workspace-packages.js';
import { resolveTsModule } from './module-resolution.js';
import type { TsconfigIndex } from './tsconfig.js';
export interface TsResolveContext {
readonly fromFile: string;
/** Mutable `Set` because the standard resolver consumes `Set<string>`.
* Callers holding a `ReadonlySet` should copy via `new Set(...)`. */
readonly allFilePaths: Set<string>;
/** Repo file list, normalized (lowercased) for suffix matching. May
* be supplied by the orchestrator; if absent we derive it on the
* fly from `allFilePaths`. */
readonly allFileList?: readonly string[];
readonly normalizedFileList?: readonly string[];
/** Per-call resolution cache to dedupe repeated lookups. */
readonly resolveCache?: Map<string, string | null>;
/** Prebuilt suffix index for O(1)-style package/absolute import matching. */
readonly index?: SuffixIndex;
/** Parsed tsconfig path-aliases. `null` = no aliases configured. */
readonly tsconfigPaths?: TsconfigPaths | null;
/** JavaScript vs TypeScript switch — affects the extensions the
* resolver tries. Defaults to TypeScript. */
readonly language?: SupportedLanguages.TypeScript | SupportedLanguages.JavaScript;
/** The workspace file set. */
readonly allFilePaths: ReadonlySet<string>;
/** Every tsconfig in the repo; `null` when the repo declares none. */
readonly tsconfigs?: TsconfigIndex | null;
/** Every in-repo `package.json`; `null` when the repo declares none. */
readonly nodeWorkspacePackages?: NodeWorkspacePackages | null;
}
export function resolveTsImportTarget(
@ -59,36 +50,21 @@ export function resolveTsImportTarget(
}
/**
* Resolve a raw module-path string to a workspace file path using the
* same standard-strategy resolver as the legacy DAG. Operates directly on
* the source string without requiring a `ParsedImport`, so the
* `ScopeResolver.resolveImportTarget` adapter doesn't need to construct
* a fake `ParsedImport` to reach the resolver.
* Resolve a raw module-path string to a workspace file path. Operates directly
* on the source string without requiring a `ParsedImport`, so the
* `ScopeResolver.resolveImportTarget` adapter doesn't need to construct a fake
* one to reach the resolver.
*
* Returns `null` when:
* - the context is malformed (missing `fromFile` / `allFilePaths`)
* - `targetRaw` is empty
* - the resolver finds no matching file
* Returns `null` when `targetRaw` is empty, names an external package, or names
* something no declared config maps into the repo.
*/
export function resolveTsTarget(targetRaw: string, ctx: TsResolveContext): string | null {
if (targetRaw === '') return null;
const language = ctx.language ?? SupportedLanguages.TypeScript;
const allFileList = ctx.allFileList ?? Array.from(ctx.allFilePaths);
const normalizedFileList = ctx.normalizedFileList ?? allFileList.map((f) => f.toLowerCase());
const resolveCache = ctx.resolveCache ?? new Map<string, string | null>();
return resolveImportPath(
ctx.fromFile,
targetRaw,
ctx.allFilePaths,
allFileList,
normalizedFileList,
resolveCache,
language,
ctx.tsconfigPaths ?? null,
ctx.index,
);
return resolveTsModule(targetRaw, {
fromFile: ctx.fromFile,
allFilePaths: ctx.allFilePaths,
tsconfigs: ctx.tsconfigs ?? null,
workspacePackages: ctx.nodeWorkspacePackages ?? null,
});
}
function narrowTsContext(workspaceIndex: WorkspaceIndex): TsResolveContext | null {

View file

@ -0,0 +1,199 @@
/**
* TypeScript / JavaScript module resolution (#2953).
*
* This is the algorithm `tsc` and Node actually run, in the order they run it.
* It replaces `import-resolvers/utils.ts:suffixResolve` on the TS/JS/Vue path,
* which answered a different question — "does any file in this repo have a path
* ending in this specifier?" — and answered it by dropping leading segments
* until something matched. That is why `@acme/telemetry/nest`, a registry
* dependency, landed on the repo's only path ending in `nest/index.ts`.
*
* Every rule below resolves against something DECLARED: a real path, a
* `tsconfig` mapping, or a `package.json` manifest. A specifier that matches
* none of them is external, and external resolves to nothing. There is
* deliberately no fallback: a guess is what this module exists to remove, and
* an edge nobody declared is worse than a missing one precisely because it
* cannot be told apart from a real one downstream.
*
* ## The order, and why it is this order
*
* 1. relative / absolute — a path is a path; nothing else can claim it.
* 2. `#`-prefixed — package.json `imports`, which is scoped to the importing
* package and shadows everything else by design.
* 3. tsconfig `paths` — explicit mappings win over `baseUrl`, and the LONGEST
* matching pattern wins among them (tsc's rule, not first-declared).
* 4. tsconfig `baseUrl` — the rule that makes `import 'src/utils/foo'` legal.
* Note it applies only when a config actually declares one; without it,
* TypeScript treats a non-relative specifier as a package lookup, and so
* does this module.
* 5. workspace package — the manifest map, resolved through that package's
* own `exports` / `main` / `module` / `types`.
* 6. anything else — external. `null`.
*/
import type { NodeWorkspacePackages } from '../../import-resolvers/node-workspace-packages.js';
import {
matchSubpathMap,
nodePackageNameOf,
owningPackage,
resolveNodeWorkspaceImport,
substituteStar,
} from '../../import-resolvers/node-workspace-packages.js';
import { resolveFile } from './file-candidates.js';
import { tsconfigFor, type TsconfigIndex, type TsPathMapping } from './tsconfig.js';
export interface TsModuleResolutionContext {
readonly fromFile: string;
readonly allFilePaths: ReadonlySet<string>;
readonly tsconfigs: TsconfigIndex | null;
readonly workspacePackages: NodeWorkspacePackages | null;
}
/**
* Resolve one specifier to a repo file, or `null` when nothing in the repo
* declares it.
*/
export function resolveTsModule(specifier: string, ctx: TsModuleResolutionContext): string | null {
if (specifier === '') return null;
// 1. A path specifier.
if (specifier.startsWith('.')) {
const joined = joinFrom(ctx.fromFile, specifier);
return joined === null ? null : resolveFile(joined, ctx.allFilePaths);
}
if (specifier.startsWith('/')) {
return resolveFile(specifier.slice(1), ctx.allFilePaths);
}
// 2. Package-internal `#imports`. Scoped to the importing package, so it is
// looked up there and nowhere else — a `#` specifier that the package does
// not declare is an error in Node, not a repo-wide search.
if (specifier.startsWith('#')) {
return resolveSubpathImport(specifier, ctx);
}
const config = tsconfigFor(ctx.tsconfigs, ctx.fromFile);
// 3. `paths`, longest matching pattern first.
if (config !== null && config.paths.length > 0) {
const viaPaths = resolveViaPaths(specifier, config.paths, ctx.allFilePaths);
if (viaPaths !== null) return viaPaths;
}
// 4. `baseUrl`.
if (config !== null && config.baseUrl !== null) {
const viaBaseUrl = resolveFile(joinRepo(config.baseUrl, specifier), ctx.allFilePaths);
if (viaBaseUrl !== null) return viaBaseUrl;
}
// 5. A package that lives in this repo.
const viaWorkspace = resolveNodeWorkspaceImport(
specifier,
ctx.workspacePackages,
ctx.allFilePaths,
);
if (viaWorkspace !== null) return viaWorkspace;
// 6. External. Nothing in the repo declared it, so it resolves to nothing —
// which for a registry dependency is the correct and complete answer.
return null;
}
/**
* Apply `paths` the way tsc does: the pattern with the longest literal prefix
* before `*` wins, and its targets are tried in declaration order.
*
* The old loader kept `targets[0]` and treated the pattern as a plain prefix,
* which silently mis-resolves the common `"@/*": ["./src/*", "./generated/*"]`
* shape — the second target is where half of a generated-code monorepo lives.
*/
function resolveViaPaths(
specifier: string,
paths: readonly TsPathMapping[],
allFiles: ReadonlySet<string>,
): string | null {
const matches: { mapping: TsPathMapping; stem: string | null; prefixLength: number }[] = [];
for (const mapping of paths) {
const star = mapping.pattern.indexOf('*');
if (star === -1) {
if (mapping.pattern === specifier) {
matches.push({ mapping, stem: null, prefixLength: mapping.pattern.length });
}
continue;
}
const prefix = mapping.pattern.slice(0, star);
const suffix = mapping.pattern.slice(star + 1);
if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix)) continue;
if (specifier.length < prefix.length + suffix.length) continue;
matches.push({
mapping,
stem: specifier.slice(prefix.length, specifier.length - suffix.length),
prefixLength: prefix.length,
});
}
// An exact (starless) pattern outranks any wildcard, THEN longer prefix wins.
// Sorting on prefix length alone left that first rule to luck: `a` and `a*`
// both match `a` with prefix length 1, so whichever was declared first won.
matches.sort(
(a, b) => Number(a.stem !== null) - Number(b.stem !== null) || b.prefixLength - a.prefixLength,
);
for (const match of matches) {
for (const target of match.mapping.targets) {
const candidate = match.stem === null ? target : substituteStar(target, match.stem);
const resolved = resolveFile(candidate, allFiles);
if (resolved !== null) return resolved;
}
}
return null;
}
/** Resolve `#name` against the importing file's own package manifest. */
function resolveSubpathImport(specifier: string, ctx: TsModuleResolutionContext): string | null {
const packages = ctx.workspacePackages;
if (packages === null) return null;
const owner = owningPackage(ctx.fromFile, packages);
if (owner === null) return null;
// `imports` takes pattern keys (`"#internal/*"`) exactly like `exports`, so
// it gets the same matcher rather than an exact lookup.
for (const stem of matchSubpathMap(owner.subpathImports, specifier) ?? []) {
const resolved = resolveFile(stem, ctx.allFilePaths);
if (resolved !== null) return resolved;
}
return null;
}
/**
* Resolve a relative specifier against the importing file's directory, or
* `null` when it climbs out of the repository.
*
* Popping an empty segment list would silently CLAMP at the root, so
* `../../../secret` from `src/main.ts` became `secret` and could resolve a
* repo-root file the specifier never named. Outside the repo there is nothing
* indexed to resolve to, so the honest answer is nothing.
*/
function joinFrom(fromFile: string, specifier: string): string | null {
const segments = fromFile.split('/').slice(0, -1);
for (const part of specifier.split('/')) {
if (part === '.' || part === '') continue;
if (part === '..') {
if (segments.length === 0) return null;
segments.pop();
} else {
segments.push(part);
}
}
return segments.join('/');
}
function joinRepo(dir: string, rest: string): string {
return dir === '' ? rest : `${dir}/${rest}`;
}
/** Whether a specifier names a package rather than a path — used by callers
* that want to report an unresolved import as external rather than missing. */
export function isPackageSpecifier(specifier: string): boolean {
return nodePackageNameOf(specifier) !== null;
}

View file

@ -21,16 +21,13 @@ import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolv
import { simpleKey } from '../../scope-resolution/graph-bridge/node-lookup.js';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { typescriptProvider } from '../typescript.js';
import { loadTsconfigPaths, type TsconfigPaths } from '../../language-config.js';
import { buildImportPassCache } from '../../import-resolvers/pass-cache.js';
import { perFileSet } from '../../import-resolvers/per-file-set.js';
import { indexOnlyElementType } from '../../type-extractors/shared.js';
import { loadTsconfigIndex, type TsconfigIndex } from './tsconfig.js';
import {
typescriptArityCompatibility,
typescriptMergeBindings,
resolveTsTarget,
type TsResolveContext,
} from './index.js';
loadNodeWorkspacePackages,
type NodeWorkspacePackages,
} from '../../import-resolvers/node-workspace-packages.js';
import { indexOnlyElementType } from '../../type-extractors/shared.js';
import { typescriptArityCompatibility, typescriptMergeBindings, resolveTsTarget } from './index.js';
import {
getNuxtAutoImportEntry,
hasNuxtAutoImports,
@ -40,7 +37,10 @@ import {
/** Shape the orchestrator threads in via `RunScopeResolutionInput.resolutionConfig`. */
interface TypescriptResolutionConfig {
readonly tsconfigPaths: TsconfigPaths | null;
/** Every tsconfig in the repo, `extends` resolved (#2953). */
readonly tsconfigs: TsconfigIndex | null;
/** Every in-repo `package.json`, for workspace-package resolution (#2953). */
readonly nodeWorkspacePackages: NodeWorkspacePackages | null;
/** Nuxt/Nitro auto-import map. Null for non-Nuxt projects. */
readonly nuxtAutoImports: NuxtAutoImportConfig | null;
}
@ -56,43 +56,22 @@ const TYPESCRIPT_TYPE_ONLY_BINDING_TYPES = new Set<NodeLabel>([
]);
/**
* Memoized on the `allFilePaths` Set identity, like every other language's
* import index (`import-resolvers/workspace-file-index.ts` and friends).
* Build the `resolveImportTarget` adapter.
*
* This used to be a single-slot `let cached` invalidated by
* `cached.key !== allFilePaths` — correct for one file set and degenerate for
* two: alternating calls across two sets rebuilt everything every time.
* Measured here at 4000 files × 400 imports: 12.0 ms for one set, 1438.2 ms
* alternating between two (120x). A `WeakMap` has no such state to thrash, and
* it is what lets this adapter carry the standard
* `expectDistinctFileSetsGetOwnIndex` guard the other languages carry
* (`test/integration/typescript-import-index-reuse.test.ts`).
*
* The Set must be passed THROUGH by the caller, never copied: a defensive
* `new Set(allFilePaths)` at the adapter boundary hands a fresh key per import
* and restores the per-import rebuild (PR #1918 review P1).
*/
const tsPassCacheFor = perFileSet(buildImportPassCache);
/**
* Build a `resolveImportTarget` adapter that reads the memoized per-file-set
* state above rather than re-deriving it on every import lookup.
* No per-file-set memo any more: the suffix index it existed to amortize is
* gone with #2953. Real resolution derives nothing from the file list — every
* candidate comes from a config the repo declares, and checking one is a
* `Set.has` — so there is nothing left to cache per pass.
*/
function makeTsResolveImportTarget(): ScopeResolver['resolveImportTarget'] {
return (targetRaw, fromFile, allFilePaths, resolutionConfig) => {
const cached = tsPassCacheFor(allFilePaths);
const cfg = resolutionConfig as TypescriptResolutionConfig | undefined;
const ws: TsResolveContext = {
return resolveTsTarget(targetRaw, {
fromFile,
allFilePaths: cached.allFilePaths,
allFileList: cached.allFileList,
normalizedFileList: cached.normalizedFileList,
index: cached.index,
resolveCache: cached.resolveCache,
tsconfigPaths: cfg?.tsconfigPaths ?? null,
};
return resolveTsTarget(targetRaw, ws);
allFilePaths,
tsconfigs: cfg?.tsconfigs ?? null,
nodeWorkspacePackages: cfg?.nodeWorkspacePackages ?? null,
});
};
}
@ -118,7 +97,8 @@ const typescriptScopeResolver: ScopeResolver = {
// `nuxtAutoImports` is null for non-Nuxt projects (no .nuxt/imports.d.ts),
// so this adds zero overhead to ordinary TypeScript repos.
loadResolutionConfig: async (repoPath: string) => ({
tsconfigPaths: await loadTsconfigPaths(repoPath),
tsconfigs: await loadTsconfigIndex(repoPath),
nodeWorkspacePackages: await loadNodeWorkspacePackages(repoPath),
nuxtAutoImports: await loadNuxtAutoImports(repoPath),
}),

View file

@ -0,0 +1,338 @@
/**
* Real `tsconfig.json` loading for module resolution (#2953).
*
* The previous loader (`language-config.ts:loadTsconfigPaths`) was built to feed
* a heuristic, and it shows: it reads three filenames at the repo ROOT only,
* gives up unless `compilerOptions.paths` exists, keeps only `targets[0]` of
* each mapping, and treats a pattern as a plain prefix. That is enough to make
* a guess look plausible and not enough to resolve anything correctly:
*
* - a monorepo has one tsconfig PER PACKAGE, and `apps/web/tsconfig.json` is
* what governs `apps/web/src/main.ts` — the root config governs nothing;
* - `extends` is how essentially every real config is written, and the
* `baseUrl` / `paths` almost always live in the extended base;
* - `baseUrl` alone (no `paths`) is a complete resolution rule on its own, and
* it is exactly the rule that makes `import 'src/utils/foo'` legal — the
* case the old suffix matcher was really standing in for;
* - `paths` maps a pattern to an ORDERED LIST of targets, tried in order.
*
* So this module answers the question TypeScript actually asks: for THIS file,
* what are `baseUrl` and `paths`?
*/
import fs from 'fs/promises';
import path from 'path';
import { isHardcodedIgnoredDirectory } from '../../../../config/ignore-service.js';
import { logger } from '../../../logger.js';
/** One `paths` entry, pattern and targets kept in declaration order. */
export interface TsPathMapping {
/** The pattern as written, e.g. `@/*`, `@app/*`, `exact`. */
readonly pattern: string;
/** Targets as written, relative to `baseUrl`. Tried in order. */
readonly targets: readonly string[];
}
/** The resolution-relevant part of one resolved tsconfig. */
export interface TsconfigScope {
/** Repo-relative directory the config governs (the tsconfig's own directory). */
readonly dir: string;
/**
* Repo-relative `baseUrl`, or `null` when the config declares none.
*
* `null` is not the same as `'.'`: without `baseUrl`, TypeScript does NOT
* resolve non-relative specifiers against the project at all (they are
* package lookups), and `paths` targets are resolved against the tsconfig's
* own directory instead.
*/
readonly baseUrl: string | null;
readonly paths: readonly TsPathMapping[];
}
/** Every tsconfig in the repo, indexed so the nearest one to a file wins. */
export interface TsconfigIndex {
/** Deepest-first, so the first `dir` that prefixes a file path governs it. */
readonly scopes: readonly TsconfigScope[];
}
const SCAN_MAX_DIRS = 20_000;
const SCAN_MAX_DEPTH = 24;
/** Guard against an `extends` cycle or a pathological chain. */
const MAX_EXTENDS_DEPTH = 16;
/**
* The config governing `filePath` — the nearest tsconfig at or above it.
*
* TypeScript resolves a file against the project that includes it; the nearest
* enclosing tsconfig is the faithful approximation of that without evaluating
* `include`/`exclude` globs, and it is what makes a monorepo's per-package
* `baseUrl` apply to that package's files instead of the root's.
*/
export function tsconfigFor(index: TsconfigIndex | null, filePath: string): TsconfigScope | null {
if (index === null) return null;
for (const scope of index.scopes) {
if (scope.dir === '') return scope;
if (filePath.startsWith(`${scope.dir}/`)) return scope;
}
return null;
}
/** Load every tsconfig in the repo, resolving `extends` chains. */
export async function loadTsconfigIndex(repoRoot: string): Promise<TsconfigIndex | null> {
const files = await findTsconfigFiles(repoRoot);
if (files.length === 0) return null;
const ranked: { scope: TsconfigScope; rank: number }[] = [];
for (const absPath of files) {
const options = await readCompilerOptions(absPath, 0);
if (options === null) continue;
// `readCompilerOptions` resolves both to ABSOLUTE paths against whichever
// config in the `extends` chain declared them, which is the only way the
// chain stays unambiguous. Rebasing to repo-relative happens once, here.
const baseUrl = options.baseUrl === undefined ? null : repoRelative(repoRoot, options.baseUrl);
const paths = (options.paths ?? []).map((mapping) => ({
pattern: mapping.pattern,
targets: mapping.targets.map((t) => rebaseTarget(repoRoot, t)),
}));
// A config declaring NEITHER is kept, not skipped. Dropping it let
// `tsconfigFor` fall through to an enclosing config, so a package whose own
// tsconfig declares no `baseUrl` — meaning its non-relative specifiers are
// package lookups — silently inherited the repo root's aliases instead.
// An empty scope is the accurate answer for such a file, and only a scope
// can express it.
ranked.push({
scope: { dir: repoRelative(repoRoot, path.dirname(absPath)), baseUrl, paths },
rank: configRank(path.basename(absPath)),
});
}
if (ranked.length === 0) return null;
// Deepest first, because `tsconfigFor` takes the first match and it must be
// the most specific config rather than whichever the walk reached first.
//
// Then by filename rank WITHIN a directory, which is the half that is easy to
// miss: `tsconfig.json` and `tsconfig.base.json` routinely sit side by side,
// and the base exists to be extended, not to govern. Reading whichever the
// directory listing returned first made a config's own `paths` invisible
// whenever its base happened to be listed earlier.
ranked.sort((a, b) => b.scope.dir.length - a.scope.dir.length || a.rank - b.rank);
return { scopes: ranked.map((entry) => entry.scope) };
}
/**
* Precedence among configs sharing a directory: the project config governs, and
* everything else is a base or a variant that exists to be extended.
*/
function configRank(fileName: string): number {
if (fileName === 'tsconfig.json') return 0;
if (fileName === 'jsconfig.json') return 1;
return 2;
}
/** Resolved compiler options, rebased to repo-relative paths. */
interface ResolvedOptions {
baseUrl?: string;
paths?: TsPathMapping[];
}
/**
* Read one tsconfig and merge in whatever it `extends`.
*
* Rebasing happens per FILE, before merging, because `extends` does not rebase
* `baseUrl`: a base config at `configs/tsconfig.base.json` declaring
* `"baseUrl": "."` means `configs/`, even when extended from `apps/web`. Doing
* the rebase at read time is what keeps that true through the chain.
*/
async function readCompilerOptions(
absPath: string,
depth: number,
repoRootHint?: string,
): Promise<ResolvedOptions | null> {
if (depth > MAX_EXTENDS_DEPTH) {
logger.warn(`[typescript] tsconfig extends chain too deep at ${absPath}; ignoring the rest`);
return null;
}
let parsed: Record<string, unknown>;
try {
parsed = parseJsonc(await fs.readFile(absPath, 'utf-8'));
} catch {
return null;
}
const dir = path.dirname(absPath);
// Read what this config extends FIRST: `paths` targets resolve against the
// EFFECTIVE `baseUrl`, which a config declaring `paths` alone inherits from
// its base. Resolving them against this config's own directory instead would
// load the right alias pattern and point every target at the wrong place.
const inherited = await readExtended(parsed.extends, dir, depth, repoRootHint);
const own: ResolvedOptions = {};
const compilerOptions = parsed.compilerOptions;
if (compilerOptions !== null && typeof compilerOptions === 'object') {
const opts = compilerOptions as Record<string, unknown>;
if (typeof opts.baseUrl === 'string') {
own.baseUrl = path.resolve(dir, opts.baseUrl);
}
if (opts.paths !== null && typeof opts.paths === 'object' && !Array.isArray(opts.paths)) {
// tsc resolves `paths` targets against the effective `baseUrl` — this
// config's own if it declares one, otherwise the inherited one — and
// against the config's own directory only when neither exists. Doing it
// here, per file, is what keeps an `extends` chain unambiguous: by the
// time these merge, every target is already absolute.
const pathsBase = own.baseUrl ?? inherited?.baseUrl ?? dir;
own.paths = [];
for (const [pattern, targets] of Object.entries(opts.paths as Record<string, unknown>)) {
if (!Array.isArray(targets)) continue;
const asStrings = targets
.filter((t): t is string => typeof t === 'string')
.map((t) => path.resolve(pathsBase, t));
if (asStrings.length > 0) own.paths.push({ pattern, targets: asStrings });
}
}
}
// Own options win over inherited ones — that is what `extends` means. `paths`
// is replaced wholesale rather than merged, matching tsc.
return {
...(inherited ?? {}),
...own,
};
}
/** Follow `extends`, which may be a string or (TS 5+) an array, base-first. */
async function readExtended(
value: unknown,
fromDir: string,
depth: number,
repoRootHint?: string,
): Promise<ResolvedOptions | null> {
const specs = typeof value === 'string' ? [value] : Array.isArray(value) ? value : [];
let merged: ResolvedOptions | null = null;
for (const spec of specs) {
if (typeof spec !== 'string') continue;
const resolved = await resolveExtendsTarget(spec, fromDir);
if (resolved === null) continue;
const options = await readCompilerOptions(resolved, depth + 1, repoRootHint);
if (options === null) continue;
// Later entries win over earlier ones, per tsc's array semantics.
merged = { ...(merged ?? {}), ...options };
}
return merged;
}
/**
* An `extends` value is either a path or a package name.
*
* The package form (`"extends": "@tsconfig/node20/tsconfig.json"`,
* `"@acme/tsconfig"`) lives in `node_modules`, which this tool deliberately
* does NOT index — it is dependency code, not the repository's own. But not
* indexing it is different from not READING it, and the distinction matters
* here: a shared internal base config is exactly where a monorepo puts the
* `paths` its packages import through, so refusing to open it loses aliases
* that the repository genuinely declares.
*
* So the file is read from disk when it is there, walking `node_modules` up
* from the extending config the way Node does. When it is absent — an
* un-installed checkout, which is a shape a static analyser must expect and a
* compiler may refuse — the answer is `null`, and the caller keeps whatever the
* extending config declared itself. That degrades to fewer resolutions, never
* to invented ones.
*/
async function resolveExtendsTarget(spec: string, fromDir: string): Promise<string | null> {
if (spec.startsWith('.') || path.isAbsolute(spec)) {
return firstReadableConfig(path.resolve(fromDir, spec));
}
for (const modulesDir of nodeModulesChain(fromDir)) {
const found = await firstReadableConfig(path.join(modulesDir, spec));
if (found !== null) return found;
}
return null;
}
/** `<dir>/node_modules`, then each ancestor's, the way Node resolves. */
function* nodeModulesChain(fromDir: string): Generator<string> {
let dir = fromDir;
for (;;) {
if (path.basename(dir) !== 'node_modules') yield path.join(dir, 'node_modules');
const parent = path.dirname(dir);
if (parent === dir) return;
dir = parent;
}
}
/** The first spelling of `base` that is a readable file. */
async function firstReadableConfig(base: string): Promise<string | null> {
for (const candidate of [base, `${base}.json`, path.join(base, 'tsconfig.json')]) {
try {
const stat = await fs.stat(candidate);
if (stat.isFile()) return candidate;
} catch {
// try the next spelling
}
}
return null;
}
async function findTsconfigFiles(repoRoot: string): Promise<string[]> {
const found: string[] = [];
const queue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }];
let dirsScanned = 0;
while (queue.length > 0 && dirsScanned < SCAN_MAX_DIRS) {
const { dir, depth } = queue.shift()!;
dirsScanned++;
let entries: import('fs').Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (isHardcodedIgnoredDirectory(entry.name)) continue;
if (depth < SCAN_MAX_DEPTH)
queue.push({ dir: path.join(dir, entry.name), depth: depth + 1 });
continue;
}
if (!entry.isFile()) continue;
// `tsconfig.json`, `tsconfig.app.json`, `jsconfig.json`, … — any of them
// can carry the `baseUrl`/`paths` that governs its directory.
if (/^(ts|js)config(\..+)?\.json$/.test(entry.name)) {
found.push(path.join(dir, entry.name));
}
}
}
return found;
}
/** Strip comments and trailing commas — tsconfig is JSONC, not JSON. */
function parseJsonc(raw: string): Record<string, unknown> {
const withoutComments = raw
.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*$)|(\/\*[\s\S]*?\*\/)/gm, (match, line, block) =>
line !== undefined || block !== undefined ? '' : match,
)
.replace(/,(\s*[}\]])/g, '$1');
return JSON.parse(withoutComments) as Record<string, unknown>;
}
function repoRelative(repoRoot: string, absDir: string): string {
const rel = path.relative(repoRoot, absDir).split(path.sep).join('/');
return rel === '.' || rel === '' ? '' : rel;
}
/**
* A `paths` target rebased to repo-relative, keeping any trailing `*`.
*
* `path.resolve` swallows the wildcard into a path segment, so it is stripped
* before resolving and re-appended after — the `*` is a substitution marker,
* not a directory named `*`.
*/
function rebaseTarget(repoRoot: string, absTarget: string): string {
// `/repo/src/*` must come back as `src/*`, not `src*`: stripping only the
// star leaves a trailing slash that `path.relative` then eats.
const suffix = absTarget.endsWith('/*') ? '/*' : absTarget.endsWith('*') ? '*' : '';
const base = suffix === '' ? absTarget : absTarget.slice(0, -suffix.length);
return `${repoRelative(repoRoot, base)}${suffix}`;
}

View file

@ -1,60 +1,23 @@
/**
* Import-target resolver for Vue SFCs (RFC #909 Ring 3, issue #940).
*
* Vue `<script>` / `<script setup>` blocks are TypeScript (or plain
* JavaScript), so the resolver delegates to `resolveTsTarget` with
* `language: SupportedLanguages.TypeScript` to get:
*
* - tsconfig path-alias rewriting (Vue projects universally use TS)
* - `.ts` / `.tsx` / `.js` / `.jsx` extension-suffix fallback
*
* `.vue` imports are written with explicit extensions (`'./Button.vue'`),
* so no Vue-specific suffix guessing is required: the standard
* resolver finds them via the exact-path branch before any extension
* logic fires.
*
* Memoization mirrors the TypeScript adapter: workspace file-list
* arrays, the suffix index and the per-pass resolve cache are built
* once per `allFilePaths` Set and memoized on that Set's identity.
* `<script>` / `<script setup>` blocks are TypeScript or plain JavaScript, so
* resolution is TypeScript's: the same tsconfig `paths`/`baseUrl` rules and the
* same `package.json` manifests. `.vue` imports are written with an explicit
* extension (`'./Button.vue'`) and `.vue` is in the candidate list, so no
* Vue-specific guessing is required — the exact-path branch finds them first.
*/
import { SupportedLanguages } from 'gitnexus-shared';
import { resolveTsTarget, type TsResolveContext } from '../typescript/import-target.js';
import { buildImportPassCache } from '../../import-resolvers/pass-cache.js';
import { perFileSet } from '../../import-resolvers/per-file-set.js';
import type { TsconfigPaths } from '../../language-config.js';
import type { NodeWorkspacePackages } from '../../import-resolvers/node-workspace-packages.js';
import { resolveTsTarget } from '../typescript/import-target.js';
import type { TsconfigIndex } from '../typescript/tsconfig.js';
interface VueResolutionConfig {
readonly tsconfigPaths: TsconfigPaths | null;
readonly tsconfigs?: TsconfigIndex | null;
readonly nodeWorkspacePackages?: NodeWorkspacePackages | null;
}
/**
* Memoized on the `allFilePaths` Set identity, like every other language's
* import index (`import-resolvers/workspace-file-index.ts` and friends).
*
* This used to be a single-slot `let cached` invalidated by
* `cached.key !== allFilePaths` — correct for one file set and degenerate for
* two: alternating calls across two sets rebuilt everything every time.
* Measured on the identical TypeScript adapter at 4000 files × 400 imports:
* 12.0 ms for one set, 1438.2 ms alternating between two (120x). A `WeakMap`
* has no such state to thrash, and it is what lets this adapter carry the
* standard
* `expectDistinctFileSetsGetOwnIndex` guard the other languages carry
* (`test/integration/vue-import-index-reuse.test.ts`).
*
* The Set must be passed THROUGH by the caller, never copied: a defensive
* `new Set(allFilePaths)` at the adapter boundary hands a fresh key per import
* and restores the per-import rebuild (PR #1918 review P1).
*/
const passCacheFor = perFileSet(buildImportPassCache);
/**
* Build a memoized `resolveImportTarget` adapter for Vue SFCs.
*
* Uses `SupportedLanguages.TypeScript` so tsconfig path-alias resolution
* and `.ts`/`.tsx` extension guessing fire for relative and bare-specifier
* imports inside `<script>` blocks.
*/
/** Build the Vue `resolveImportTarget` adapter. */
export function makeVueResolveImportTarget(): (
targetRaw: string,
fromFile: string,
@ -62,19 +25,12 @@ export function makeVueResolveImportTarget(): (
resolutionConfig?: unknown,
) => string | readonly string[] | null {
return (targetRaw, fromFile, allFilePaths, resolutionConfig) => {
const cached = passCacheFor(allFilePaths);
const cfg = resolutionConfig as VueResolutionConfig | undefined;
const ws: TsResolveContext = {
return resolveTsTarget(targetRaw, {
fromFile,
language: SupportedLanguages.TypeScript,
allFilePaths: cached.allFilePaths,
allFileList: cached.allFileList,
normalizedFileList: cached.normalizedFileList,
index: cached.index,
resolveCache: cached.resolveCache,
tsconfigPaths: cfg?.tsconfigPaths ?? null,
};
return resolveTsTarget(targetRaw, ws);
allFilePaths,
tsconfigs: cfg?.tsconfigs ?? null,
nodeWorkspacePackages: cfg?.nodeWorkspacePackages ?? null,
});
};
}

View file

@ -59,7 +59,8 @@ import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import { simpleKey } from '../../scope-resolution/graph-bridge/node-lookup.js';
import { vueProvider } from '../vue.js';
import { loadTsconfigPaths } from '../../language-config.js';
import { loadTsconfigIndex } from '../typescript/tsconfig.js';
import { loadNodeWorkspacePackages } from '../../import-resolvers/node-workspace-packages.js';
import { typescriptArityCompatibility, typescriptMergeBindings } from '../typescript/index.js';
import { makeVueResolveImportTarget } from './import-target.js';
import { extractVueTemplateEdgeData } from '../../vue-sfc-extractor.js';
@ -87,7 +88,8 @@ const vueScopeResolver: ScopeResolver = {
// Vue projects universally use TypeScript — load tsconfig so path
// aliases (`@/`, `~/`, `#/`) resolve through the standard branch.
loadResolutionConfig: async (repoPath: string) => ({
tsconfigPaths: await loadTsconfigPaths(repoPath),
tsconfigs: await loadTsconfigIndex(repoPath),
nodeWorkspacePackages: await loadNodeWorkspacePackages(repoPath),
}),
// TypeScript LEGB semantics apply inside `<script>` / `<script setup>`.

View file

@ -0,0 +1,4 @@
{
"name": "@repo/web",
"dependencies": { "@acme/telemetry": "0.5.0", "@repo/utils": "workspace:*" }
}

View file

@ -0,0 +1,5 @@
import { Button } from '@ui/Button';
export function render(): string {
return Button();
}

View file

@ -0,0 +1,5 @@
import { format } from 'utils/format';
export function run(): string {
return format(' x ');
}

View file

@ -0,0 +1,3 @@
export function Button(): string {
return 'button';
}

View file

@ -0,0 +1,5 @@
import { help } from 'shared/helper';
export function useHelp(): string {
return help();
}

View file

@ -0,0 +1,5 @@
import { init } from '@acme/telemetry/nest';
export function boot(): void {
init();
}

View file

@ -0,0 +1,5 @@
import { excludedThing } from '@repo/excluded';
export function callExcluded(): string {
return excludedThing();
}

View file

@ -0,0 +1,5 @@
import { doThing } from '@repo/utils';
export function run(): string {
return doThing();
}

View file

@ -0,0 +1,3 @@
export function format(value: string): string {
return value.trim();
}

View file

@ -0,0 +1,8 @@
{
"compilerOptions": {
"baseUrl": "src",
"paths": {
"@ui/*": ["components/*"]
}
}
}

View file

@ -0,0 +1 @@
{ "name": "@repo/excluded", "main": "src/index.ts" }

View file

@ -0,0 +1,3 @@
export function excludedThing(): string {
return 'excluded';
}

View file

@ -0,0 +1 @@
{ "name": "workspace-root", "private": true }

View file

@ -0,0 +1 @@
{ "name": "@repo/inner", "main": "src/index.ts" }

View file

@ -0,0 +1,3 @@
export function nested(): string {
return 'inner';
}

View file

@ -0,0 +1,3 @@
export function help(): string {
return 'helped';
}

View file

@ -0,0 +1 @@
{ "name": "@repo/utils", "main": "src/index.ts" }

View file

@ -0,0 +1,3 @@
export function doThing(): string {
return 'done';
}

View file

@ -0,0 +1,3 @@
packages:
- "apps/*"
- "packages/*"

View file

@ -1,175 +0,0 @@
/**
* Production-path regression guard for the JavaScript import-resolution index
* (#2910).
*
* `makeJsResolveImportTarget`'s `PassCache` was the TypeScript one minus its
* `index` field, so every JavaScript import reached `suffixResolve` with
* `index === undefined` and took the linear-`findIndex` fallback: one pass over
* `normalizedFileList` per path part per extension, ~39 extensions. 6448.9 µs
* per import at 2000 files and 25972.6 µs at 8000 — 4.12x the per-import cost
* for 4x the files, which is O(imports × files) — against 25.0 / 27.0 µs for
* TypeScript over the identical corpus. With the index it is 28.5 / 27.4 µs and
* the scaling factor is 1.09x.
*
* ## Why the existing guards were blind to it
*
* `CountingSet` counts traversals of the SET, and this scan walked the array
* the adapter had already materialized from it (`test/helpers/counting-file-set.ts`
* says so under "What it does NOT see"). The pass cache was reused correctly,
* so the traversal count read 2 with the defect and reads 2 without it — the
* sixteen-language contract test scored `javascript` a clean pass throughout.
*
* So the arm that would have caught this is not a count of Set traversals but
* `resolves a repo-root module by bare specifier` below: without an index a
* repo-root file is unreachable through this leg, because the scan tests
* `endsWith('/' + suffix)` and a root-level path has no `/`. It is a behaviour
* assertion, it is deterministic, and it fails the moment `index` leaves the
* cache. The direct instrument — counting entries into `suffixResolve`'s linear
* branch, with the pre-index adapter as its control — lives beside the
* differential in `test/unit/scope-resolution/javascript-import-target-parity.test.ts`.
*
* ## What the traversal counts here do guard
*
* Resolution reaches the cache through `javascriptScopeResolver.resolveImportTarget`
* — the orchestrator adapter — which must pass the Set THROUGH: a defensive
* `new Set(allFilePaths)` hands a fresh `WeakMap` key per import and restores
* the per-import rebuild (PR #1918 review P1). Two traversals per file set, not
* one: the adapter materializes `allFileList` and then keeps one mutable copy
* of the Set, because `TsResolveContext.allFilePaths` is a `Set`, not a
* `ReadonlySet`.
*
* The counts are paired with result assertions on purpose: a count of 2 is
* equally true of an adapter that has stopped resolving anything at all.
*/
import { describe, it, expect } from 'vitest';
import { javascriptScopeResolver } from '../../src/core/ingestion/languages/javascript/scope-resolver.js';
import { CountingSet, expectDistinctFileSetsGetOwnIndex } from '../helpers/counting-file-set.js';
const { resolveImportTarget } = javascriptScopeResolver;
const FROM_FILE = 'src/main.js';
/**
* A synthetic CommonJS/ESM app covering the legs the resolver takes: a relative
* import answered by exact `Set.has`, a bare specifier answered by path suffix,
* a `node_modules` package, a directory `index.js`, and `config.js` at the repo
* root — the one shape only the index can reach.
*/
function buildWorkspace(fileCount: number): CountingSet {
const files: string[] = [];
for (let i = 0; i < fileCount; i++) {
files.push(`src/components/Widget${String(i).padStart(5, '0')}.js`);
}
files.push('src/util.js');
files.push('src/models/index.js');
files.push('lib/esm.mjs');
files.push('node_modules/dep/index.js');
files.push('config.js');
files.push('bootstrap.cjs');
files.push(FROM_FILE);
return new CountingSet(files);
}
describe('JavaScript import resolution — index reuse across imports (#2910)', () => {
it('builds the pass cache once for many imports over a stable file set', () => {
const files = buildWorkspace(300);
const resolved: (string | readonly string[] | null)[] = [];
for (let i = 0; i < 200; i++) {
// A relative hit, a bare-specifier suffix hit, a repo-root hit, and a
// bare specifier that misses. The miss is the expensive case: it runs
// every path part × every extension before returning null, which is the
// loop that used to scan the whole file list each time round.
resolved.push(resolveImportTarget('./util', FROM_FILE, files, undefined));
resolved.push(resolveImportTarget('src/models', FROM_FILE, files, undefined));
resolved.push(resolveImportTarget('config', FROM_FILE, files, undefined));
resolved.push(resolveImportTarget(`@vendor/ghost${i}/deep`, FROM_FILE, files, undefined));
}
// Two: `Array.from(allFilePaths)` and the one mutable `Set` copy the
// resolver context requires. Both happen once per file set.
expect(files.scans).toBe(2);
// Paired result assertions — a count of 2 must not be the count of an
// adapter that resolves nothing.
expect(resolved[0]).toBe('src/util.js');
expect(resolved[1]).toBe('src/models/index.js');
expect(resolved[2]).toBe('config.js');
expect(resolved[3]).toBeNull();
});
it('a distinct file set gets its own index (no stale cross-run reuse)', () => {
expectDistinctFileSetsGetOwnIndex({
resolveImportTarget,
buildWorkspace: () => buildWorkspace(20),
targetRaw: 'src/models',
fromFile: FROM_FILE,
resolutionConfig: undefined,
expected: 'src/models/index.js',
expectedScans: 2,
});
});
/**
* The per-file-set index and `resolveCache` must not become global. The arm
* above cannot see that: `expectDistinctFileSetsGetOwnIndex` builds two
* IDENTICAL corpora, so a stale answer carried across them is also the right
* answer, and only its traversal counts would notice. These two workspaces
* answer the same specifier differently, and they are resolved alternately.
*/
it('two different workspaces answer the same specifier differently', () => {
const a = new Set(['src/util.js', FROM_FILE]);
const b = new Set(['vendor/util.js', FROM_FILE]);
expect(resolveImportTarget('util', FROM_FILE, a, undefined)).toBe('src/util.js');
expect(resolveImportTarget('util', FROM_FILE, b, undefined)).toBe('vendor/util.js');
expect(resolveImportTarget('util', FROM_FILE, a, undefined)).toBe('src/util.js');
expect(resolveImportTarget('util', FROM_FILE, b, undefined)).toBe('vendor/util.js');
});
/**
* The arm that fails without the suffix index, and the reason it is here
* rather than in the counting arms above: a repo-root file has no `/`, so
* `suffixResolve`'s scan — which tests `endsWith('/' + suffix)` — can never
* match it, while `buildSuffixIndex` indexes the whole path and can.
* Dropping `index` from the pass cache turns every one of these back to null
* while leaving `files.scans` at 2.
*/
it('resolves a repo-root module by bare specifier — impossible without the index', () => {
const files = buildWorkspace(5);
expect(resolveImportTarget('config', FROM_FILE, files, undefined)).toBe('config.js');
expect(resolveImportTarget('bootstrap', FROM_FILE, files, undefined)).toBe('bootstrap.cjs');
// Not `'config.js'`, and that is unchanged by the index: a specifier with
// no `/` has its dots turned into slashes before the suffix cascade
// (`resolveImportPath`), so `config.js` is looked up as `config/js`.
// TypeScript answers null here too — it is the same code path.
expect(resolveImportTarget('config.js', FROM_FILE, files, undefined)).toBeNull();
});
it('still resolves real imports correctly (the perf test is not vacuous)', () => {
const files = buildWorkspace(5);
// Relative, with and without an extension.
expect(resolveImportTarget('./util', FROM_FILE, files, undefined)).toBe('src/util.js');
expect(resolveImportTarget('./util.js', FROM_FILE, files, undefined)).toBe('src/util.js');
// Directory index.
expect(resolveImportTarget('./models', FROM_FILE, files, undefined)).toBe(
'src/models/index.js',
);
// Bare specifier resolved by path suffix, and an ESM extension.
expect(resolveImportTarget('components/Widget00000', FROM_FILE, files, undefined)).toBe(
'src/components/Widget00000.js',
);
expect(resolveImportTarget('lib/esm', FROM_FILE, files, undefined)).toBe('lib/esm.mjs');
// A package in node_modules.
expect(resolveImportTarget('dep', FROM_FILE, files, undefined)).toBe(
'node_modules/dep/index.js',
);
// Nothing in the repo answers these.
expect(resolveImportTarget('./nowhere', FROM_FILE, files, undefined)).toBeNull();
expect(resolveImportTarget('@vendor/ghost/deep', FROM_FILE, files, undefined)).toBeNull();
});
});

View file

@ -0,0 +1,107 @@
/**
* TypeScript bare-specifier resolution in a pnpm workspace monorepo (#2953).
*
* Two failures with one cause — the resolver knew nothing about `package.json`:
* 1. `@acme/telemetry/nest` (a registry dependency) basename-matched the
* repo's only `**\/nest/index.ts` and emitted an `IMPORTS` edge for it.
* 2. `@repo/utils` (a workspace package, named only in its manifest) resolved
* to nothing, so no cross-package `IMPORTS`/`CALLS` edges existed.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js';
describe('TypeScript workspace-package imports (#2953)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'typescript-pnpm-workspace-imports'),
() => {},
);
}, 60000);
it('does not resolve the external @acme/telemetry/nest to packages/inner/src/nest/index.ts', () => {
const imports = getRelationships(result, 'IMPORTS');
const falseEdge = imports.find(
(e) =>
e.sourceFilePath === 'apps/web/src/main.ts' &&
e.targetFilePath === 'packages/inner/src/nest/index.ts',
);
expect(falseEdge).toBeUndefined();
});
it('emits no in-repo IMPORTS edge at all from the file whose only import is external', () => {
const imports = getRelationships(result, 'IMPORTS');
const fromMain = imports.filter((e) => e.sourceFilePath === 'apps/web/src/main.ts');
expect(fromMain).toEqual([]);
});
it('resolves the workspace package @repo/utils to its entry point', () => {
const imports = getRelationships(result, 'IMPORTS');
const workspaceEdge = imports.find(
(e) =>
e.sourceFilePath === 'apps/web/src/use.ts' &&
e.targetFilePath === 'packages/utils/src/index.ts',
);
expect(workspaceEdge).toBeDefined();
});
it('emits a cross-package CALLS edge into the workspace package', () => {
const calls = getRelationships(result, 'CALLS');
const crossPackage = calls.filter(
(e) =>
e.sourceFilePath.startsWith('apps/') &&
e.targetFilePath.startsWith('packages/utils/') &&
e.target === 'doThing',
);
expect(crossPackage.length).toBeGreaterThan(0);
});
it('resolves a baseUrl-relative specifier through the package tsconfig', () => {
// `apps/web/tsconfig.json` declares `baseUrl: "src"`, which is what makes
// `import 'utils/format'` legal. The old resolver reached the same file by
// suffix-matching and would have reached it just as happily with no
// tsconfig at all; this one resolves it because the project says so.
const imports = getRelationships(result, 'IMPORTS');
const edge = imports.find(
(e) =>
e.sourceFilePath === 'apps/web/src/baseurl.ts' &&
e.targetFilePath === 'apps/web/src/utils/format.ts',
);
expect(edge).toBeDefined();
});
it('resolves a tsconfig paths alias', () => {
const imports = getRelationships(result, 'IMPORTS');
const edge = imports.find(
(e) =>
e.sourceFilePath === 'apps/web/src/alias.ts' &&
e.targetFilePath === 'apps/web/src/components/Button.ts',
);
expect(edge).toBeDefined();
});
it('resolves nothing for a package outside the declared workspace globs', () => {
// `examples/excluded/package.json` declares `@repo/excluded`, but
// `pnpm-workspace.yaml` admits only `apps/*` and `packages/*`. A manifest
// the workspace never admitted is not a workspace package — treating every
// `package.json` found on disk as one recreates the false-positive half of
// #2953 from a different source, since an excluded fixture or example that
// happens to reuse a registry name would capture that name's imports.
const imports = getRelationships(result, 'IMPORTS');
const edge = imports.find((e) => e.sourceFilePath === 'apps/web/src/outside.ts');
expect(edge).toBeUndefined();
});
it('resolves nothing for a specifier no config declares', () => {
// `shared/helper` is not relative, not a package, and not reachable through
// this project's `baseUrl` (`apps/web/src/shared/helper` does not exist).
// The old resolver answered `packages/inner/src/shared/helper.ts` anyway,
// by dropping leading segments until a path suffix matched. Nothing
// declares that edge, so there is no edge.
const imports = getRelationships(result, 'IMPORTS');
const guessed = imports.filter((e) => e.sourceFilePath === 'apps/web/src/guess.ts');
expect(guessed).toEqual([]);
});
});

View file

@ -1,184 +0,0 @@
/**
* Production-path regression guard for the TypeScript import-resolution pass
* cache (#2910).
*
* `makeTsResolveImportTarget` has carried a `SuffixIndex` since #1918, so the
* per-import rebuild this file's siblings were written for never applied here.
* What did apply is the OTHER failure mode of the memo it used: a single slot,
* invalidated by `cached.key !== allFilePaths`. One file set is memoized
* perfectly; two alternating file sets rebuild the arrays, the index and the
* `resolveCache` on every single call. Measured at 4000 files × 400 imports:
* 12.0 ms for one set, 1438.2 ms alternating between two — 120x, and the same
* O(imports × files) shape the per-file-set index hoists removed.
*
* That is why this adapter could not carry `expectDistinctFileSetsGetOwnIndex`,
* the one arm every other language's guard has: the arm alternates two sets by
* construction, and the single-slot cache posts 42 traversals against the 2 it
* posts now. The cache is a `WeakMap<ReadonlySet<string>, PassCache>` keyed on
* the Set, like every other language's index, and the arm below is the proof.
*
* Whether the thrash was reachable in production: `pipeline/run.ts` builds one
* `allFilePaths` Set per provider pass and TypeScript, JavaScript and Vue are
* separate providers with separate caches, so within one analyze it was latent
* rather than live. It was one refactor — an interleaved or re-entrant pass, a
* second workspace, a caller resolving against a filtered file set — away from
* live, and the `WeakMap` is strictly simpler than the slot it replaces.
*
* Resolution goes through `typescriptScopeResolver.resolveImportTarget`, the
* orchestrator adapter, which must pass the Set THROUGH: a defensive
* `new Set(allFilePaths)` hands a fresh `WeakMap` key per import and restores
* the per-import rebuild (PR #1918 review P1). Two traversals per file set, not
* one: the adapter materializes `allFileList` and then keeps one mutable copy
* of the Set, because `TsResolveContext.allFilePaths` is a `Set`, not a
* `ReadonlySet`.
*
* The counts are paired with result assertions on purpose: a count of 2 is
* equally true of an adapter that has stopped resolving anything at all.
*/
import { describe, it, expect } from 'vitest';
import { typescriptScopeResolver } from '../../src/core/ingestion/languages/typescript/scope-resolver.js';
import type { TsconfigPaths } from '../../src/core/ingestion/language-config.js';
import { CountingSet, expectDistinctFileSetsGetOwnIndex } from '../helpers/counting-file-set.js';
const { resolveImportTarget } = typescriptScopeResolver;
const FROM_FILE = 'src/main.ts';
/** What `loadTsconfigPaths` produces for `"@/*": ["src/*"]` under `baseUrl: "."`. */
const TSCONFIG_PATHS: TsconfigPaths = { aliases: new Map([['@/', 'src/']]), baseUrl: '.' };
/** The shape `loadResolutionConfig` returns for a non-Nuxt TypeScript repo. */
const RESOLUTION_CONFIG = { tsconfigPaths: TSCONFIG_PATHS, nuxtAutoImports: null };
/**
* A synthetic TypeScript app covering the legs the resolver takes: a relative
* import answered by exact `Set.has`, an ESM `.js` specifier that must strip to
* `.ts`, a bare specifier answered by path suffix, a directory `index.ts`, and
* a `@/`-aliased path.
*/
function buildWorkspace(fileCount: number): CountingSet {
const files: string[] = [];
for (let i = 0; i < fileCount; i++) {
files.push(`src/services/Service${String(i).padStart(5, '0')}.ts`);
}
files.push('src/util.ts');
files.push('src/models/index.ts');
files.push('src/components/Widget.tsx');
files.push('node_modules/dep/index.d.ts');
files.push(FROM_FILE);
return new CountingSet(files);
}
describe('TypeScript import resolution — index reuse across imports (#2910)', () => {
it('builds the pass cache once for many imports over a stable file set', () => {
const files = buildWorkspace(300);
const resolved: (string | readonly string[] | null)[] = [];
for (let i = 0; i < 200; i++) {
// A relative hit, an ESM `.js` specifier stripped back to `.ts`, an
// aliased path, a bare-specifier suffix hit, and a bare specifier that
// misses — the expensive case, which runs every path part × every
// extension before returning null.
resolved.push(resolveImportTarget('./util', FROM_FILE, files, RESOLUTION_CONFIG));
resolved.push(resolveImportTarget('./util.js', FROM_FILE, files, RESOLUTION_CONFIG));
resolved.push(resolveImportTarget('@/models', FROM_FILE, files, RESOLUTION_CONFIG));
resolved.push(resolveImportTarget('components/Widget', FROM_FILE, files, RESOLUTION_CONFIG));
resolved.push(
resolveImportTarget(`@vendor/ghost${i}/deep`, FROM_FILE, files, RESOLUTION_CONFIG),
);
}
// Two: `Array.from(allFilePaths)` and the one mutable `Set` copy the
// resolver context requires. Both happen once per file set.
expect(files.scans).toBe(2);
// Paired result assertions — a count of 2 must not be the count of an
// adapter that resolves nothing.
expect(resolved[0]).toBe('src/util.ts');
expect(resolved[1]).toBe('src/util.ts');
expect(resolved[2]).toBe('src/models/index.ts');
expect(resolved[3]).toBe('src/components/Widget.tsx');
expect(resolved[4]).toBeNull();
});
/**
* The arm the single-slot cache could not pass. `expectDistinctFileSetsGetOwnIndex`
* alternates two file sets 20 times; the slot was invalidated on every one of
* those calls, so each set posted 42 traversals instead of 2.
*/
it('a distinct file set gets its own index (no stale cross-run reuse)', () => {
expectDistinctFileSetsGetOwnIndex({
resolveImportTarget,
buildWorkspace: () => buildWorkspace(20),
targetRaw: '@/models',
fromFile: FROM_FILE,
resolutionConfig: RESOLUTION_CONFIG,
expected: 'src/models/index.ts',
expectedScans: 2,
});
});
/**
* The per-file-set index and `resolveCache` must not become global. The arm
* above cannot see that: `expectDistinctFileSetsGetOwnIndex` builds two
* IDENTICAL corpora, so a stale answer carried across them is also the right
* answer, and only its traversal counts would notice. These two workspaces
* answer the same specifier differently, and they are resolved alternately.
*/
it('two different workspaces answer the same specifier differently', () => {
const a = new Set(['src/util.ts', FROM_FILE]);
const b = new Set(['vendor/util.ts', FROM_FILE]);
expect(resolveImportTarget('util', FROM_FILE, a, RESOLUTION_CONFIG)).toBe('src/util.ts');
expect(resolveImportTarget('util', FROM_FILE, b, RESOLUTION_CONFIG)).toBe('vendor/util.ts');
expect(resolveImportTarget('util', FROM_FILE, a, RESOLUTION_CONFIG)).toBe('src/util.ts');
expect(resolveImportTarget('util', FROM_FILE, b, RESOLUTION_CONFIG)).toBe('vendor/util.ts');
});
it('still resolves real imports correctly (the perf test is not vacuous)', () => {
const files = buildWorkspace(5);
// Relative, extensionless and with the ESM `.js` spelling.
expect(resolveImportTarget('./util', FROM_FILE, files, RESOLUTION_CONFIG)).toBe('src/util.ts');
expect(resolveImportTarget('./util.js', FROM_FILE, files, RESOLUTION_CONFIG)).toBe(
'src/util.ts',
);
// Directory index.
expect(resolveImportTarget('./models', FROM_FILE, files, RESOLUTION_CONFIG)).toBe(
'src/models/index.ts',
);
// tsconfig alias.
expect(
resolveImportTarget('@/services/Service00000', FROM_FILE, files, RESOLUTION_CONFIG),
).toBe('src/services/Service00000.ts');
// Bare specifier resolved by path suffix.
expect(resolveImportTarget('components/Widget', FROM_FILE, files, RESOLUTION_CONFIG)).toBe(
'src/components/Widget.tsx',
);
// Nothing in the repo answers these.
expect(resolveImportTarget('./nowhere', FROM_FILE, files, RESOLUTION_CONFIG)).toBeNull();
expect(
resolveImportTarget('@vendor/ghost/deep', FROM_FILE, files, RESOLUTION_CONFIG),
).toBeNull();
});
/**
* No `tsconfig.json` at all, which is the config the orchestrator threads for
* a repo without one. It skips the alias branch entirely and leaves the
* suffix cascade as the only path to the index.
*/
it('builds the pass cache once with no tsconfig paths', () => {
const files = buildWorkspace(300);
const resolved: (string | readonly string[] | null)[] = [];
for (let i = 0; i < 200; i++) {
resolved.push(resolveImportTarget('components/Widget', FROM_FILE, files, undefined));
resolved.push(resolveImportTarget(`@vendor/ghost${i}/deep`, FROM_FILE, files, undefined));
}
expect(files.scans).toBe(2);
expect(resolved[0]).toBe('src/components/Widget.tsx');
expect(resolved[1]).toBeNull();
});
});

View file

@ -1,168 +0,0 @@
/**
* Production-path regression guard for the Vue import-resolution pass cache
* (#2910).
*
* `makeVueResolveImportTarget` is the TypeScript adapter with the language
* pinned to TypeScript, and it inherited the same memo: a single slot,
* invalidated by `cached.key !== allFilePaths`. One file set is memoized
* perfectly; two alternating file sets rebuild the arrays, the suffix index and
* the `resolveCache` on every call. Measured on the identical TypeScript
* adapter at 4000 files × 400 imports: 12.0 ms for one set, 1438.2 ms
* alternating between two — 120x, and the same O(imports × files) shape the
* per-file-set index hoists removed.
*
* That is why this adapter could not carry `expectDistinctFileSetsGetOwnIndex`,
* the one arm every other language's guard has: the arm alternates two sets by
* construction, and the single-slot cache posts 42 traversals against the 2 it
* posts now. The cache is a `WeakMap<ReadonlySet<string>, PassCache>` keyed on
* the Set, like every other language's index, and the arm below is the proof.
*
* Whether the thrash was reachable in production: `pipeline/run.ts` builds one
* `allFilePaths` Set per provider pass and Vue, TypeScript and JavaScript are
* separate providers with separate caches, so within one analyze it was latent
* rather than live — one refactor away from live, and the `WeakMap` is strictly
* simpler than the slot it replaces.
*
* Resolution goes through `vueScopeResolver.resolveImportTarget`, the
* orchestrator adapter, which must pass the Set THROUGH: a defensive
* `new Set(allFilePaths)` hands a fresh `WeakMap` key per import and restores
* the per-import rebuild (PR #1918 review P1). Two traversals per file set, not
* one: the adapter materializes `allFileList` and then keeps one mutable copy
* of the Set, because `TsResolveContext.allFilePaths` is a `Set`, not a
* `ReadonlySet`.
*
* The counts are paired with result assertions on purpose: a count of 2 is
* equally true of an adapter that has stopped resolving anything at all.
*/
import { describe, it, expect } from 'vitest';
import { vueScopeResolver } from '../../src/core/ingestion/languages/vue/scope-resolver.js';
import type { TsconfigPaths } from '../../src/core/ingestion/language-config.js';
import { CountingSet, expectDistinctFileSetsGetOwnIndex } from '../helpers/counting-file-set.js';
const { resolveImportTarget } = vueScopeResolver;
const FROM_FILE = 'src/App.vue';
/** What `loadTsconfigPaths` produces for `"@/*": ["src/*"]` under `baseUrl: "."`. */
const TSCONFIG_PATHS: TsconfigPaths = { aliases: new Map([['@/', 'src/']]), baseUrl: '.' };
/** The shape `loadResolutionConfig` returns for a Vue repo with a tsconfig. */
const RESOLUTION_CONFIG = { tsconfigPaths: TSCONFIG_PATHS };
/**
* A synthetic Vue SFC project: many components, a `.ts` composable reached by
* bare specifier, a `@/`-aliased path, and a directory `index.ts`. `.vue`
* imports carry their extension, so they land on the exact-path branch.
*/
function buildWorkspace(fileCount: number): CountingSet {
const files: string[] = [];
for (let i = 0; i < fileCount; i++) {
files.push(`src/components/Widget${String(i).padStart(5, '0')}.vue`);
}
files.push('src/composables/useUser.ts');
files.push('src/stores/index.ts');
files.push('src/util.ts');
files.push(FROM_FILE);
return new CountingSet(files);
}
describe('Vue import resolution — index reuse across imports (#2910)', () => {
it('builds the pass cache once for many imports over a stable file set', () => {
const files = buildWorkspace(300);
const resolved: (string | readonly string[] | null)[] = [];
for (let i = 0; i < 200; i++) {
// A relative `.vue` hit, a relative `.ts` composable, an aliased path, a
// bare-specifier suffix hit, and a bare specifier that misses — the
// expensive case, which runs every path part × every extension before
// returning null.
resolved.push(
resolveImportTarget('./components/Widget00000.vue', FROM_FILE, files, RESOLUTION_CONFIG),
);
resolved.push(
resolveImportTarget('./composables/useUser', FROM_FILE, files, RESOLUTION_CONFIG),
);
resolved.push(resolveImportTarget('@/stores', FROM_FILE, files, RESOLUTION_CONFIG));
resolved.push(
resolveImportTarget('composables/useUser', FROM_FILE, files, RESOLUTION_CONFIG),
);
resolved.push(
resolveImportTarget(`@vendor/ghost${i}/deep`, FROM_FILE, files, RESOLUTION_CONFIG),
);
}
// Two: `Array.from(allFilePaths)` and the one mutable `Set` copy the
// resolver context requires. Both happen once per file set.
expect(files.scans).toBe(2);
// Paired result assertions — a count of 2 must not be the count of an
// adapter that resolves nothing.
expect(resolved[0]).toBe('src/components/Widget00000.vue');
expect(resolved[1]).toBe('src/composables/useUser.ts');
expect(resolved[2]).toBe('src/stores/index.ts');
expect(resolved[3]).toBe('src/composables/useUser.ts');
expect(resolved[4]).toBeNull();
});
/**
* The arm the single-slot cache could not pass. `expectDistinctFileSetsGetOwnIndex`
* alternates two file sets 20 times; the slot was invalidated on every one of
* those calls, so each set posted 42 traversals instead of 2.
*/
it('a distinct file set gets its own index (no stale cross-run reuse)', () => {
expectDistinctFileSetsGetOwnIndex({
resolveImportTarget,
buildWorkspace: () => buildWorkspace(20),
targetRaw: '@/stores',
fromFile: FROM_FILE,
resolutionConfig: RESOLUTION_CONFIG,
expected: 'src/stores/index.ts',
expectedScans: 2,
});
});
/**
* The per-file-set index and `resolveCache` must not become global. The arm
* above cannot see that: `expectDistinctFileSetsGetOwnIndex` builds two
* IDENTICAL corpora, so a stale answer carried across them is also the right
* answer, and only its traversal counts would notice. These two workspaces
* answer the same specifier differently, and they are resolved alternately.
*/
it('two different workspaces answer the same specifier differently', () => {
const a = new Set(['src/util.ts', FROM_FILE]);
const b = new Set(['vendor/util.ts', FROM_FILE]);
expect(resolveImportTarget('util', FROM_FILE, a, RESOLUTION_CONFIG)).toBe('src/util.ts');
expect(resolveImportTarget('util', FROM_FILE, b, RESOLUTION_CONFIG)).toBe('vendor/util.ts');
expect(resolveImportTarget('util', FROM_FILE, a, RESOLUTION_CONFIG)).toBe('src/util.ts');
expect(resolveImportTarget('util', FROM_FILE, b, RESOLUTION_CONFIG)).toBe('vendor/util.ts');
});
it('still resolves real SFC imports correctly (the perf test is not vacuous)', () => {
const files = buildWorkspace(5);
// `.vue` imports are written with their extension and hit the exact-path
// branch.
expect(
resolveImportTarget('./components/Widget00000.vue', FROM_FILE, files, RESOLUTION_CONFIG),
).toBe('src/components/Widget00000.vue');
// A composable, relative and extensionless.
expect(resolveImportTarget('./composables/useUser', FROM_FILE, files, RESOLUTION_CONFIG)).toBe(
'src/composables/useUser.ts',
);
// Directory index behind a tsconfig alias.
expect(resolveImportTarget('@/stores', FROM_FILE, files, RESOLUTION_CONFIG)).toBe(
'src/stores/index.ts',
);
// Bare specifier resolved by path suffix.
expect(
resolveImportTarget('components/Widget00000.vue', FROM_FILE, files, RESOLUTION_CONFIG),
).toBe('src/components/Widget00000.vue');
// Nothing in the repo answers these.
expect(resolveImportTarget('./Missing.vue', FROM_FILE, files, RESOLUTION_CONFIG)).toBeNull();
expect(
resolveImportTarget('@vendor/ghost/deep', FROM_FILE, files, RESOLUTION_CONFIG),
).toBeNull();
});
});

View file

@ -0,0 +1,341 @@
/**
* TypeScript/JavaScript module resolution — the declared-input rules (#2953).
*
* The property under test is one sentence: a specifier resolves when something
* in the repo DECLARES it, and not otherwise. Each arm names the declaration
* doing the work — a real path, a tsconfig mapping, a package manifest — and
* the negative arms are the ones the old suffix matcher got wrong, so they are
* paired with a positive arm resolving the same specifier to the same file once
* a declaration exists.
*/
import { describe, it, expect } from 'vitest';
import {
nodePackageNameOf,
substituteStar,
type NodeWorkspacePackages,
} from '../../src/core/ingestion/import-resolvers/node-workspace-packages.js';
import { resolveTsModule } from '../../src/core/ingestion/languages/typescript/module-resolution.js';
import type { TsconfigIndex } from '../../src/core/ingestion/languages/typescript/tsconfig.js';
const FILES = new Set([
'apps/web/src/main.ts',
'apps/web/src/utils/foo.ts',
'packages/inner/src/nest/index.ts',
'packages/inner/src/secret.ts',
'packages/utils/src/index.ts',
'packages/utils/src/deep/thing.ts',
]);
const PACKAGES: NodeWorkspacePackages = {
byName: new Map([
[
'@repo/utils',
{
dir: 'packages/utils',
entries: ['packages/utils/src/index'],
subpathExports: new Map(),
subpathImports: new Map(),
},
],
[
'@repo/inner',
{
dir: 'packages/inner',
entries: ['packages/inner/src/index'],
// An `exports` map is a restriction: `nest` is public, `secret` is not.
subpathExports: new Map([['nest', ['packages/inner/src/nest']]]),
subpathImports: new Map([['#hidden', ['packages/inner/src/secret']]]),
},
],
]),
};
function resolve(
specifier: string,
opts: {
from?: string;
tsconfigs?: TsconfigIndex | null;
packages?: NodeWorkspacePackages | null;
} = {},
): string | null {
return resolveTsModule(specifier, {
fromFile: opts.from ?? 'apps/web/src/main.ts',
allFilePaths: FILES,
tsconfigs: opts.tsconfigs ?? null,
workspacePackages: opts.packages === undefined ? PACKAGES : opts.packages,
});
}
const BASE_URL_SRC: TsconfigIndex = {
scopes: [{ dir: 'apps/web', baseUrl: 'apps/web/src', paths: [] }],
};
describe('nodePackageNameOf', () => {
it('takes two segments for a scoped specifier and one otherwise', () => {
expect(nodePackageNameOf('@acme/telemetry/nest')).toBe('@acme/telemetry');
expect(nodePackageNameOf('@repo/utils')).toBe('@repo/utils');
expect(nodePackageNameOf('lodash/fp')).toBe('lodash');
expect(nodePackageNameOf('utils/foo')).toBe('utils');
});
it('returns null for specifiers that name a path or a package-internal import', () => {
expect(nodePackageNameOf('./sibling')).toBeNull();
expect(nodePackageNameOf('../up')).toBeNull();
expect(nodePackageNameOf('/abs')).toBeNull();
expect(nodePackageNameOf('#hidden')).toBeNull();
expect(nodePackageNameOf('@scope-only')).toBeNull();
});
});
describe('relative specifiers', () => {
it('resolves by exact path, extension and directory index', () => {
expect(resolve('./utils/foo')).toBe('apps/web/src/utils/foo.ts');
expect(resolve('../../../packages/utils/src')).toBe('packages/utils/src/index.ts');
});
it('resolves the ESM `.js` spelling of a `.ts` source', () => {
expect(resolve('./utils/foo.js')).toBe('apps/web/src/utils/foo.ts');
});
it('resolves to nothing when the path does not exist', () => {
expect(resolve('./nowhere')).toBeNull();
});
});
describe('external packages (#2953 direction 1)', () => {
it('does not resolve a registry package into the repo', () => {
// The reported defect: `@acme/telemetry` is a registry dependency, and the
// repo's only path ending in `nest/index.ts` belongs to an unrelated
// package. Dropping leading segments found it; declared resolution does not.
expect(resolve('@acme/telemetry/nest')).toBeNull();
});
it('does not resolve a bare specifier that merely matches a path suffix', () => {
expect(resolve('utils/foo')).toBeNull();
expect(resolve('src/utils/foo')).toBeNull();
});
it('resolves that same specifier once a tsconfig baseUrl declares it', () => {
expect(resolve('utils/foo', { tsconfigs: BASE_URL_SRC })).toBe('apps/web/src/utils/foo.ts');
});
});
describe('relative traversal out of the repository', () => {
it('resolves nothing when a specifier climbs past the root', () => {
// Popping an empty segment list silently CLAMPS at the root, so
// `../../../secret` from `apps/web/src/main.ts` became `secret` and could
// resolve a repo-root file the specifier never named. Outside the repo
// there is nothing indexed, so the answer is nothing.
expect(resolve('../../../../../../etc/passwd')).toBeNull();
});
it('still resolves a traversal that lands exactly on the root', () => {
// The paired positive: climbing to the root is legal, only climbing PAST
// it is not, and the guard must not take the legal case with it.
expect(resolve('../../../packages/utils/src')).toBe('packages/utils/src/index.ts');
});
});
describe('tsconfig paths', () => {
const withPaths: TsconfigIndex = {
scopes: [
{
dir: '',
baseUrl: null,
paths: [
{ pattern: '@/*', targets: ['apps/web/src/*'] },
// A longer literal prefix must win over `@/*` even though it is
// declared second — tsc ranks by prefix length, not declaration order.
{ pattern: '@/utils/*', targets: ['packages/utils/src/*'] },
{ pattern: 'exact', targets: ['packages/utils/src/index'] },
],
},
],
};
it('substitutes the wildcard', () => {
expect(resolve('@/main', { tsconfigs: withPaths })).toBe('apps/web/src/main.ts');
});
it('prefers the longest matching pattern, not the first declared', () => {
expect(resolve('@/utils/deep/thing', { tsconfigs: withPaths })).toBe(
'packages/utils/src/deep/thing.ts',
);
});
it('supports a starless exact pattern', () => {
expect(resolve('exact', { tsconfigs: withPaths })).toBe('packages/utils/src/index.ts');
});
it('tries every target in order, not just the first', () => {
const twoTargets: TsconfigIndex = {
scopes: [
{
dir: '',
baseUrl: null,
// The first target names nothing; the old loader kept only this one.
paths: [{ pattern: '~/*', targets: ['generated/*', 'packages/utils/src/*'] }],
},
],
};
expect(resolve('~/index', { tsconfigs: twoTargets })).toBe('packages/utils/src/index.ts');
});
it('applies the nearest config, not the root one', () => {
const nested: TsconfigIndex = {
scopes: [
{ dir: 'apps/web', baseUrl: 'apps/web/src', paths: [] },
{ dir: '', baseUrl: 'packages/utils/src', paths: [] },
],
};
// `apps/web/src/main.ts` is governed by `apps/web`, whose baseUrl resolves
// `utils/foo`. The root config would have looked in `packages/utils/src`.
expect(resolve('utils/foo', { tsconfigs: nested })).toBe('apps/web/src/utils/foo.ts');
});
});
describe('workspace packages (#2953 direction 2)', () => {
it('resolves a package name to its manifest entry point', () => {
// The name appears in no file path, so nothing but the manifest can find it.
expect(resolve('@repo/utils')).toBe('packages/utils/src/index.ts');
});
it('resolves a subpath a package exports', () => {
expect(resolve('@repo/inner/nest')).toBe('packages/inner/src/nest/index.ts');
});
it('honours the restriction an `exports` map imposes', () => {
// `secret.ts` exists and its path would satisfy any suffix match, but the
// package does not export it. Node would refuse, and so does this.
expect(resolve('@repo/inner/secret')).toBeNull();
});
it('resolves a package-internal `#` import against the importing package', () => {
expect(resolve('#hidden', { from: 'packages/inner/src/nest/index.ts' })).toBe(
'packages/inner/src/secret.ts',
);
});
it('does not honour another package’s `#` imports', () => {
expect(resolve('#hidden', { from: 'apps/web/src/main.ts' })).toBeNull();
});
it('does not fall back into `src/` for an unexported subpath', () => {
// `@repo/utils` declares no `exports`, so Node resolves a subpath against
// the package DIRECTORY and only against it. An earlier draft also tried
// `<dir>/src/<subpath>` on the theory that a workspace package is consumed
// from source — but nothing declares that mapping, so it is the same kind
// of guess this module exists to remove, and the import it "resolves" is
// broken in the real project too.
expect(resolve('@repo/utils/deep/thing')).toBeNull();
});
it('resolves a `#imports` PATTERN key, not just an exact one', () => {
const patterned: NodeWorkspacePackages = {
byName: new Map([
[
'@repo/inner',
{
dir: 'packages/inner',
entries: ['packages/inner/src/index'],
subpathExports: new Map(),
subpathImports: new Map([['#internal/*', ['packages/inner/src/*']]]),
},
],
]),
};
expect(
resolveTsModule('#internal/secret', {
fromFile: 'packages/inner/src/nest/index.ts',
allFilePaths: FILES,
tsconfigs: null,
workspacePackages: patterned,
}),
).toBe('packages/inner/src/secret.ts');
});
it('honours an `exports` ARRAY as an ordered fallback list', () => {
// `["./dist/x.js", "./src/x.ts"]` is what a workspace package publishes to
// say "built output, or source". For a static analyser the source arm is
// the one that matters, because `dist/` is build output and is not indexed
// — and a build need not have run at all for the repo to be analysable.
const withArray: NodeWorkspacePackages = {
byName: new Map([
[
'@repo/inner',
{
dir: 'packages/inner',
entries: ['packages/inner/src/index'],
subpathExports: new Map([
['nest', ['packages/inner/dist/nest', 'packages/inner/src/nest']],
]),
subpathImports: new Map(),
},
],
]),
};
expect(
resolveTsModule('@repo/inner/nest', {
fromFile: 'apps/web/src/main.ts',
allFilePaths: FILES,
tsconfigs: null,
workspacePackages: withArray,
}),
).toBe('packages/inner/src/nest/index.ts');
});
it('refuses the package root when `exports` omits it', () => {
// `exports` is the package's ENTIRE public interface when present: Node
// ignores `main` and refuses anything the map does not list. The root is
// the same rule as a subpath, so a manifest exporting only `./nest` must
// not answer a bare `@repo/inner` — that import does not resolve in the
// real project, and an edge for it is a fabricated one.
const subpathOnly: NodeWorkspacePackages = {
byName: new Map([
[
'@repo/inner',
{
dir: 'packages/inner',
// What `readManifest` produces for `{"exports": {"./nest": …}}`:
// no root entry, and no legacy/conventional fallbacks.
entries: [],
subpathExports: new Map([['nest', ['packages/inner/src/nest']]]),
subpathImports: new Map(),
},
],
]),
};
const ctx = {
fromFile: 'apps/web/src/main.ts',
allFilePaths: FILES,
tsconfigs: null,
workspacePackages: subpathOnly,
};
expect(resolveTsModule('@repo/inner', ctx)).toBeNull();
// The paired positive: what the map DOES list still resolves.
expect(resolveTsModule('@repo/inner/nest', ctx)).toBe('packages/inner/src/nest/index.ts');
});
it('resolves nothing when the repo declares no packages at all', () => {
expect(resolve('@repo/utils', { packages: null })).toBeNull();
});
});
describe('subpath pattern substitution', () => {
it('substitutes the single `*` a pattern is allowed to contain', () => {
// Node subpath patterns and tsconfig `paths` both allow AT MOST one `*`, so
// substituting the first occurrence is the specified behaviour. Pinned
// because the obvious spelling — `String.replace` with a string needle —
// states that only by accident and reads as the replace-all footgun.
expect(substituteStar('packages/utils/src/*.ts', 'deep/thing')).toBe(
'packages/utils/src/deep/thing.ts',
);
});
it('leaves a starless target alone', () => {
expect(substituteStar('packages/utils/src/index', 'ignored')).toBe('packages/utils/src/index');
});
});

View file

@ -0,0 +1,172 @@
/**
* Which directories the workspace ADMITS as packages (#2953 review).
*
* Reading manifests is not the same as trusting them. An earlier draft
* registered every `package.json` the repo-wide scan found, which recreates the
* false-positive half of #2953 from a different source: an app importing
* registry package `foo` binds to an excluded fixture or example that happens
* to declare `name: "foo"`. This repository is itself the example —
* `test/fixtures/**` declares `@repo/utils` among others.
*
* So the boundary is the workspace declaration, and these arms are about where
* it is read from and what it admits.
*/
import { describe, it, expect, afterAll } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { loadNodeWorkspacePackages } from '../../src/core/ingestion/import-resolvers/node-workspace-packages.js';
const roots: string[] = [];
/** Write a throwaway repo from a `relativePath -> contents` map. */
function repo(files: Readonly<Record<string, string>>): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-ws-'));
roots.push(root);
for (const [rel, contents] of Object.entries(files)) {
const full = path.join(root, rel);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, contents);
}
return root;
}
const pkg = (name: string): string => JSON.stringify({ name, main: 'src/index.ts' });
afterAll(() => {
for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
});
describe('workspace boundary', () => {
it('admits a package the pnpm globs declare', async () => {
const root = repo({
'pnpm-workspace.yaml': 'packages:\n - "packages/*"\n',
'package.json': JSON.stringify({ name: 'root', private: true }),
'packages/utils/package.json': pkg('@repo/utils'),
});
const packages = await loadNodeWorkspacePackages(root);
expect(packages?.byName.has('@repo/utils')).toBe(true);
});
it('refuses a package outside those globs', async () => {
const root = repo({
'pnpm-workspace.yaml': 'packages:\n - "packages/*"\n',
'package.json': JSON.stringify({ name: 'root', private: true }),
'packages/utils/package.json': pkg('@repo/utils'),
// A fixture or example. Declares a name, is not a workspace member.
'examples/demo/package.json': pkg('lodash'),
});
const packages = await loadNodeWorkspacePackages(root);
expect(packages?.byName.has('@repo/utils')).toBe(true);
// The pointed case: an app importing registry `lodash` must not bind here.
expect(packages?.byName.has('lodash')).toBe(false);
});
it('reads npm/yarn `workspaces` from the root manifest', async () => {
const root = repo({
'package.json': JSON.stringify({ name: 'root', workspaces: ['apps/*'] }),
'apps/web/package.json': pkg('@repo/web'),
'vendored/copy/package.json': pkg('@repo/vendored'),
});
const packages = await loadNodeWorkspacePackages(root);
expect(packages?.byName.has('@repo/web')).toBe(true);
expect(packages?.byName.has('@repo/vendored')).toBe(false);
});
it('reads the yarn object form', async () => {
const root = repo({
'package.json': JSON.stringify({ name: 'root', workspaces: { packages: ['apps/*'] } }),
'apps/web/package.json': pkg('@repo/web'),
});
const packages = await loadNodeWorkspacePackages(root);
expect(packages?.byName.has('@repo/web')).toBe(true);
});
it('honours a `!` exclusion', async () => {
const root = repo({
'pnpm-workspace.yaml': 'packages:\n - "packages/*"\n - "!packages/internal"\n',
'package.json': JSON.stringify({ name: 'root' }),
'packages/utils/package.json': pkg('@repo/utils'),
'packages/internal/package.json': pkg('@repo/internal'),
});
const packages = await loadNodeWorkspacePackages(root);
expect(packages?.byName.has('@repo/utils')).toBe(true);
expect(packages?.byName.has('@repo/internal')).toBe(false);
});
it('matches `**` across segments', async () => {
const root = repo({
'pnpm-workspace.yaml': 'packages:\n - "packages/**"\n',
'package.json': JSON.stringify({ name: 'root' }),
'packages/group/nested/package.json': pkg('@repo/nested'),
});
const packages = await loadNodeWorkspacePackages(root);
expect(packages?.byName.has('@repo/nested')).toBe(true);
});
it('admits only the root when the repo declares no workspace', async () => {
const root = repo({
'package.json': pkg('just-one-package'),
// A nested manifest in a repo with no workspace declaration is not a
// member of anything — which is exactly this repository's own shape, and
// why its `test/fixtures/**` manifests must not register.
'test/fixtures/thing/package.json': pkg('@repo/utils'),
});
const packages = await loadNodeWorkspacePackages(root);
expect(packages?.byName.has('just-one-package')).toBe(true);
expect(packages?.byName.has('@repo/utils')).toBe(false);
});
it('gives a package with a root-less `exports` map no root entries', async () => {
// The loader half of the rule: when `exports` is present it is the whole
// interface, so `main` and the conventional `src/index` fallback must not
// be offered for the bare package name.
const root = repo({
'pnpm-workspace.yaml': 'packages:\n - "packages/*"\n',
'package.json': JSON.stringify({ name: 'root' }),
'packages/inner/package.json': JSON.stringify({
name: '@repo/inner',
main: 'src/index.ts',
exports: { './nest': './src/nest.ts' },
}),
});
const packages = await loadNodeWorkspacePackages(root);
const inner = packages?.byName.get('@repo/inner');
expect(inner?.entries).toEqual([]);
expect(inner?.subpathExports.get('nest')).toEqual(['packages/inner/src/nest']);
});
it('keeps legacy and conventional root entries when there is no `exports`', async () => {
const root = repo({
'pnpm-workspace.yaml': 'packages:\n - "packages/*"\n',
'package.json': JSON.stringify({ name: 'root' }),
'packages/utils/package.json': JSON.stringify({ name: '@repo/utils', main: 'src/index.ts' }),
});
const packages = await loadNodeWorkspacePackages(root);
expect(packages?.byName.get('@repo/utils')?.entries).toContain('packages/utils/src/index');
});
it('returns null when the repo has no manifest at all', async () => {
const root = repo({ 'src/main.ts': 'export const x = 1;\n' });
expect(await loadNodeWorkspacePackages(root)).toBeNull();
});
});

View file

@ -0,0 +1,403 @@
/**
* One property, asserted for EVERY registered `ScopeResolver` (#2953).
*
* A specifier that names something OUTSIDE the repository must not resolve to
* a file inside it.
*
* This is the property #2953 was filed against. Its violation is not a missing
* edge but a fabricated one: `@acme/telemetry/nest` resolving to the repo's
* only path ending in `nest/index.ts` produces an `IMPORTS` edge at full
* confidence between two files that have no relationship, and `impact` then
* reports a blast radius into code that cannot be affected. The reporter
* measured 44 of 74 cross-package edges landing on two such files.
*
* The mechanism is shared, which is why this file is: `import-resolvers/
* utils.ts:suffixResolve` asks "does any file in this repo have a path ending
* in this specifier?", retrying with each leading segment dropped, and most
* languages still end their resolution chain there.
*
* ## The decoy is the whole test
*
* Every case pairs an external specifier with a DECOY — an unrelated in-repo
* file whose path ends the way the specifier does. Without one, a resolver that
* simply found nothing would pass while still holding no property at all: the
* question is not "did it return null" but "did it return null when a tempting
* wrong answer was sitting in the file set". Each case therefore also asserts
* the decoy is genuinely reachable, by resolving the spelling that SHOULD find
* it, so a typo in a fixture cannot manufacture a pass.
*
* ## `KNOWN_GAPS` is a work list, not an allowance
*
* A language in that map does not hold the property today. The entry records
* what its resolver currently answers, so the follow-up that fixes it deletes
* one line and the arm below starts enforcing it. Both arms run for every
* language either way — a gap that silently closes fails just as loudly as one
* that opens, because a resolver quietly starting to answer `null` for the
* paired positive case is a regression wearing the fix's clothes.
*/
import { describe, expect, it } from 'vitest';
import type { ParsedImport } from 'gitnexus-shared';
import { SupportedLanguages } from 'gitnexus-shared';
import { SCOPE_RESOLVERS } from '../../../src/core/ingestion/scope-resolution/pipeline/registry.js';
import type { ComposerConfig } from '../../../src/core/ingestion/language-config.js';
/** The `composer.json` PSR-4 map `loadPhpComposerConfig` would have produced. */
const PHP_COMPOSER: ComposerConfig = { psr4: new Map([['App', 'app']]) };
/** The value `loadGoModulePath` produces for a repo with a `go.mod`. */
const GO_MODULE = { modulePath: 'example.com/mod' };
/** What `scanCSharpProject` would report for the C# workspace below — the
* in-repo namespace evidence the #1881 suffix-fallback gate reads. */
const CSHARP_NAMESPACES = {
namespaces: {
declaredNamespaces: new Set(['App', 'App.Models']),
rootNamespaces: new Set(['App']),
truncated: false,
},
};
interface ConformanceCase {
/** The workspace, including the decoy. */
readonly files: readonly string[];
readonly fromFile: string;
readonly resolutionConfig: unknown;
/** A specifier naming something outside the repo. Must resolve to nothing. */
readonly external: string;
/**
* The in-repo file the external specifier is tempting: its path ends the way
* `external` does, so a suffix matcher lands on it.
*/
readonly decoy: string;
/**
* A specifier that SHOULD reach {@link decoy} — the paired positive proving
* the decoy is live, so a `null` below is a refusal rather than an empty
* corpus.
*
* Required only for a language that HOLDS the property. For one in
* {@link KNOWN_GAPS} the gap assertion already resolves `external` TO the
* decoy, which is that proof — and for several of them no other spelling
* exists: Swift's `Foundation` and COBOL's `EXTERNAL` name the in-repo
* directory and copybook as well as the external module, which is precisely
* why those resolvers cannot tell the two apart.
*/
readonly reachesDecoy?: string;
readonly parsedImport?: (targetRaw: string) => ParsedImport | undefined;
}
const PHP_FUNCTION_IMPORT = (targetRaw: string): ParsedImport => ({
kind: 'named',
localName: 'imported',
importedName: 'imported',
targetRaw,
importedSymbolKind: 'function',
});
const CASES: ReadonlyMap<SupportedLanguages, ConformanceCase> = new Map([
[
SupportedLanguages.TypeScript,
{
files: ['apps/web/src/main.ts', 'packages/inner/src/nest/index.ts', 'apps/web/src/nest.ts'],
fromFile: 'apps/web/src/main.ts',
resolutionConfig: undefined,
external: '@acme/telemetry/nest',
decoy: 'packages/inner/src/nest/index.ts',
reachesDecoy: '../../../packages/inner/src/nest',
},
],
[
SupportedLanguages.JavaScript,
{
files: ['apps/web/src/main.js', 'packages/inner/src/nest/index.js'],
fromFile: 'apps/web/src/main.js',
resolutionConfig: undefined,
external: '@acme/telemetry/nest',
decoy: 'packages/inner/src/nest/index.js',
reachesDecoy: '../../../packages/inner/src/nest',
},
],
[
SupportedLanguages.Vue,
{
files: ['apps/web/src/main.ts', 'packages/inner/src/nest/index.ts'],
fromFile: 'apps/web/src/main.ts',
resolutionConfig: undefined,
external: '@acme/telemetry/nest',
decoy: 'packages/inner/src/nest/index.ts',
reachesDecoy: '../../../packages/inner/src/nest',
},
],
[
SupportedLanguages.Python,
{
// #898's exact shape: `django.apps` beside an unrelated `accounts/apps.py`.
files: ['accounts/apps.py', 'accounts/models.py', 'billing/models.py'],
fromFile: 'accounts/models.py',
resolutionConfig: undefined,
external: 'django.apps',
decoy: 'accounts/apps.py',
reachesDecoy: 'accounts.apps',
},
],
[
SupportedLanguages.CSharp,
{
// #1881's shape: a BCL using beside a same-named local file.
files: ['App/Tasks.cs', 'App/Models/User.cs', 'App/Program.cs'],
fromFile: 'App/Program.cs',
// The #1881 gate is evidence-driven and fails OPEN without any, so a
// `undefined` config here would record a gap C# does not have.
resolutionConfig: CSHARP_NAMESPACES,
external: 'System.Threading.Tasks',
decoy: 'App/Tasks.cs',
reachesDecoy: 'App.Tasks',
},
],
[
SupportedLanguages.Java,
{
files: [
'vendor/util/List.java',
'com/example/model/User.java',
'src/main/java/com/example/App.java',
],
fromFile: 'src/main/java/com/example/App.java',
resolutionConfig: undefined,
external: 'java.util.List',
decoy: 'vendor/util/List.java',
reachesDecoy: 'vendor.util.List',
},
],
[
SupportedLanguages.Kotlin,
{
files: ['src/main/kotlin/vendor/Assert.kt', 'src/main/kotlin/com/example/App.kt'],
fromFile: 'src/main/kotlin/com/example/App.kt',
resolutionConfig: undefined,
external: 'org.junit.Assert',
decoy: 'src/main/kotlin/vendor/Assert.kt',
reachesDecoy: 'vendor.Assert',
},
],
[
SupportedLanguages.Go,
{
files: ['internal/models/user.go', 'main.go'],
fromFile: 'main.go',
resolutionConfig: GO_MODULE,
external: 'github.com/vendor/dep/internal/models',
decoy: 'internal/models/user.go',
reachesDecoy: 'example.com/mod/internal/models',
},
],
[
SupportedLanguages.Ruby,
{
files: ['lib/app/models/user.rb', 'lib/generators.rb', 'lib/main.rb'],
fromFile: 'lib/main.rb',
resolutionConfig: undefined,
external: 'rails/generators',
decoy: 'lib/generators.rb',
reachesDecoy: 'generators',
},
],
[
SupportedLanguages.Rust,
{
files: ['src/de.rs', 'src/models.rs', 'src/main.rs'],
fromFile: 'src/main.rs',
resolutionConfig: undefined,
external: 'serde::de',
decoy: 'src/de.rs',
reachesDecoy: 'crate::de',
},
],
[
SupportedLanguages.PHP,
{
files: ['app/Models/User.php', 'lib/Legacy/Missing.php', 'app/Main.php'],
fromFile: 'app/Main.php',
resolutionConfig: PHP_COMPOSER,
external: 'Vendor\\Ghost\\Missing',
decoy: 'lib/Legacy/Missing.php',
reachesDecoy: 'App\\Models\\User',
parsedImport: PHP_FUNCTION_IMPORT,
},
],
[
SupportedLanguages.Dart,
{
files: ['lib/http.dart', 'lib/models.dart', 'lib/main.dart'],
fromFile: 'lib/main.dart',
resolutionConfig: undefined,
external: 'package:http/http.dart',
decoy: 'lib/http.dart',
reachesDecoy: 'package:app/http.dart',
},
],
[
SupportedLanguages.Swift,
{
files: [
'Sources/Foundation/Thing.swift',
'Sources/Models/User.swift',
'Sources/App/main.swift',
],
fromFile: 'Sources/App/main.swift',
resolutionConfig: undefined,
external: 'Foundation',
decoy: 'Sources/Foundation/Thing.swift',
reachesDecoy: 'Models',
},
],
[
SupportedLanguages.C,
{
files: ['src/stdio.h', 'include/util.h', 'src/main.c'],
fromFile: 'src/main.c',
resolutionConfig: undefined,
external: 'stdio.h',
decoy: 'src/stdio.h',
reachesDecoy: 'util.h',
},
],
[
SupportedLanguages.CPlusPlus,
{
files: ['src/cstdio.h', 'include/util.hpp', 'src/main.cpp'],
fromFile: 'src/main.cpp',
resolutionConfig: undefined,
// `cstdio` with no extension would miss the decoy on spelling alone and
// post a pass that measures nothing; the header spelling is the real test.
external: 'cstdio.h',
decoy: 'src/cstdio.h',
reachesDecoy: 'util.hpp',
},
],
[
SupportedLanguages.Cobol,
{
files: ['copybooks/CUSTREC.cpy', 'vendor/EXTERNAL.cpy', 'src/PROG.cbl'],
fromFile: 'src/PROG.cbl',
resolutionConfig: undefined,
external: 'EXTERNAL',
decoy: 'vendor/EXTERNAL.cpy',
reachesDecoy: 'CUSTREC',
},
],
]);
/**
* Languages that do NOT hold the property yet, with what they answer instead.
*
* Each entry is a follow-up, not a decision: the resolver ends its chain in
* `suffixResolve` and so cannot tell an external module from a path suffix.
* Fixing one means giving that language its real algorithm the way #2953 gave
* TypeScript one, then deleting its line here.
*/
const KNOWN_GAPS: ReadonlyMap<SupportedLanguages, string> = new Map<SupportedLanguages, string>([
[SupportedLanguages.Java, '`java.util.List` -> `vendor/util/List.java`'],
[SupportedLanguages.Kotlin, '`org.junit.Assert` -> `src/main/kotlin/vendor/Assert.kt`'],
[SupportedLanguages.Go, '`github.com/vendor/dep/internal/models` -> `internal/models/user.go`'],
[SupportedLanguages.Ruby, '`rails/generators` -> `lib/generators.rb`'],
[SupportedLanguages.PHP, '`Vendor\\Ghost\\Missing` -> `lib/Legacy/Missing.php`'],
[SupportedLanguages.Dart, '`package:http/http.dart` -> `lib/http.dart`'],
[SupportedLanguages.Swift, '`Foundation` -> `Sources/Foundation/Thing.swift`'],
[SupportedLanguages.C, '`stdio.h` -> `src/stdio.h`'],
[SupportedLanguages.CPlusPlus, '`cstdio.h` -> `src/cstdio.h`'],
[SupportedLanguages.Cobol, '`EXTERNAL` -> `vendor/EXTERNAL.cpy`'],
]);
/**
* The six that hold it, and what earns each one.
*
* Not a list to maintain by hand — it is derived below — but worth reading
* once, because the three mechanisms are different and only one of them
* generalizes:
*
* - TypeScript / JavaScript / Vue resolve against declared config only and
* have no suffix fallback at all (#2953). This is the shape the ten above
* need.
* - Python (#898) and C# (#1881) kept the fallback and put a gate in front of
* it, keyed on whether the specifier's leading segment names anything
* in-repo. Cheaper, and it holds — but it is a filter on a guess rather
* than a resolution rule, so it answers "probably not external" instead of
* "here is what the language says this means".
* - Rust holds it for a spelling reason: `::` is not `/` or `.`, so
* `serde::de` never decomposes into a path suffix that could match
* `src/de.rs`. The decoy-reachability arm proves the file IS reachable via
* `crate::de`, so this is a real pass — but it is contingent on the
* separator, not on Rust knowing what a crate is.
*/
function resolveWith(
language: SupportedLanguages,
testCase: ConformanceCase,
targetRaw: string,
): string | readonly string[] | null {
const resolver = SCOPE_RESOLVERS.get(language)!;
const files = new Set(testCase.files);
return resolver.resolveImportTarget(
targetRaw,
testCase.fromFile,
files,
testCase.resolutionConfig,
{
parsedFiles: [],
parsedImport: testCase.parsedImport?.(targetRaw),
},
);
}
/** Every file an answer names, whatever shape the resolver used. */
function filesOf(answer: string | readonly string[] | null): readonly string[] {
if (answer === null) return [];
return typeof answer === 'string' ? [answer] : answer;
}
describe('external imports never resolve into the repository (#2953)', () => {
it('covers every registered scope resolver', () => {
// A new language lands here before it lands anywhere else: it either gets a
// case or an explicit gap entry, and both are edits someone has to justify.
expect([...CASES.keys()].sort()).toEqual([...SCOPE_RESOLVERS.keys()].sort());
});
it.each([...CASES.keys()].filter((language) => !KNOWN_GAPS.has(language)))(
'%s: the decoy is reachable, so the arm below means something',
(language) => {
const testCase = CASES.get(language)!;
const reached = filesOf(resolveWith(language, testCase, testCase.reachesDecoy!));
// The DECOY specifically, not merely something. Asserting non-empty let a
// case pair `reachesDecoy` with a different file than `decoy` and still
// pass, which proves the resolver can reach SOME file and says nothing
// about whether the tempting wrong answer below was ever reachable.
expect(
reached,
`${language}: '${testCase.reachesDecoy}' did not reach the decoy '${testCase.decoy}', so this workspace proves nothing about '${testCase.external}'`,
).toContain(testCase.decoy);
},
);
it.each([...CASES.keys()])('%s: an external specifier resolves to nothing', (language) => {
const testCase = CASES.get(language)!;
const resolved = filesOf(resolveWith(language, testCase, testCase.external));
const gap = KNOWN_GAPS.get(language);
if (gap !== undefined) {
// The gap is asserted, not skipped, and asserted as the RECORDED answer
// rather than as "something": a resolver returning an unrelated in-repo
// file would otherwise keep the entry green while the map's description
// of what it does went stale. A language that starts holding the property
// fails here too, which is how the entry gets deleted deliberately.
expect(
resolved,
`${language}: KNOWN_GAPS records '${testCase.external}' resolving to '${testCase.decoy}', but it resolved to ${JSON.stringify(resolved)} — update the entry or delete it`,
).toEqual([testCase.decoy]);
return;
}
expect(
resolved,
`${language}: '${testCase.external}' names nothing in this repo, but resolved to ${JSON.stringify(resolved)}`,
).toEqual([]);
});
});

View file

@ -275,19 +275,6 @@ const FIXTURES: ReadonlyMap<SupportedLanguages, ImportTargetFixture> = new Map<
minimumParsedFileReads: 0,
},
],
[
SupportedLanguages.TypeScript,
{
files: ['src/util.ts', 'src/models/user.ts', 'src/main.ts'],
fromFile: 'src/main.ts',
resolutionConfig: undefined,
missTarget: (i) => `./ghost${i}`,
hitTarget: './util',
parsedImport: IGNORES_CONTEXT,
minimumScans: 1,
minimumParsedFileReads: 0,
},
],
[
SupportedLanguages.Go,
{
@ -388,19 +375,6 @@ const FIXTURES: ReadonlyMap<SupportedLanguages, ImportTargetFixture> = new Map<
minimumParsedFileReads: 0,
},
],
[
SupportedLanguages.JavaScript,
{
files: ['src/util.js', 'src/models/user.js', 'src/main.js'],
fromFile: 'src/main.js',
resolutionConfig: undefined,
missTarget: (i) => `./ghost${i}`,
hitTarget: './util',
parsedImport: IGNORES_CONTEXT,
minimumScans: 1,
minimumParsedFileReads: 0,
},
],
[
SupportedLanguages.Kotlin,
{
@ -477,39 +451,61 @@ const FIXTURES: ReadonlyMap<SupportedLanguages, ImportTargetFixture> = new Map<
minimumParsedFileReads: 0,
},
],
[
SupportedLanguages.Vue,
{
files: ['src/components/Widget.vue', 'src/util.ts', 'src/App.vue'],
fromFile: 'src/App.vue',
resolutionConfig: undefined,
missTarget: (i) => `./ghost${i}.vue`,
hitTarget: './components/Widget.vue',
parsedImport: IGNORES_CONTEXT,
minimumScans: 1,
minimumParsedFileReads: 0,
},
],
]);
/**
* Registered resolvers exempted from the property, each with the open issue
* that will remove the exemption.
* Registered resolvers exempted from the property, each citing the issue that
* explains why.
*
* EMPTY, and that is the result rather than the starting state: every resolver
* in `SCOPE_RESOLVERS` either memoizes its index on the `allFilePaths` Set
* identity or never traverses the Set at all (#2872, #2877, #2878, #2879, #2880,
* #2901, #2902, #2908 closed the last of them). The map stays because the
* mechanism is the point — the next language must not be able to opt out of the
* property by quietly not appearing in `FIXTURES`. An entry here must
* cite an open issue (`#NNNN`); the arm below enforces the citation, and the
* pinned empty key list means adding one is a visible, reviewed edit rather
* than a line in a table nobody reads.
* It was EMPTY until #2953, and that emptiness was a result rather than a
* starting state: every resolver in `SCOPE_RESOLVERS` either memoized its index
* on the `allFilePaths` Set identity or never traversed the Set at all (#2872,
* #2877, #2878, #2879, #2880, #2901, #2902, #2908 closed the last of them).
*
* The three entries it now carries are a different KIND of exemption, and the
* distinction is the reason this comment is worth reading. They are not "this
* resolver is allowed to re-scan": TypeScript, JavaScript and Vue stopped
* deriving anything from the file set at all, so there is no per-pass structure
* for the property to be about. `Set.has` against a candidate the config named
* is O(1) and independent of workspace size, which is the outcome the
* per-import-scan ban existed to secure.
*
* The mechanism stays because it is the point — a language must not be able to
* opt out by quietly not appearing in `FIXTURES`. An entry must cite an issue
* (`#NNNN`), and the key list is pinned exactly, so both adding one and letting
* one rot are visible, reviewed edits rather than lines in a table nobody
* reads.
*/
const KNOWN_UNINDEXED: ReadonlyMap<SupportedLanguages, string> = new Map<
SupportedLanguages,
string
>();
>([
// The three Node-family resolvers build no per-pass structure because #2953
// removed the thing one was for. They used to derive a `SuffixIndex` plus two
// file-list arrays from `allFilePaths` so `suffixResolve` could ask "does any
// file's path end in this specifier?" — a question real module resolution
// never asks. Every candidate now comes from a declared source (a real path,
// a tsconfig mapping, a package manifest) and is checked with a single
// `Set.has`, so there is nothing derived from the file set to reuse and no
// per-import cost for this property to catch.
//
// Exempt from THIS property, not from the guard behind it: `Set.has` is O(1)
// and independent of workspace size, which is the outcome the per-import scan
// ban existed to secure. `languages/typescript/module-resolution.ts` is where
// that now has to stay true.
[
SupportedLanguages.TypeScript,
'resolves against declared config only (#2953) — no per-pass index to reuse',
],
[
SupportedLanguages.JavaScript,
'resolves against declared config only (#2953) — no per-pass index to reuse',
],
[
SupportedLanguages.Vue,
'resolves against declared config only (#2953) — no per-pass index to reuse',
],
]);
interface ContractCase {
readonly language: SupportedLanguages;
@ -640,11 +636,19 @@ describe('import-target index reuse — the contract every registered resolver h
expect(accountedFor).toEqual(registered);
});
it('exempts nothing, and would make an exemption cite an issue', () => {
it('exempts only the Node family, and makes every exemption cite an issue', () => {
for (const [language, reason] of KNOWN_UNINDEXED) {
expect(reason, `${language}'s exemption must cite an open issue`).toMatch(/#\d+/);
}
expect([...KNOWN_UNINDEXED.keys()]).toEqual([]);
// Pinned as a list rather than a count. The exemption is not "this resolver
// is allowed to be slow" — it is "this resolver derives nothing from the
// file set, so there is no per-pass structure for the property to be about"
// (#2953). A fourth language appearing here is a claim someone has to make
// deliberately, and the likeliest reason for it is an adapter that quietly
// stopped building the index it still needs.
expect([...KNOWN_UNINDEXED.keys()].sort()).toEqual(
[SupportedLanguages.TypeScript, SupportedLanguages.JavaScript, SupportedLanguages.Vue].sort(),
);
});
});

View file

@ -1,710 +0,0 @@
/**
* Differential harness for the JavaScript import-target suffix index (#2910).
*
* JavaScript's `PassCache` was the TypeScript one minus its `index` field, so
* `makeJsResolveImportTarget` handed `resolveTsTarget` a context with
* `index: undefined` and every JavaScript import fell through to
* `suffixResolve`'s linear `findIndex` — one pass over `normalizedFileList` per
* path part per extension, and `EXTENSIONS` has ~39 entries. 6448.9 µs per
* import at 2000 files and 25972.6 µs at 8000, against 25.0 / 27.0 µs for
* TypeScript over the same corpus; 28.5 / 27.4 µs with the index.
*
* Adding the field is NOT a pure hoist. `suffixResolve` answers a different
* question with an index than without:
*
* - without: `filePath.endsWith('/' + s)`, so only a PROPER suffix matches;
* - with: `index.get(s) || index.getInsensitive(s)`, and `buildSuffixIndex`
* indexes `j = 0`, so WHOLE paths match too.
*
* This file holds a verbatim copy of the pre-change adapter
* (`git show HEAD:gitnexus/src/core/ingestion/languages/javascript/import-target.ts`)
* and pins exactly what that difference does. Two classes of answer move and no
* others:
*
* A. `null → repo-root file`. A path with no `/` has no proper suffix at all,
* so the scan could never reach it: `require('config')` was unresolvable
* with `config.js` sitting in the repo root.
* B. `file → different file`, always toward a MORE specific match. The scan
* skips the whole-path candidate and falls through to a shorter path
* suffix or a later extension, where it finds something else:
* `import 'app/main'` resolved to `node_modules/dep0/lib/main.js` — the
* first `/main.js` in file order — and now resolves to `app/main.js`.
*
* Measured over 211 200 old-vs-new pairs (400 generated corpora × 3 importing
* files × 176 targets) there is no third class: the index never loses a match
* the scan found, and its answer is never matched at a less specific
* (path-part, extension) position. Both of those are asserted below as
* universal properties over this corpus rather than as a count.
*
* ## Why the moved answers are JavaScript being fixed, not the index being wrong
*
* TypeScript and Vue have run the indexed path since #1918, over an identically
* built `normalizedFileList` (`allFileList.map(f => f.toLowerCase())`), through
* the same `resolveTsTarget` — and this adapter's whole stated design is "TS
* resolver, JS extensions". So the fix makes JavaScript agree with TypeScript,
* and the arm below asserts that agreement over the entire corpus rather than
* asserting it in prose. Class B's witness settles the direction: resolving
* `'app/main'` into `node_modules` was not a behaviour worth preserving.
*
* ## The scan counter, and its control
*
* The last arm counts entries into `suffixResolve`'s linear branch. That is the
* instrument this defect needed and did not have: `CountingSet` counts
* traversals of the SET, and this scan walks the materialized array behind it,
* which is exactly why the defect survived every index-reuse guard that existed
* and the contract test over `SCOPE_RESOLVERS`. The arm reads the legacy
* adapter first, so a
* count of zero is paired with a demonstration that the counter can be nonzero.
*/
import { describe, expect, it, vi } from 'vitest';
import { SupportedLanguages } from 'gitnexus-shared';
import { makeJsResolveImportTarget } from '../../../src/core/ingestion/languages/javascript/import-target.js';
import { typescriptScopeResolver } from '../../../src/core/ingestion/languages/typescript/scope-resolver.js';
import {
resolveTsTarget,
type TsResolveContext,
} from '../../../src/core/ingestion/languages/typescript/import-target.js';
import { EXTENSIONS } from '../../../src/core/ingestion/import-resolvers/utils.js';
// ─── the linear-fallback counter ─────────────────────────────────────────────
// `suffixResolve` is reached through `import-resolvers/standard.ts`, which
// imports it by a relative specifier that resolves to this same module id.
// Everything else in the module — `buildSuffixIndex`, `EXTENSIONS`,
// `tryResolveWithExtensions` — is passed straight through.
const linearScans = { count: 0 };
vi.mock('../../../src/core/ingestion/import-resolvers/utils.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../../src/core/ingestion/import-resolvers/utils.js')>();
return {
...actual,
suffixResolve: (
pathParts: string[],
normalizedFileList: string[],
allFileList: string[],
index?: import('../../../src/core/ingestion/import-resolvers/utils.js').SuffixIndex,
): string | null => {
linearScans.count += index === undefined ? 1 : 0;
return actual.suffixResolve(pathParts, normalizedFileList, allFileList, index);
},
};
});
// ─── verbatim pre-change implementation ──────────────────────────────────────
// Copied from `git show HEAD:gitnexus/src/core/ingestion/languages/javascript/
// import-target.ts`. Only the names are prefixed; the body is untouched, and in
// particular the `PassCache` below still has no `index` field and the cache is
// still the single slot the WeakMap replaced.
type LegacyJsResolveContext = TsResolveContext;
type LegacyPassCache = {
readonly key: ReadonlySet<string>;
readonly allFilePaths: Set<string>;
readonly allFileList: readonly string[];
readonly normalizedFileList: readonly string[];
readonly resolveCache: Map<string, string | null>;
};
function legacyMakeJsResolveImportTarget(): (
targetRaw: string,
fromFile: string,
allFilePaths: ReadonlySet<string>,
resolutionConfig?: unknown,
) => string | readonly string[] | null {
let cached: LegacyPassCache | null = null;
return (targetRaw, fromFile, allFilePaths) => {
if (cached === null || cached.key !== allFilePaths) {
const allFileList = Array.from(allFilePaths);
cached = {
key: allFilePaths,
allFilePaths: new Set(allFilePaths),
allFileList,
normalizedFileList: allFileList.map((f) => f.toLowerCase()),
resolveCache: new Map(),
};
}
const ws: LegacyJsResolveContext = {
fromFile,
language: SupportedLanguages.JavaScript,
allFilePaths: cached.allFilePaths,
allFileList: cached.allFileList,
normalizedFileList: cached.normalizedFileList,
resolveCache: cached.resolveCache,
tsconfigPaths: null,
};
return resolveTsTarget(targetRaw, ws);
};
}
// ─── corpus ──────────────────────────────────────────────────────────────────
/** One differential case. `files` is emitted in the listed order, and that
* order is the tie-break under test — nothing here is random. */
interface Case {
readonly name: string;
readonly files: readonly string[];
readonly target: string;
readonly fromFile: string;
}
const FROM_FILE = 'src/main.js';
/**
* A deterministic multi-root workspace. Every root carries the same relative
* layout, so a suffix-keyed lookup and a proper-suffix scan disagree about
* which root wins; `node_modules/dep0/lib/main.js` exists so a short suffix has
* somewhere wrong to land; `SRC/Utils/Helper0.JS` differs from
* `src/utils/helper0.js` only in case; and `config.js`, `index.js`, `mod0.js`
* sit at the repo root, where no proper suffix can reach them.
*/
function generatedFiles(): string[] {
const files: string[] = [];
for (let i = 0; i < 10; i++) {
files.push(`src/components/Widget${i}.js`);
files.push(`src/components/widget${i}.jsx`);
files.push(`vendor/pkg${i % 3}/src/utils/helper${i}.js`);
files.push(`src/utils/helper${i}.js`);
files.push(`src/utils/helper${i}/index.js`);
files.push(`lib/mod${i}.mjs`);
files.push(`lib/legacy${i}.cjs`);
files.push(`mod${i}.js`);
files.push(`node_modules/dep${i}/index.js`);
files.push(`node_modules/dep${i}/lib/main.js`);
files.push(`SRC/Utils/Helper${i}.JS`);
files.push(`packages/app${i}/src/index.js`);
}
files.push('config.js');
files.push('index.js');
files.push('src/index.js');
files.push('src/main.js');
files.push('app/main.js');
return files;
}
const GENERATED_FILES = generatedFiles();
/**
* Targets swept across `GENERATED_FILES`: relative hits and misses,
* extensionless and explicit-extension spellings, `index.js` directories, bare
* and `node_modules` specifiers, scoped packages, case-differing paths, and
* plain misses. Most of them miss, which is both the realistic shape and the
* expensive one — a miss runs the cascade to completion.
*/
function generatedTargets(): string[] {
const targets: string[] = [];
for (let i = 0; i < 10; i++) {
targets.push(`./components/Widget${i}`);
targets.push(`./components/Widget${i}.js`);
targets.push(`../src/utils/helper${i}`);
targets.push(`src/utils/helper${i}`);
targets.push(`utils/helper${i}`);
targets.push(`helper${i}`);
targets.push(`mod${i}`);
targets.push(`lib/mod${i}.mjs`);
targets.push(`lib/legacy${i}`);
targets.push(`dep${i}`);
targets.push(`dep${i}/lib/main`);
targets.push(`SRC/Utils/Helper${i}`);
targets.push(`packages/app${i}/src`);
targets.push(`@scope/pkg${i}`);
targets.push(`ghost${i}/missing`);
targets.push(`node_modules/dep${i}`);
}
targets.push('config');
targets.push('index');
targets.push('src');
targets.push('src/main');
targets.push('app/main');
return targets;
}
const GENERATED_CASES: readonly Case[] = generatedTargets().map((target) => ({
name: `generated ${target}`,
files: GENERATED_FILES,
target,
fromFile: FROM_FILE,
}));
/** Hand-built cases, one per shape the index could have moved. */
const HAND_CASES: readonly Case[] = [
// ── relative specifiers: resolved by exact `Set.has`, never reach the index ──
{
name: 'relative hit',
files: ['src/util.js', 'src/main.js'],
target: './util',
fromFile: FROM_FILE,
},
{
name: 'relative hit with an explicit extension',
files: ['src/util.js', 'src/main.js'],
target: './util.js',
fromFile: FROM_FILE,
},
{
name: 'relative parent-directory hit',
files: ['shared/util.js', 'src/main.js'],
target: '../shared/util',
fromFile: FROM_FILE,
},
{
name: 'relative miss',
files: ['src/util.js', 'src/main.js'],
target: './missing',
fromFile: FROM_FILE,
},
{
name: 'relative directory index.js',
files: ['src/util/index.js', 'src/main.js'],
target: './util',
fromFile: FROM_FILE,
},
{
name: 'relative ESM specifier written as .js against a .mjs file',
files: ['src/util.mjs', 'src/main.js'],
target: './util.js',
fromFile: FROM_FILE,
},
// ── class A: repo-root files, unreachable as a proper suffix ────────────────
{
name: 'root-level file by bare specifier',
files: ['config.js', 'src/main.js'],
target: 'config',
fromFile: FROM_FILE,
},
{
name: 'root index.js by bare specifier',
files: ['index.js', 'src/main.js'],
target: 'index',
fromFile: FROM_FILE,
},
{
name: 'root-level file with an explicit extension',
files: ['config.js', 'src/main.js'],
target: 'config.js',
fromFile: FROM_FILE,
},
{
name: 'root-level .mjs by bare specifier',
files: ['esm.mjs'],
target: 'esm',
fromFile: FROM_FILE,
},
{
name: 'root-level .cjs by bare specifier',
files: ['legacy.cjs'],
target: 'legacy',
fromFile: FROM_FILE,
},
{
name: 'root-level .jsx by bare specifier',
files: ['Btn.jsx'],
target: 'Btn',
fromFile: FROM_FILE,
},
// ── class B: whole path vs proper suffix ────────────────────────────────────
{
name: 'whole-path candidate earlier in file order than a proper-suffix one',
files: ['src/util.js', 'vendor/src/util.js'],
target: 'src/util',
fromFile: FROM_FILE,
},
{
name: 'whole-path candidate later in file order than a proper-suffix one',
files: ['vendor/src/util.js', 'src/util.js'],
target: 'src/util',
fromFile: FROM_FILE,
},
{
name: 'whole-path hit at a long suffix vs proper-suffix hit at a short one',
files: ['node_modules/dep/lib/main.js', 'app/main.js'],
target: 'app/main',
fromFile: FROM_FILE,
},
{
name: 'whole-path hit at a long suffix vs proper-suffix hit at a short one, reversed',
files: ['app/main.js', 'node_modules/dep/lib/main.js'],
target: 'app/main',
fromFile: FROM_FILE,
},
{
name: 'whole path is the only candidate, and a proper suffix of it exists',
files: ['src/util.js', 'src/main.js'],
target: 'src/util',
fromFile: FROM_FILE,
},
{
name: 'whole-path directory index.js',
files: ['src/util/index.js', 'src/main.js'],
target: 'src/util',
fromFile: FROM_FILE,
},
{
name: 'whole-path candidate at an earlier extension than the proper-suffix one',
files: ['x/U.js', 'U.jsx'],
target: 'U',
fromFile: FROM_FILE,
},
{
name: 'whole-path candidate at an earlier extension than the proper-suffix one, reversed',
files: ['U.jsx', 'x/U.js'],
target: 'U',
fromFile: FROM_FILE,
},
{
name: 'root .js outranks a nested .mjs',
files: ['lib/mod.mjs', 'mod.js'],
target: 'mod',
fromFile: FROM_FILE,
},
// ── case-differing paths ────────────────────────────────────────────────────
{
name: 'case-differing whole path beats a case-exact proper suffix',
files: ['SRC/Util.js', 'other/src/util.js'],
target: 'src/util',
fromFile: FROM_FILE,
},
{
name: 'case-differing proper suffixes only',
files: ['other/SRC/Util.js', 'zz/deep/src/util.js'],
target: 'src/util',
fromFile: FROM_FILE,
},
{
name: 'case-exact file later in order than a case-differing one',
files: ['a/FOO.js', 'b/Foo.js'],
target: 'Foo',
fromFile: FROM_FILE,
},
{
name: 'case-exact file earlier in order than a case-differing one',
files: ['b/Foo.js', 'a/FOO.js'],
target: 'Foo',
fromFile: FROM_FILE,
},
// ── bare / node_modules specifiers ──────────────────────────────────────────
{
name: 'node_modules package by bare specifier',
files: ['node_modules/dep/index.js', 'src/main.js'],
target: 'dep',
fromFile: FROM_FILE,
},
{
name: 'node_modules deep path',
files: ['node_modules/dep/lib/main.js', 'src/main.js'],
target: 'dep/lib/main',
fromFile: FROM_FILE,
},
{
name: 'scoped package with no file anywhere',
files: ['src/util.js', 'src/main.js'],
target: '@scope/pkg',
fromFile: FROM_FILE,
},
{
name: 'dotted specifier is split on dots',
files: ['a/b.js', 'src/main.js'],
target: 'a.b',
fromFile: FROM_FILE,
},
// ── extension coverage ──────────────────────────────────────────────────────
{
name: 'nested .mjs by bare specifier',
files: ['lib/mod.mjs', 'src/main.js'],
target: 'lib/mod',
fromFile: FROM_FILE,
},
{
name: 'nested .cjs by bare specifier',
files: ['lib/legacy.cjs', 'src/main.js'],
target: 'lib/legacy',
fromFile: FROM_FILE,
},
{
name: 'nested .jsx by bare specifier',
files: ['comp/Btn.jsx', 'src/main.js'],
target: 'comp/Btn',
fromFile: FROM_FILE,
},
// ── degenerate inputs ───────────────────────────────────────────────────────
{ name: 'empty file set', files: [], target: 'anything', fromFile: FROM_FILE },
{ name: 'empty target', files: ['src/util.js'], target: '', fromFile: FROM_FILE },
{
name: 'plain miss',
files: ['src/util.js', 'src/main.js'],
target: 'nowhere/at/all',
fromFile: FROM_FILE,
},
{
name: 'importing file is itself at the repo root',
files: ['config.js', 'main.js'],
target: 'config',
fromFile: 'main.js',
},
];
// ─── runners ─────────────────────────────────────────────────────────────────
type Resolved = string | readonly string[] | null;
/**
* One legacy adapter and one current adapter per corpus, each over its own copy
* of the file set — the legacy single-slot cache and the current WeakMap are
* both keyed on the Set, so sharing one would let each observe the other's
* work.
*/
interface Runners {
readonly legacy: (target: string, fromFile: string) => Resolved;
readonly current: (target: string, fromFile: string) => Resolved;
readonly typescript: (target: string, fromFile: string) => Resolved;
}
function runnersFor(files: readonly string[]): Runners {
const legacyAdapter = legacyMakeJsResolveImportTarget();
const currentAdapter = makeJsResolveImportTarget();
const legacyFiles = new Set(files);
const currentFiles = new Set(files);
const typescriptFiles = new Set(files);
return {
legacy: (target, fromFile) => legacyAdapter(target, fromFile, legacyFiles, undefined),
current: (target, fromFile) => currentAdapter(target, fromFile, currentFiles, undefined),
typescript: (target, fromFile) =>
typescriptScopeResolver.resolveImportTarget(target, fromFile, typescriptFiles, undefined),
};
}
interface Outcome {
readonly name: string;
readonly target: string;
readonly legacy: Resolved;
readonly current: Resolved;
readonly typescript: Resolved;
}
/** Every case, resolved once. Built lazily and shared: the generated corpus is
* one file set across 165 targets, which is the shape a real pass has. */
const OUTCOMES: readonly Outcome[] = (() => {
const generated = runnersFor(GENERATED_FILES);
const handOutcomes = HAND_CASES.map((testCase) => {
const runners = runnersFor(testCase.files);
return {
name: testCase.name,
target: testCase.target,
legacy: runners.legacy(testCase.target, testCase.fromFile),
current: runners.current(testCase.target, testCase.fromFile),
typescript: runners.typescript(testCase.target, testCase.fromFile),
};
});
const generatedOutcomes = GENERATED_CASES.map((testCase) => ({
name: testCase.name,
target: testCase.target,
legacy: generated.legacy(testCase.target, testCase.fromFile),
current: generated.current(testCase.target, testCase.fromFile),
typescript: generated.typescript(testCase.target, testCase.fromFile),
}));
return [...handOutcomes, ...generatedOutcomes];
})();
const DIVERGENT: readonly Outcome[] = OUTCOMES.filter(
(outcome) => outcome.legacy !== outcome.current,
);
function describeDivergence(outcome: Outcome): string {
return `${outcome.name} :: ${JSON.stringify(outcome.legacy)} → ${JSON.stringify(outcome.current)}`;
}
/**
* Where in `suffixResolve`'s two nested loops a result was matched, as
* `pathPartIndex:extensionIndex`. Lower is more specific: a longer path suffix,
* or the same suffix at an earlier extension. Mirrors `resolveImportPath`'s
* own `pathParts` construction (dots become slashes only when the specifier
* carries no slash).
*/
function matchPosition(result: string, target: string): readonly [number, number] {
const pathLike = target.includes('/') ? target : target.replace(/\./g, '/');
const parts = pathLike.split('/').filter(Boolean);
const lower = result.toLowerCase();
const positions = parts.flatMap((_part, i) => {
const suffix = parts.slice(i).join('/').toLowerCase();
return EXTENSIONS.flatMap((ext, e) => {
const candidate = suffix + ext.toLowerCase();
const matches = lower === candidate || lower.endsWith(`/${candidate}`);
return matches ? [[i, e] as const] : [];
});
});
return positions[0] ?? [Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER];
}
/**
* Class A — a repo-root file, which has no proper suffix and so was
* unreachable through this leg at all. Every line goes `null → <root file>`,
* and the arm below enforces that shape rather than trusting the grouping.
*/
const CLASS_A_DIVERGENCES: readonly string[] = [
'root-level file by bare specifier :: null → "config.js"',
'root index.js by bare specifier :: null → "index.js"',
'root-level .mjs by bare specifier :: null → "esm.mjs"',
'root-level .cjs by bare specifier :: null → "legacy.cjs"',
'root-level .jsx by bare specifier :: null → "Btn.jsx"',
'importing file is itself at the repo root :: null → "config.js"',
'generated config :: null → "config.js"',
];
/**
* Class B — the scan skipped the whole-path candidate and landed on a shorter
* path suffix or a later extension instead. Every line moves from one file to
* another, toward the more specific match; `never answers at a less specific
* path-part / extension position` is the property behind that claim.
*
* `generated src/main` and `generated app/main` are the witnesses that settle
* the direction: both used to resolve into `node_modules`, because
* `node_modules/dep0/lib/main.js` is the first file in the corpus ending in
* `/main.js` and the scan never tried the two-segment suffix as a whole path.
*/
const CLASS_B_DIVERGENCES: readonly string[] = [
'whole-path candidate earlier in file order than a proper-suffix one :: "vendor/src/util.js" → "src/util.js"',
'whole-path hit at a long suffix vs proper-suffix hit at a short one :: "node_modules/dep/lib/main.js" → "app/main.js"',
'whole-path candidate at an earlier extension than the proper-suffix one :: "x/U.js" → "U.jsx"',
'whole-path candidate at an earlier extension than the proper-suffix one, reversed :: "x/U.js" → "U.jsx"',
'root .js outranks a nested .mjs :: "lib/mod.mjs" → "mod.js"',
'case-differing whole path beats a case-exact proper suffix :: "other/src/util.js" → "SRC/Util.js"',
'generated src/main :: "node_modules/dep0/lib/main.js" → "src/main.js"',
'generated app/main :: "node_modules/dep0/lib/main.js" → "app/main.js"',
'generated mod0 :: "lib/mod0.mjs" → "mod0.js"',
'generated mod1 :: "lib/mod1.mjs" → "mod1.js"',
'generated mod2 :: "lib/mod2.mjs" → "mod2.js"',
'generated mod3 :: "lib/mod3.mjs" → "mod3.js"',
'generated mod4 :: "lib/mod4.mjs" → "mod4.js"',
'generated mod5 :: "lib/mod5.mjs" → "mod5.js"',
'generated mod6 :: "lib/mod6.mjs" → "mod6.js"',
'generated mod7 :: "lib/mod7.mjs" → "mod7.js"',
'generated mod8 :: "lib/mod8.mjs" → "mod8.js"',
'generated mod9 :: "lib/mod9.mjs" → "mod9.js"',
];
/**
* The divergences this corpus produces, pinned old → new. A future edit that
* moves a DIFFERENT answer — or stops moving one of these — fails here rather
* than quietly shipping.
*/
const EXPECTED_DIVERGENCES: readonly string[] = [...CLASS_A_DIVERGENCES, ...CLASS_B_DIVERGENCES];
// ─── the differential ────────────────────────────────────────────────────────
describe('JavaScript import-target parity with the pre-index adapter (#2910)', () => {
it('agrees with the pre-index adapter on every case outside the pinned set', () => {
const pinned = new Set(EXPECTED_DIVERGENCES);
const unexpected = DIVERGENT.map(describeDivergence).filter(
(description) => !pinned.has(description),
);
expect(unexpected).toEqual([]);
});
it('moves exactly the pinned answers, and still moves all of them', () => {
expect(DIVERGENT.map(describeDivergence).sort()).toEqual([...EXPECTED_DIVERGENCES].sort());
});
it('never loses a match the scan found', () => {
const lost = OUTCOMES.filter(
(outcome) => outcome.legacy !== null && outcome.current === null,
).map(describeDivergence);
expect(lost).toEqual([]);
});
it('never answers at a less specific path-part / extension position', () => {
const lessSpecific = DIVERGENT.filter(
(outcome) => typeof outcome.legacy === 'string' && typeof outcome.current === 'string',
)
.map((outcome) => ({
outcome,
was: matchPosition(String(outcome.legacy), outcome.target),
now: matchPosition(String(outcome.current), outcome.target),
}))
.filter(({ was, now }) => now[0] > was[0] || (now[0] === was[0] && now[1] > was[1]))
.map(({ outcome, was, now }) => `${describeDivergence(outcome)} (${was} → ${now})`);
expect(lessSpecific).toEqual([]);
});
it('answers identically to the TypeScript adapter over the whole corpus', () => {
const disagreements = OUTCOMES.filter((outcome) => outcome.current !== outcome.typescript).map(
(outcome) =>
`${outcome.name} :: js=${JSON.stringify(outcome.current)} ts=${JSON.stringify(outcome.typescript)}`,
);
expect(disagreements).toEqual([]);
});
it('both classes are witnessed, and each line has its class’s shape', () => {
// Class A is `null → <a repo-root file>`: no slash in the new answer, which
// is the whole reason the scan could not reach it.
const misfiledA = CLASS_A_DIVERGENCES.filter((line) => !/ :: null → "[^/"]+"$/.test(line));
// Class B moves between two files; neither side is null.
const misfiledB = CLASS_B_DIVERGENCES.filter((line) => line.includes('null'));
expect(misfiledA).toEqual([]);
expect(misfiledB).toEqual([]);
expect(CLASS_A_DIVERGENCES.length).toBeGreaterThan(0);
expect(CLASS_B_DIVERGENCES.length).toBeGreaterThan(0);
});
it('resolves real JavaScript imports (the differential is not vacuous)', () => {
const resolveImportTarget = makeJsResolveImportTarget();
const files = new Set([
'src/main.js',
'src/util.js',
'src/components/Widget.jsx',
'src/models/index.js',
'lib/esm.mjs',
'lib/legacy.cjs',
'node_modules/dep/index.js',
]);
expect(resolveImportTarget('./util', FROM_FILE, files, undefined)).toBe('src/util.js');
expect(resolveImportTarget('./util.js', FROM_FILE, files, undefined)).toBe('src/util.js');
expect(resolveImportTarget('./components/Widget', FROM_FILE, files, undefined)).toBe(
'src/components/Widget.jsx',
);
expect(resolveImportTarget('./models', FROM_FILE, files, undefined)).toBe(
'src/models/index.js',
);
expect(resolveImportTarget('lib/esm', FROM_FILE, files, undefined)).toBe('lib/esm.mjs');
expect(resolveImportTarget('lib/legacy', FROM_FILE, files, undefined)).toBe('lib/legacy.cjs');
expect(resolveImportTarget('./nowhere', FROM_FILE, files, undefined)).toBeNull();
expect(resolveImportTarget('@scope/absent', FROM_FILE, files, undefined)).toBeNull();
});
});
// ─── the guard the defect needed ─────────────────────────────────────────────
describe('JavaScript import resolution never enters the linear suffix scan (#2910)', () => {
/**
* The control runs first and on purpose. `CountingSet` cannot see this defect
* — the scan walks the array the index materialized, not the Set — so an
* assertion of zero is worth nothing unless the same instrument is shown
* reading nonzero against the adapter that had the bug.
*/
it('the pre-index adapter scans linearly once per bare specifier; the current one never does', () => {
const files = new Set(GENERATED_FILES);
const bareTargets = generatedTargets().filter((target) => !target.startsWith('.'));
const legacyAdapter = legacyMakeJsResolveImportTarget();
linearScans.count = 0;
bareTargets.forEach((target) => legacyAdapter(target, FROM_FILE, files, undefined));
const legacyEntries = linearScans.count;
const currentAdapter = makeJsResolveImportTarget();
linearScans.count = 0;
bareTargets.forEach((target) => currentAdapter(target, FROM_FILE, new Set(files), undefined));
const currentEntries = linearScans.count;
expect(legacyEntries).toBe(bareTargets.length);
expect(currentEntries).toBe(0);
});
});

View file

@ -14,7 +14,6 @@ import {
resolveTsImportTarget,
type TsResolveContext,
} from '../../../../src/core/ingestion/languages/typescript/import-target.js';
import { buildSuffixIndex } from '../../../../src/core/ingestion/import-resolvers/utils.js';
import * as importResolverUtils from '../../../../src/core/ingestion/import-resolvers/utils.js';
import { typescriptScopeResolver } from '../../../../src/core/ingestion/languages/typescript/scope-resolver.js';
import type { SyntaxNode } from '../../../../src/core/ingestion/utils/ast-helpers.js';
@ -477,9 +476,8 @@ describe('resolveTsImportTarget — standard suffix + alias resolution', () => {
const result = resolveTsImportTarget(
parsed,
ctx('src/main.ts', ['src/main.ts', 'src/services/user.ts'], {
tsconfigPaths: {
baseUrl: '.',
aliases: [['@/', 'src/']],
tsconfigs: {
scopes: [{ dir: '', baseUrl: '', paths: [{ pattern: '@/*', targets: ['src/*'] }] }],
},
}),
);
@ -541,27 +539,41 @@ describe('resolveTsImportTarget — standard suffix + alias resolution', () => {
expect(result).toBe('src/a.js');
});
it('uses a prebuilt suffix index for package-style imports', () => {
it('does not resolve a bare specifier that no config declares (#2953)', () => {
// `components/Button` is not relative, names no package, and this workspace
// declares no `baseUrl` to make it project-relative. The old resolver
// answered `src/components/Button.ts` anyway, by searching the file list for
// any path ending in the specifier — the defect in #2953. Nothing declares
// that edge, so there is none.
const parsed: ParsedImport = {
kind: 'named',
localName: 'Button',
importedName: 'Button',
targetRaw: 'components/Button',
};
const files = ['src/main.ts', 'src/components/Button.ts'];
const index = buildSuffixIndex(
files.map((f) => f.toLowerCase()),
files,
);
const result = resolveTsImportTarget(
parsed,
ctx('src/main.ts', files, {
allFileList: files,
normalizedFileList: files.map((f) => f.toLowerCase()),
index,
ctx('src/main.ts', ['src/main.ts', 'src/components/Button.ts']),
);
expect(result).toBeNull();
});
it('resolves that same specifier once a tsconfig `baseUrl` declares it (#2953)', () => {
// The other half of the arm above, and the reason it is not a regression:
// the specifier resolves when the project says it should, and to the same
// file. What changed is that the answer now has a declared basis.
const parsed: ParsedImport = {
kind: 'named',
localName: 'Button',
importedName: 'Button',
targetRaw: 'components/Button',
};
const result = resolveTsImportTarget(
parsed,
ctx('src/main.ts', ['src/main.ts', 'src/components/Button.ts'], {
tsconfigs: { scopes: [{ dir: '', baseUrl: 'src', paths: [] }] },
}),
);
expect(result).toBe('src/components/Button.ts');
});
});
@ -571,7 +583,12 @@ describe('typescriptScopeResolver.resolveImportTarget — real wiring', () => {
vi.restoreAllMocks();
});
it('builds suffix index and resolves package-style imports through the production path', () => {
it('builds no suffix index, and answers null for an undeclared bare specifier (#2953)', () => {
// Both halves matter. The index is not built because nothing asks the
// question it answered — "does any file's path end in this specifier?" —
// and the specifier does not resolve because nothing in this workspace
// declares it. The spy is what keeps the first half honest: an index built
// and then ignored would still be per-pass work over the whole file list.
const spy = vi.spyOn(importResolverUtils, 'buildSuffixIndex');
const files = new Set(['src/main.ts', 'src/components/Button.ts']);
@ -581,8 +598,8 @@ describe('typescriptScopeResolver.resolveImportTarget — real wiring', () => {
files,
);
expect(result).toBe('src/components/Button.ts');
expect(spy).toHaveBeenCalled();
expect(result).toBeNull();
expect(spy).not.toHaveBeenCalled();
});
it('returns null for unresolvable package-style imports', () => {

View file

@ -0,0 +1,222 @@
/**
* `tsconfig` loading for module resolution (#2953).
*
* The previous loader read three filenames at the repo root, required `paths`
* to exist, and kept only `targets[0]`. Each arm here is one of the things that
* made it unusable for resolution — and the `extends` arms are where the
* subtlety is, because `baseUrl` and `paths` routinely live in different files.
*/
import { describe, it, expect, afterAll } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
loadTsconfigIndex,
tsconfigFor,
} from '../../src/core/ingestion/languages/typescript/tsconfig.js';
const roots: string[] = [];
function repo(files: Readonly<Record<string, string>>): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-tsc-'));
roots.push(root);
for (const [rel, contents] of Object.entries(files)) {
const full = path.join(root, rel);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, contents);
}
return root;
}
afterAll(() => {
for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
});
describe('extends chains', () => {
it('inherits `baseUrl` from the config it extends', async () => {
const root = repo({
'tsconfig.base.json': JSON.stringify({ compilerOptions: { baseUrl: 'src' } }),
'tsconfig.json': JSON.stringify({ extends: './tsconfig.base.json' }),
});
const scope = tsconfigFor(await loadTsconfigIndex(root), 'src/main.ts');
expect(scope?.baseUrl).toBe('src');
});
it('resolves own `paths` targets against the INHERITED baseUrl', async () => {
// The subtle one. This config declares `paths` but no `baseUrl`, so the
// effective base is the inherited `src`. Resolving the targets against this
// config's own directory instead loads the right alias pattern and points
// every target at the wrong place — an alias that silently resolves to
// nothing, or worse to a same-named file one directory up.
const root = repo({
'tsconfig.base.json': JSON.stringify({ compilerOptions: { baseUrl: 'src' } }),
'tsconfig.json': JSON.stringify({
extends: './tsconfig.base.json',
compilerOptions: { paths: { '@/*': ['./features/*'] } },
}),
});
const scope = tsconfigFor(await loadTsconfigIndex(root), 'src/main.ts');
expect(scope?.paths[0]?.targets).toEqual(['src/features/*']);
});
it('lets an own `baseUrl` win over the inherited one', async () => {
const root = repo({
'tsconfig.base.json': JSON.stringify({ compilerOptions: { baseUrl: 'src' } }),
'tsconfig.json': JSON.stringify({
extends: './tsconfig.base.json',
compilerOptions: { baseUrl: 'app', paths: { '@/*': ['./features/*'] } },
}),
});
const scope = tsconfigFor(await loadTsconfigIndex(root), 'app/main.ts');
expect(scope?.baseUrl).toBe('app');
expect(scope?.paths[0]?.targets).toEqual(['app/features/*']);
});
it('rebases a base config’s `baseUrl` to that config’s own directory', async () => {
// `extends` does not rebase: `"baseUrl": "."` inside `configs/` means
// `configs/`, even when extended from the repo root.
const root = repo({
'configs/tsconfig.base.json': JSON.stringify({ compilerOptions: { baseUrl: '.' } }),
'tsconfig.json': JSON.stringify({ extends: './configs/tsconfig.base.json' }),
});
const scope = tsconfigFor(await loadTsconfigIndex(root), 'src/main.ts');
expect(scope?.baseUrl).toBe('configs');
});
it('reads a package-form `extends` out of node_modules when it is installed', async () => {
// `node_modules` is not INDEXED — it is dependency code, not the
// repository's own — but that is different from not reading it. A shared
// internal base is exactly where a monorepo puts the `paths` its packages
// import through, so refusing to open it loses aliases the repository does
// declare.
const root = repo({
'node_modules/@acme/tsconfig/tsconfig.json': JSON.stringify({
compilerOptions: { baseUrl: 'src', paths: { '~/*': ['./shared/*'] } },
}),
'tsconfig.json': JSON.stringify({ extends: '@acme/tsconfig' }),
});
const scope = tsconfigFor(await loadTsconfigIndex(root), 'src/main.ts');
// Resolved relative to the config that DECLARED them, which is tsc's rule
// and not an artefact of reading from `node_modules`: `extends` never
// rebases `baseUrl`. So a package base points at its own directory, and
// that is worth pinning rather than wishing away — it is the reason a
// published base config rarely contributes aliases a repo's own files
// resolve through, and why the `@tsconfig/*` family (which sets `target`
// and `lib`, never `paths`) is a no-op here either way.
expect(scope?.baseUrl).toBe('node_modules/@acme/tsconfig/src');
expect(scope?.paths[0]?.targets).toEqual(['node_modules/@acme/tsconfig/src/shared/*']);
});
it('keeps the extending config’s own options when the package is not installed', async () => {
// An un-installed checkout is a shape a static analyser must expect and a
// compiler may refuse. Degrading to fewer resolutions is correct; inventing
// a base is not.
const root = repo({
'tsconfig.json': JSON.stringify({
extends: '@tsconfig/node20/tsconfig.json',
compilerOptions: { baseUrl: 'src' },
}),
});
const scope = tsconfigFor(await loadTsconfigIndex(root), 'src/main.ts');
expect(scope?.baseUrl).toBe('src');
});
it('prefers an exact `paths` pattern over a wildcard that also matches', async () => {
// `a` and `a*` both match `a` with the same literal prefix length, so
// sorting on length alone left tsc's exact-wins rule to declaration order.
const root = repo({
'tsconfig.json': JSON.stringify({
compilerOptions: {
baseUrl: '.',
paths: { 'a*': ['./wild/*'], a: ['./exact'] },
},
}),
});
const scope = tsconfigFor(await loadTsconfigIndex(root), 'src/main.ts');
const exact = scope?.paths.find((mapping) => mapping.pattern === 'a');
expect(exact?.targets).toEqual(['exact']);
});
});
describe('which config governs a file', () => {
it('lets a child config with no baseUrl shadow the root, rather than inheriting it', async () => {
// The child project declares no `baseUrl`, which in TypeScript means its
// non-relative specifiers are PACKAGE lookups. Dropping the empty child let
// `tsconfigFor` fall through to the root and apply the root's aliases to
// the child's files — inventing a resolution the child never declared.
const root = repo({
'tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '.' } }),
'apps/web/tsconfig.json': JSON.stringify({ compilerOptions: { strict: true } }),
});
const index = await loadTsconfigIndex(root);
const child = tsconfigFor(index, 'apps/web/src/main.ts');
expect(child?.dir).toBe('apps/web');
expect(child?.baseUrl).toBeNull();
expect(child?.paths).toEqual([]);
// The root still governs everything outside that package.
expect(tsconfigFor(index, 'tools/script.ts')?.baseUrl).toBe('');
});
it('takes the nearest config, not the root one', async () => {
const root = repo({
'tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '.' } }),
'apps/web/tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: 'src' } }),
});
const index = await loadTsconfigIndex(root);
// A monorepo's per-package config is what governs that package's files;
// the root config governing them was the old loader's whole model.
expect(tsconfigFor(index, 'apps/web/src/main.ts')?.baseUrl).toBe('apps/web/src');
expect(tsconfigFor(index, 'tools/script.ts')?.baseUrl).toBe('');
});
});
describe('parsing', () => {
it('reads a config written as JSONC', async () => {
const root = repo({
'tsconfig.json': `{
// the base for absolute imports
"compilerOptions": {
/* block */
"baseUrl": "src",
},
}`,
});
expect(tsconfigFor(await loadTsconfigIndex(root), 'src/a.ts')?.baseUrl).toBe('src');
});
it('keeps every `paths` target, in order', async () => {
// The old loader kept `targets[0]`, which silently mis-resolves the common
// `["./src/*", "./generated/*"]` shape.
const root = repo({
'tsconfig.json': JSON.stringify({
compilerOptions: { baseUrl: '.', paths: { '@/*': ['./src/*', './generated/*'] } },
}),
});
const scope = tsconfigFor(await loadTsconfigIndex(root), 'src/a.ts');
expect(scope?.paths[0]?.targets).toEqual(['src/*', 'generated/*']);
});
it('returns null for a repo with no config at all', async () => {
expect(await loadTsconfigIndex(repo({ 'src/a.ts': '' }))).toBeNull();
});
});