mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-10 03:27:59 +00:00
Merge branch 'main' into codex/preserve-keep-marker-stats-prefix
This commit is contained in:
commit
7f9ab9eb9b
97 changed files with 6420 additions and 2885 deletions
|
|
@ -277,6 +277,12 @@ The solver is flow-insensitive but bounded: dependency-indexed work items rerun
|
|||
|
||||
Property-key dispatch remains a separate conservative fallback. Its per-key fan-out cap is 32; capped keys synthesize no partial calls and are reported at warning level with language, skipped-key count, dropped key names (bounded), and cap; the count also travels in `RunScopeResolutionStats.propertyDispatchSkippedKeys`.
|
||||
|
||||
Interface-dispatch fan-out walks the subtype closure of the receiver's interface and is **generic-instantiation aware** (#2912): a call through `IValidator<string>` must not reach an implementor of `IValidator<int>`, which shares its declaration and therefore its subtype list. Each heritage clause's arguments reach resolution by one of three routes — read off the `@reference.inherits` anchor's own spelling where that anchor spans the whole base (most languages, no query change), through the `@reference.type-arguments` sub-tag where the anchor is the bare name and moving it would renumber inheritance edge ids (Rust `impl T<A> for S`, Dart `extends`), or on a heritage MARKER payload for clauses that never become reference sites (Dart `implements`/`with`). Whichever pass emits the edge records the pair through one sink: `preEmitInheritanceEdges` for heritage clauses, `ScopeResolver.emitHeritageEdges` for the rest.
|
||||
|
||||
The walk then carries a substitution: a subtype's own type parameters bind to the receiver's arguments, so `class Wrapper<T> : IValidator<T>` stays reachable from every instantiation while `class IntValidator : IValidator<int>` is pruned from the `string` one. Receiver arguments come from the declared type (Case 4), a class-level field's declared type (Case 6), or — for a compound receiver such as `this._repo` — the spelling the compound fold typed that position from, reported back through `recordReceiverType` and accepted only when it names the class the fold returned.
|
||||
|
||||
The filter prunes only on positive evidence: an unknown instantiation on either side, an argument list whose arity does not line up, a name that may be a type variable the language's captures never recorded, or an unresolved spelling whose simple name matches all keep the target. A type parameter of the declaration ENCLOSING either side is recognised as such and never compared — `void Run<T>(IValidator<T> v)` writes a receiver with no known instantiation, so it keeps the unfiltered fan-out. That recognition is what generic METHODS now carry `@declaration.type-parameters` for in C#, Java and Kotlin (TypeScript already did): without it an unbounded `T` grounds to nothing and a bounded one grounds to its BOUND, and both compare unequal to an implementor's concrete argument. Languages that capture neither type arguments nor type parameters therefore emit exactly the pre-#2912 fan-out. The fan-out cap (32, `GITNEXUS_MAX_INTERFACE_DISPATCH_FANOUT`) and its skipped-target reporting are unchanged and apply after filtering. Note the fan-out itself still fires only for a receiver whose folded type is an `Interface` symbol, so a Rust `Trait` or a Dart abstract `Class` receiver emits no secondary targets to filter in the first place.
|
||||
|
||||
Standalone (regex-based) providers such as COBOL participate via `ScopeResolver.scopeResolutionEdgeMode: 'callable-flow-only'`: `runScopeResolution` runs for them, but every ordinary emission path — heritage, interface implementations, receiver-bound, free-call fallback, reference/import edges, post-resolution hooks — is gated off, so their legacy phase (e.g. `cobolPhase`) remains the sole owner of structural edges and the callable solver's `CALLS` are purely additive. A callable-flow-only provider whose files emitted no callable facts exits early, before finalize, keeping the opt-in proportional to source scanning.
|
||||
|
||||
### Receiver chains and the drop census (#2766)
|
||||
|
|
|
|||
|
|
@ -82,6 +82,28 @@ export interface ReferenceSite {
|
|||
* otherwise, in which case resolution is unchanged.
|
||||
*/
|
||||
readonly rawQualifiedName?: string;
|
||||
/**
|
||||
* Top-level generic/template arguments the source wrote ON this reference —
|
||||
* `class UserValidator : IValidator<string>` yields `['string']` on the
|
||||
* `inherits` site whose `name` is `IValidator`.
|
||||
*
|
||||
* `name` is the BASE name and stays that way: every lookup in resolution is
|
||||
* keyed by it, and one declaration answers for every instantiation of itself.
|
||||
* This records what the erasure threw away, so a consumer that needs the
|
||||
* INSTANTIATION — receiver-bound interface dispatch, which must not fan a
|
||||
* `IValidator<string>` receiver out to an `IValidator<int>` implementor
|
||||
* (#2912) — can ask for it without re-parsing the source.
|
||||
*
|
||||
* Derived generically from the anchor capture's own text (see
|
||||
* `collectReferenceSites`), so no language query change is needed: an emitter
|
||||
* whose `@reference.inherits` anchor spans the whole base gets this for free,
|
||||
* and one whose anchor is the bare name simply leaves it absent.
|
||||
*
|
||||
* ABSENT MEANS UNKNOWN, never "not generic" — the two are indistinguishable
|
||||
* here, and only the first is safe to act on. Consumers must fail OPEN on
|
||||
* absence (keep the target), matching `SymbolDefinition.typeParameters`.
|
||||
*/
|
||||
readonly typeArguments?: readonly string[];
|
||||
/** Source-text range of this reference. */
|
||||
readonly atRange: Range;
|
||||
/**
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
|
|
@ -345,7 +361,7 @@
|
|||
* inventory arm at the foot of the file reads
|
||||
* `SCOPE_RESOLVERS.get(language).resolveImportTarget.length` and reconciles it
|
||||
* against `CONTEXT_LANGS` in both directions, so a language that grows a
|
||||
* context leg cannot ship with the leg unmeasured. The other fifteen are handed
|
||||
* context leg cannot ship with the leg unmeasured. The other fourteen are handed
|
||||
* nothing and build no `ParsedFile[]` at all, so their numbers are unmoved.
|
||||
*
|
||||
* `newPass` mints the `ParsedFile[]` FIRST and derives the path set from it
|
||||
|
|
@ -369,9 +385,9 @@
|
|||
* `parsedFiles` and once without, whose two answers must DIFFER and must both
|
||||
* equal what baselines.json records. Dropping the fifth argument, dropping
|
||||
* `importedSymbolKind`, or reverting Python to `namespace` collapses the two
|
||||
* onto one value and fails — and no other arm here would: on the main corpus
|
||||
* the leg agrees with the cascade, so both languages' fingerprints are
|
||||
* UNCHANGED by this (measured, all ten).
|
||||
* onto one value and fails. PHP and Python still agree with their fallback on
|
||||
* the main corpus; Java deliberately has no context-free fallback, so its
|
||||
* fingerprints move to the declared-package answers recorded here.
|
||||
*
|
||||
* WHAT IS STILL NOT MEASURED, narrowed rather than deleted:
|
||||
*
|
||||
|
|
@ -446,7 +462,7 @@ import { resolveRubyImportTarget } from '../../src/core/ingestion/languages/ruby
|
|||
import { resolveCsharpImportTarget } from '../../src/core/ingestion/languages/csharp/import-target.ts';
|
||||
import { resolveKotlinImportTarget } from '../../src/core/ingestion/languages/kotlin/import-target.ts';
|
||||
import { resolvePhpImportTargetInternal } from '../../src/core/ingestion/languages/php/import-target.ts';
|
||||
import { resolveJavaImportTarget } from '../../src/core/ingestion/languages/java/import-target.ts';
|
||||
import { javaScopeResolver } from '../../src/core/ingestion/languages/java/scope-resolver.ts';
|
||||
import { cobolScopeResolver } from '../../src/core/ingestion/languages/cobol/scope-resolver.ts';
|
||||
import { resolveSwiftImportTarget } from '../../src/core/ingestion/languages/swift/import-target.ts';
|
||||
import { resolveRustImportTarget } from '../../src/core/ingestion/languages/rust/import-target.ts';
|
||||
|
|
@ -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,24 +595,35 @@ 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 }`
|
||||
* — because their registered hook DECLARES it. Two of seventeen, and the
|
||||
* — because their registered hook DECLARES it. Three of seventeen, and the
|
||||
* inventory arm at the foot of this file reconciles that claim against
|
||||
* `SCOPE_RESOLVERS` in both directions rather than trusting this line.
|
||||
*
|
||||
* These are also the only arms for which `newPass` builds a `ParsedFile[]` at
|
||||
* all. Building one for the other fifteen would cost their timed loop an
|
||||
* all. Building one for the other fourteen would cost their timed loop an
|
||||
* O(files) allocation per pass that no resolver of theirs can even observe —
|
||||
* their hooks declare three or four parameters — so their numbers stay exactly
|
||||
* where they were.
|
||||
*/
|
||||
const CONTEXT_LANGS = ['php', 'python'];
|
||||
const CONTEXT_LANGS = ['php', 'java', 'python'];
|
||||
|
||||
/**
|
||||
* Needs `node --expose-gc` to force collection for a clean delta; without it
|
||||
|
|
@ -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 = {
|
||||
|
|
@ -1022,8 +1088,26 @@ const probeFile = (filePath, defs) => ({
|
|||
referenceSites: [],
|
||||
});
|
||||
|
||||
const javaProbeFile = (filePath, packageName) => ({
|
||||
...probeFile(filePath, []),
|
||||
captureSideChannel: {
|
||||
kind: 'java',
|
||||
packageFact: { status: 'known', packageName },
|
||||
classAnnotations: [],
|
||||
},
|
||||
});
|
||||
|
||||
function javaBenchmarkPackage(filePath) {
|
||||
const uniquePackage = /\/com\/example\/(pkg\d+)(?:\/|$)/.exec(`/${filePath}`)?.[1];
|
||||
if (uniquePackage !== undefined) return `com.example.${uniquePackage}`;
|
||||
|
||||
return /\/svc\d+\/.*\/com\/example\/model(?:\/|$)/.test(`/${filePath}`)
|
||||
? 'com.example.model'
|
||||
: '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The `ParsedFile[]` the orchestrator threads beside the path set, for the two
|
||||
* The `ParsedFile[]` the orchestrator threads beside the path set, for the three
|
||||
* languages whose hook declares a `context` — see `CONTEXT_LANGS`.
|
||||
*
|
||||
* Two defs per file, and both are real shapes rather than padding. PHP keeps
|
||||
|
|
@ -1045,6 +1129,10 @@ const probeFile = (filePath, defs) => ({
|
|||
function buildParsedFiles(lang, files) {
|
||||
const parsedFiles = [];
|
||||
for (const filePath of files) {
|
||||
if (lang === 'java') {
|
||||
parsedFiles.push(javaProbeFile(filePath, javaBenchmarkPackage(filePath)));
|
||||
continue;
|
||||
}
|
||||
const slash = filePath.lastIndexOf('/');
|
||||
const stem = filePath.slice(slash + 1, filePath.lastIndexOf('.'));
|
||||
const parent = slash < 0 ? '' : filePath.slice(0, slash);
|
||||
|
|
@ -1358,17 +1446,12 @@ function collideTarget(lang, { local, r, d, j, dirs }) {
|
|||
: `Vendor${(r >>> 4) % 97}\\Ghost\\Missing`;
|
||||
}
|
||||
if (lang === 'java') {
|
||||
// `com.svc{d}.model` matches no directory, but its LAST segment is the one
|
||||
// every directory now ends in, so `firstFileDirectlyInPkgDir` walks the
|
||||
// whole `model` bucket twice — at the direct match and again after the
|
||||
// first strip — before the third strip finds `model` on its own. That walk
|
||||
// is the non-constant term this arm exists to measure. The `d % 7` slice
|
||||
// used to import `com.svc{d}.vendor`, which buckets to nothing, mirroring
|
||||
// the unique arm's nested slice — which missed until #2881 and resolves
|
||||
// now, so the mirror follows it or the same-workload invariant below breaks.
|
||||
// Every file declares the same package despite living under different
|
||||
// service paths. Exact and wildcard imports therefore exercise one growing
|
||||
// declared-package bucket without relying on directory layout.
|
||||
return local
|
||||
? (r >>> 3) % 3 === 0
|
||||
? `com.svc${d}.model.*`
|
||||
? 'com.example.model.*'
|
||||
: `com.example.model.File${j}`
|
||||
: (r >>> 3) % 2 === 0
|
||||
? ['java.util.List', 'java.io.IOException', 'java.util.concurrent.ConcurrentHashMap'][
|
||||
|
|
@ -1541,16 +1624,25 @@ 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);
|
||||
restoreBenchmarkSideChannels(lang, parsedFiles);
|
||||
return {
|
||||
allFilePaths: new Set(parsedFiles.map((f) => f.filePath)),
|
||||
config: undefined,
|
||||
|
|
@ -1569,12 +1661,20 @@ function newPass(lang, files) {
|
|||
* to prove the arm can tell the two call shapes apart.
|
||||
*/
|
||||
const contextFor = (pass, parsedImport) =>
|
||||
pass.parsedFiles === undefined ? undefined : { parsedFiles: pass.parsedFiles, parsedImport };
|
||||
pass.parsedFiles === undefined
|
||||
? undefined
|
||||
: { parsedFiles: pass.parsedFiles, parsedImport, filesSkipped: 0 };
|
||||
|
||||
function restoreBenchmarkSideChannels(lang, parsedFiles) {
|
||||
if (lang !== 'java') return;
|
||||
javaScopeResolver.loadResolutionConfig?.('');
|
||||
for (const parsed of parsedFiles) javaScopeResolver.applyCaptureSideChannel?.(parsed);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
|
|
@ -1624,9 +1724,18 @@ function resolveOne(lang, from, target, pass) {
|
|||
);
|
||||
}
|
||||
if (lang === 'java') {
|
||||
return resolveJavaImportTarget(
|
||||
{ kind: 'named', localName: 'X', importedName: 'X', targetRaw: target },
|
||||
{ fromFile: from, allFilePaths },
|
||||
const parsedImport = {
|
||||
kind: 'named',
|
||||
localName: 'X',
|
||||
importedName: 'X',
|
||||
targetRaw: target,
|
||||
};
|
||||
return javaScopeResolver.resolveImportTarget(
|
||||
target,
|
||||
from,
|
||||
allFilePaths,
|
||||
pass.config,
|
||||
contextFor(pass, parsedImport),
|
||||
);
|
||||
}
|
||||
// The `ScopeResolver` hook itself — COBOL's copy index has no other export.
|
||||
|
|
@ -1665,7 +1774,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 +1785,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 +1819,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 +1858,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 +1885,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 +1924,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.
|
||||
|
|
@ -1985,12 +2096,11 @@ function measureHeap(lang) {
|
|||
* of files carrying ONE import whose answer DIFFERS between the production
|
||||
* five-argument call and the three-argument one this harness used to make.
|
||||
*
|
||||
* That difference is the whole arm. The main corpus cannot serve as one: there
|
||||
* the leg AGREES with the cascade for every import (measured — both languages'
|
||||
* ten fingerprints are unchanged by threading the context), which is the right
|
||||
* outcome for a corpus built to measure cost, and useless for proving the
|
||||
* context arrives. Timing cannot prove it either; a dropped context makes the
|
||||
* arms FASTER, and nothing here has a lower bound on ms.
|
||||
* That difference is the whole arm. PHP and Python need it because their main
|
||||
* corpus answers agree with the fallback. Java's main fingerprint also catches
|
||||
* a dropped context, but this tiny positive probe isolates the adapter contract
|
||||
* from aggregate corpus changes. Timing cannot prove any of these; a dropped
|
||||
* context makes the arms faster, and nothing here has a lower bound on ms.
|
||||
*
|
||||
* Both are resolved THROUGH `resolveOne`, not through the resolvers directly,
|
||||
* because what is under test is this file's threading rather than the
|
||||
|
|
@ -2018,6 +2128,15 @@ const CONTEXT_PROBE = {
|
|||
probeFile('src/App/Ns0/Helpers.php', [['Function', 'App\\Ns0\\Dup']]),
|
||||
],
|
||||
},
|
||||
/** A declared package resolves its type only when the parsed workspace arrives. */
|
||||
java: {
|
||||
from: 'app/Main.java',
|
||||
target: 'com.example.model.User',
|
||||
parsedFiles: [
|
||||
javaProbeFile('app/Main.java', 'app'),
|
||||
javaProbeFile('weird/path/User.java', 'com.example.model'),
|
||||
],
|
||||
},
|
||||
/**
|
||||
* `from pkg import X`, with `pkg/__init__.py` exporting `X` AND a same-named
|
||||
* submodule `pkg/X.py` beside it — the precedence CPython documents and the
|
||||
|
|
@ -2051,10 +2170,12 @@ const CONTEXT_PROBE = {
|
|||
function measureContext(lang) {
|
||||
const { from, target, parsedFiles } = CONTEXT_PROBE[lang];
|
||||
const allFilePaths = new Set(parsedFiles.map((f) => f.filePath));
|
||||
const answer = (files) =>
|
||||
renderResolved(
|
||||
const answer = (files) => {
|
||||
restoreBenchmarkSideChannels(lang, files ?? []);
|
||||
return renderResolved(
|
||||
resolveOne(lang, from, target, { allFilePaths, config: undefined, parsedFiles: files }),
|
||||
);
|
||||
};
|
||||
return {
|
||||
target,
|
||||
with_context: answer(parsedFiles),
|
||||
|
|
@ -2169,8 +2290,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 +2299,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),
|
||||
};
|
||||
}
|
||||
|
|
@ -2217,7 +2338,7 @@ for (const lang of LANGS) {
|
|||
// phase goes 2.06 s -> 3.43 s, of which kotlin alone is 0.57 s. See COST.
|
||||
for (const lang of LANGS) report[lang].heap = measureHeap(lang);
|
||||
|
||||
// Deterministic and microseconds — it resolves six imports over two three-file
|
||||
// Deterministic and microseconds — it resolves six imports over three tiny
|
||||
// corpora — so unlike the heap arm it neither needs nor deserves isolation from
|
||||
// the timing phase. It runs last only because it reads best beside the heap arm
|
||||
// in the report.
|
||||
|
|
@ -2765,7 +2886,7 @@ expectNoOrphanKeys(
|
|||
// against a claim in a comment. `run.ts` passes the fifth argument to every
|
||||
// provider; which ones can OBSERVE it is decided by how many parameters each
|
||||
// hook declares, and that is a number the registry can be asked for. Today
|
||||
// exactly two answer 5 (php, python) and the other fourteen answer 3 or 4 —
|
||||
// exactly three answer 5 (php, java, python) and the other fourteen answer 3 or 4 —
|
||||
// which is why fourteen arms could ignore this whole question and their numbers
|
||||
// did not move when it was fixed.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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,27 +64,28 @@
|
|||
"_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,
|
||||
"fixture_count": 178
|
||||
},
|
||||
"rust": {
|
||||
"fingerprint": "116a971fee0004f340477aff69fa110a1d92bd8ba882d7c926483c6b1e8ca2b9",
|
||||
"fingerprint": "e61653008ff2de506cfd47f905fa9eb22d82fbbfe94d2a1d8190c358211b57b7",
|
||||
"scaling_budget": 1.5,
|
||||
"_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,
|
||||
|
|
@ -96,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",
|
||||
|
|
@ -106,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",
|
||||
|
|
@ -118,13 +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": "ba93c90dcd341259e8e088816bc8c76ad27882419f665e35c056dc22fa54cf73",
|
||||
"fingerprint": "3a8ddabbeb1cba47a4757451d4f79d726ca230fd15e860772b11526fbb1c6687",
|
||||
"scaling_budget": 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.",
|
||||
|
|
@ -133,13 +135,14 @@
|
|||
"_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."
|
||||
},
|
||||
"java": {
|
||||
"fingerprint": "2e2150b4f4d64519e3f4c6d7a2c12259178d3117872203c904fab8cba96a694a",
|
||||
"fingerprint": "2bf47cc19b595a9889ac21ec0154c6ce6786271d68551f21d1bc14c626bcd4ff",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_2935_synthetic_declarations": "PR #2935 review follow-up: synthesized Java anonymous classes and bodied enum constants now carry the presence-only @declaration.is-synthetic sidecar used to preserve source-written dispatch targets at the fanout cap. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE: the tag is attached to existing synthetic declaration matches; capture groups and fixture count remain 5755/18405, 3512, and 206. Prior 36d689c58526c4482fbd701d1d9ca156623a3970734ead145717858712271ab5 -> 2e2150b4f4d64519e3f4c6d7a2c12259178d3117872203c904fab8cba96a694a; CI scaling 0.971 < 1.5.",
|
||||
"_rebaselined_2917_record_component_accessors": "#2917: every implicit Java record-component accessor now emits a component-bounded @scope.function plus @declaration.method/name/zero-arity/return-type metadata. The scope boundary prevents subsequent record-body references from being attributed to the accessor. Java was the only general language fingerprint to move; capture groups scale by exactly two per generated record component (small 5755 -> 6255, large 18405 -> 20005). Prior 36d689c58526c4482fbd701d1d9ca156623a3970734ead145717858712271ab5 -> 901a66c7dc0f071eeef9e4864b2519e5b58a1a141a1f9a7817ea42f7ff70eafb; scaling 0.961 < 1.5. Re-measured after merging origin/main, which carries #2935's is-synthetic sidecar on top of the same corpus: 2e2150b4f4d64519e3f4c6d7a2c12259178d3117872203c904fab8cba96a694a -> 79dafc369eaeb7183ee8cc1149b1a6c21ad672c7e5b806fe8b0060e5a952c79a; scaling 1.085 < 1.5, capture groups 6255/20005, capture_groups_fp 3560, fixture_count 206 (unchanged by the merge).",
|
||||
"_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.",
|
||||
|
|
@ -147,44 +150,48 @@
|
|||
"_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.",
|
||||
"capture_groups_small": 5755,
|
||||
"capture_groups_large": 18405,
|
||||
"capture_groups_fp": 3512,
|
||||
"fixture_count": 206
|
||||
"_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": 3586,
|
||||
"fixture_count": 209,
|
||||
"_rebaselined_2910_declared_package_fixtures": "#2910 adds three Java resolver fixture files covering an external JDK lookalike, a path/package mismatch, and wildcard package membership. Fixture-corpus growth only: Java query rules and synthetic scaling sources are unchanged; capture_groups_small/large remain 6255/20005. capture_groups_fp 3560 -> 3586 and fixture_count 206 -> 209."
|
||||
},
|
||||
"java-local-types": {
|
||||
"fingerprint": "560734cd053fb4f4b23aa04bc7870c22089a8deedb0217fa9c1b4db689e02a97",
|
||||
"fingerprint": "bdde823fa725e636e257940efb4c8655aa23124c1727cbaa8856d1ad8f71729e",
|
||||
"scaling_budget": 1.5,
|
||||
"_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",
|
||||
|
|
@ -196,10 +203,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",
|
||||
|
|
@ -208,13 +215,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,
|
||||
|
|
|
|||
14
gitnexus/package-lock.json
generated
14
gitnexus/package-lock.json
generated
|
|
@ -79,7 +79,7 @@
|
|||
"version": "1.0.0",
|
||||
"dev": true,
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.3"
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
|
|
@ -1939,9 +1939,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz",
|
||||
"integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==",
|
||||
"version": "26.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
|
||||
"integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -5430,9 +5430,9 @@
|
|||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.11",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.11.tgz",
|
||||
"integrity": "sha512-Ry2oTEUnhBdeEdWIztY8kf3/nBGnPnjMLVGL0YfdRXMORuPER5NlKmayqxtxRxwB1xBN+RivRaJfe7PM1rtiyw==",
|
||||
"version": "4.23.12",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz",
|
||||
"integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -214,6 +214,20 @@ interface LanguageProviderConfig {
|
|||
* Default: undefined (standard label assignment). */
|
||||
readonly labelOverride?: (functionNode: SyntaxNode, defaultLabel: NodeLabel) => NodeLabel | null;
|
||||
|
||||
/**
|
||||
* Suppress a definition query match after its default label is known.
|
||||
* Languages use this for syntax that represents an implicit declaration
|
||||
* unless an explicit declaration with the same semantics is present.
|
||||
*
|
||||
* `defaultLabel` is supplied so an implementation can scope itself to one
|
||||
* kind of definition; implementations whose capture map alone decides the
|
||||
* question may ignore it.
|
||||
*/
|
||||
readonly shouldSkipDefinitionCapture?: (
|
||||
captureMap: CaptureMap,
|
||||
defaultLabel: NodeLabel,
|
||||
) => boolean;
|
||||
|
||||
// ── MRO ───────────────────────────────────────────────────────────
|
||||
/** MRO strategy for multiple inheritance resolution.
|
||||
* Default: 'first-wins'. */
|
||||
|
|
@ -615,7 +629,7 @@ interface LanguageProviderConfig {
|
|||
readonly resolveImportTarget?: (
|
||||
parsedImport: ParsedImport,
|
||||
workspaceIndex: WorkspaceIndex,
|
||||
) => string | null;
|
||||
) => string | readonly string[] | null;
|
||||
|
||||
/**
|
||||
* Enumerate the exported names of a file — used by the finalize algorithm
|
||||
|
|
|
|||
|
|
@ -93,8 +93,14 @@ const CSHARP_SCOPE_QUERY = `
|
|||
name: (identifier) @declaration.name) @declaration.enum
|
||||
|
||||
;; Declarations — methods / constructors / properties
|
||||
;;
|
||||
;; A generic METHOD's parameters are read for the same reason a generic type's
|
||||
;; are (#2912 review): \`void Run<T>(IValidator<T> v)\` writes a receiver whose
|
||||
;; argument is a type VARIABLE, and a pass that cannot tell that from a concrete
|
||||
;; type prunes every implementor of \`IValidator\` from the call's fan-out.
|
||||
(method_declaration
|
||||
name: (identifier) @declaration.name) @declaration.method
|
||||
name: (identifier) @declaration.name
|
||||
(type_parameter_list)? @declaration.type-parameters) @declaration.method
|
||||
|
||||
(constructor_declaration
|
||||
name: (identifier) @declaration.name) @declaration.constructor
|
||||
|
|
|
|||
|
|
@ -102,6 +102,82 @@ const csharpScopeResolver: ScopeResolver = {
|
|||
// files. The compound-receiver walker needs to walk up from the
|
||||
// class scope to find them; see the contract field for rationale.
|
||||
hoistTypeBindingsToModule: true,
|
||||
|
||||
// `IValidator<string>` and `IValidator<String>` are one instantiation, so the
|
||||
// dispatch fan-out must not read them as two (#2912). See the alias table.
|
||||
normalizeTypeArgument: normalizeCsharpTypeArgument,
|
||||
};
|
||||
|
||||
/**
|
||||
* C# predefined type aliases — the 15 keywords the language defines as exact
|
||||
* synonyms for `System` types (`string` ≡ `System.String`), plus `nint`/`nuint`.
|
||||
* A codebase mixing the spellings is common enough that StyleCop ships a rule
|
||||
* about it (SA1121), so the two forms genuinely meet across files.
|
||||
*
|
||||
* Keyword → BCL simple name; anything else is returned unchanged, including the
|
||||
* BCL names themselves (already canonical) and any qualified spelling, which is
|
||||
* compared as written.
|
||||
*
|
||||
* A workspace may legally declare its OWN type named `String`, which shadows the
|
||||
* BCL simple name; this table then reads `IValidator<String>` as the `string`
|
||||
* instantiation and KEEPS that implementor in the fan-out. Deliberate, and the
|
||||
* safe direction: the alternative is pruning on the belief that two spellings
|
||||
* differ, which is the missing-edge failure `generic-instantiation.ts` is built
|
||||
* to avoid. Resolving instead of normalizing cannot settle it either — the
|
||||
* identity comparison needs a `definitionId` from BOTH sides, and a built-in
|
||||
* name has none, so "built-in versus workspace-declared" would be a new prune
|
||||
* with no positive evidence behind it. The result is one surplus edge in a
|
||||
* shape that is rare on its own terms, i.e. exactly the pre-#2912 fan-out for
|
||||
* that pair and no worse.
|
||||
*/
|
||||
const CSHARP_PREDEFINED_TYPE_ALIASES: ReadonlyMap<string, string> = new Map([
|
||||
['bool', 'Boolean'],
|
||||
['byte', 'Byte'],
|
||||
['sbyte', 'SByte'],
|
||||
['char', 'Char'],
|
||||
['decimal', 'Decimal'],
|
||||
['double', 'Double'],
|
||||
['float', 'Single'],
|
||||
['int', 'Int32'],
|
||||
['uint', 'UInt32'],
|
||||
['long', 'Int64'],
|
||||
['ulong', 'UInt64'],
|
||||
['short', 'Int16'],
|
||||
['ushort', 'UInt16'],
|
||||
['nint', 'IntPtr'],
|
||||
['nuint', 'UIntPtr'],
|
||||
['object', 'Object'],
|
||||
['string', 'String'],
|
||||
]);
|
||||
|
||||
/** The BCL simple names the keywords alias. A spelling that reduces to one of
|
||||
* these IS the predefined type; anything else that merely happens to sit in
|
||||
* `System` is an ordinary type and keeps its qualifier. */
|
||||
const CSHARP_PREDEFINED_TYPE_NAMES: ReadonlySet<string> = new Set(
|
||||
CSHARP_PREDEFINED_TYPE_ALIASES.values(),
|
||||
);
|
||||
|
||||
const CSHARP_SYSTEM_QUALIFIER = /^(?:global::)?System\./;
|
||||
|
||||
function normalizeCsharpTypeArgument(name: string): string {
|
||||
const named = name.trim();
|
||||
// A keyword answers immediately: `string` → `String`.
|
||||
const aliased = CSHARP_PREDEFINED_TYPE_ALIASES.get(named);
|
||||
if (aliased !== undefined) return aliased;
|
||||
// Otherwise the `System.` qualifier is dropped so the fully-qualified
|
||||
// spelling of a predefined type meets that keyword: `System.String` →
|
||||
// `String` ≡ `string` → `String`. The optional `global::` alias qualifier goes
|
||||
// with it — `import-decomposer` already unwraps that spelling elsewhere, and
|
||||
// leaving it on would make `global::System.String` unequal to `string` and
|
||||
// prune a live implementor.
|
||||
//
|
||||
// ONLY when what remains is a predefined type. `System.Custom` is an ordinary
|
||||
// type that happens to live in `System`, and answering `Custom` for it would
|
||||
// equate it with an unrelated `Custom` elsewhere in the workspace. Returned as
|
||||
// written instead, which sends it to the identity comparison — the step that
|
||||
// can actually tell two declarations apart.
|
||||
const bare = named.replace(CSHARP_SYSTEM_QUALIFIER, '');
|
||||
return bare !== named && CSHARP_PREDEFINED_TYPE_NAMES.has(bare) ? bare : named;
|
||||
}
|
||||
|
||||
export { csharpScopeResolver };
|
||||
|
|
|
|||
|
|
@ -1069,9 +1069,15 @@ function emitHeritage(classNode: SyntaxNode, out: CaptureMatch[]): void {
|
|||
for (let i = 0; i < superclass.namedChildCount; i++) {
|
||||
const c = superclass.namedChild(i);
|
||||
if (c !== null && c.type === 'type_identifier') {
|
||||
// `extends Base<User>` spells the arguments in a SIBLING node, so the
|
||||
// anchor's own text cannot carry them; the sub-tag does (#2912).
|
||||
const args = typeArgumentsAfter(superclass, i);
|
||||
out.push({
|
||||
'@reference.inherits': nodeToCapture('@reference.inherits', c),
|
||||
'@reference.name': nodeToCapture('@reference.name', c),
|
||||
...(args === null
|
||||
? {}
|
||||
: { '@reference.type-arguments': nodeToCapture('@reference.type-arguments', args) }),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
|
@ -1144,7 +1150,26 @@ function emitHeritageMarkers(
|
|||
for (let i = 0; i < container.namedChildCount; i++) {
|
||||
const c = container.namedChild(i);
|
||||
if (c === null || c.type !== 'type_identifier') continue;
|
||||
const payload = encodeMarker('heritage', [kind, c.text, className]);
|
||||
// `implements Validator<String>` / `with M<int>`: the arguments ride the
|
||||
// marker payload, because this heritage never becomes a reference SITE —
|
||||
// `emitDartHeritageEdges` reads the marker and emits the edge (#2912).
|
||||
// Dropped rather than encoded when the spelling contains the marker's own
|
||||
// ':' delimiter, which `encodeMarker` rejects outright; absence is the
|
||||
// fail-open value everywhere this is read.
|
||||
const args = typeArgumentsAfter(container, i)?.text;
|
||||
const fields =
|
||||
args === undefined || args.includes(':')
|
||||
? [kind, c.text, className]
|
||||
: [kind, c.text, className, args];
|
||||
const payload = encodeMarker('heritage', fields);
|
||||
out.push({ '@import.heritage': syntheticCapture('@import.heritage', c, payload) });
|
||||
}
|
||||
}
|
||||
|
||||
/** The `type_arguments` node written immediately after `container`'s named
|
||||
* child at `index` — the arguments of the type that child names — or `null`
|
||||
* when that type was written without any. */
|
||||
function typeArgumentsAfter(container: SyntaxNode, index: number): SyntaxNode | null {
|
||||
const next = container.namedChild(index + 1);
|
||||
return next !== null && next.type === 'type_arguments' ? next : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,15 @@ const DART_SCOPE_QUERY = `
|
|||
(enum_declaration) @scope.class
|
||||
|
||||
; ── Declarations — types ─────────────────────────────────────────────────────
|
||||
(class_definition name: (identifier) @declaration.name) @declaration.class
|
||||
; The type-parameter list is matched as an UNNAMED optional child: the Dart
|
||||
; grammar hangs \`type_parameters\` off \`class_definition\` without a field name.
|
||||
; Recording it is what lets instantiation-aware interface dispatch tell a type
|
||||
; VARIABLE (\`class Box<T> implements Validator<T>\`) from a concrete argument
|
||||
; (\`class V implements Validator<String>\`) — see #2912; absent parameters are
|
||||
; indistinguishable from a language that captures none, and read as unknown.
|
||||
(class_definition
|
||||
name: (identifier) @declaration.name
|
||||
(type_parameters)? @declaration.type-parameters) @declaration.class
|
||||
(mixin_declaration (identifier) @declaration.name) @declaration.trait
|
||||
(extension_declaration name: (identifier) @declaration.name) @declaration.class
|
||||
(enum_declaration name: (identifier) @declaration.name) @declaration.enum
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ import { generateId } from '../../../../lib/utils.js';
|
|||
import { dartProvider } from '../dart.js';
|
||||
import { dartArityCompatibility, dartMergeBindings, resolveDartImportTarget } from './index.js';
|
||||
import { decodeMarker } from '../../utils/heritage-marker.js';
|
||||
import { typeApplicationArguments } from '../../utils/template-arguments.js';
|
||||
import type { HeritageTypeArgumentSink } from '../../scope-resolution/utils/generic-instantiation.js';
|
||||
import { expandDartWildcardNames } from './expand-wildcards.js';
|
||||
|
||||
interface ClassDefRef {
|
||||
|
|
@ -77,6 +79,7 @@ function emitDartHeritageEdges(
|
|||
graph: KnowledgeGraph,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
nodeLookup: GraphNodeLookup,
|
||||
recordTypeArguments?: HeritageTypeArgumentSink,
|
||||
): void {
|
||||
const defsByName = new Map<string, ClassDefRef[]>();
|
||||
for (const parsed of parsedFiles) {
|
||||
|
|
@ -110,10 +113,19 @@ function emitDartHeritageEdges(
|
|||
if (decoded?.kind !== 'heritage') continue;
|
||||
const parts = decoded.fields;
|
||||
if (parts.length < 3) continue;
|
||||
const [kind, baseName, childName] = parts;
|
||||
const [kind, baseName, childName, rawTypeArguments] = parts;
|
||||
const childId = pickClassByName(childName!, parsed.filePath, defsByName);
|
||||
const baseId = pickClassByName(baseName!, parsed.filePath, defsByName);
|
||||
if (childId === undefined || baseId === undefined || childId === baseId) continue;
|
||||
// The instantiation this clause was written with — `implements
|
||||
// Validator<String>` (#2912). Recorded before the dedup below, since the
|
||||
// FIRST writer wins on both sides and an edge deduped here still needs
|
||||
// its arguments. A marker from a pre-#2912 cache has no fourth field,
|
||||
// which reads as unknown.
|
||||
if (rawTypeArguments !== undefined) {
|
||||
const typeArguments = typeApplicationArguments(rawTypeArguments);
|
||||
if (typeArguments !== undefined) recordTypeArguments?.(childId, baseId, typeArguments);
|
||||
}
|
||||
const key = `${childId}->${baseId}:${kind}`;
|
||||
if (emitted.has(key)) continue;
|
||||
emitted.add(key);
|
||||
|
|
@ -211,8 +223,8 @@ export const dartScopeResolver: ScopeResolver = {
|
|||
|
||||
// `implements` / `with` IMPLEMENTS edges (extends rides the generic
|
||||
// inherits pre-pass; these need an explicit, kind-independent edge type).
|
||||
emitHeritageEdges: (graph, parsedFiles, nodeLookup) =>
|
||||
emitDartHeritageEdges(graph, parsedFiles, nodeLookup),
|
||||
emitHeritageEdges: (graph, parsedFiles, nodeLookup, _scopes, recordTypeArguments) =>
|
||||
emitDartHeritageEdges(graph, parsedFiles, nodeLookup, recordTypeArguments),
|
||||
|
||||
// Dart is statically typed — the field-fallback heuristic over-connects.
|
||||
fieldFallbackOnMethodLookup: false,
|
||||
|
|
|
|||
|
|
@ -23,14 +23,16 @@ import { createCallExtractor } from '../call-extractors/generic.js';
|
|||
import { javaCallConfig } from '../call-extractors/configs/jvm.js';
|
||||
import { createFieldExtractor } from '../field-extractors/generic.js';
|
||||
import { javaConfig } from '../field-extractors/configs/jvm.js';
|
||||
import { createMethodExtractor } from '../method-extractors/generic.js';
|
||||
import { javaMethodConfig } from '../method-extractors/configs/jvm.js';
|
||||
import { createVariableExtractor } from '../variable-extractors/generic.js';
|
||||
import { javaVariableConfig } from '../variable-extractors/configs/jvm.js';
|
||||
import { createJavaCfgVisitor } from '../cfg/visitors/java.js';
|
||||
import { assertCloneable } from '../workers/clone-safety.js';
|
||||
import { collectJavaCaptureSideChannel } from './java/capture-side-channel.js';
|
||||
import type { SymbolDefinition } from 'gitnexus-shared';
|
||||
import {
|
||||
javaRecordMethodExtractor,
|
||||
shouldSkipJavaRecordComponentDefinition,
|
||||
} from './java/record-components.js';
|
||||
import {
|
||||
emitJavaScopeCaptures,
|
||||
interpretJavaImport,
|
||||
|
|
@ -186,7 +188,8 @@ export const javaProvider = defineLanguage({
|
|||
mroStrategy: 'implements-split',
|
||||
callExtractor: createCallExtractor(javaCallConfig),
|
||||
fieldExtractor: createFieldExtractor(javaConfig),
|
||||
methodExtractor: createMethodExtractor(javaMethodConfig),
|
||||
methodExtractor: javaRecordMethodExtractor,
|
||||
shouldSkipDefinitionCapture: shouldSkipJavaRecordComponentDefinition,
|
||||
variableExtractor: createVariableExtractor(javaVariableConfig),
|
||||
classExtractor: createClassExtractor(javaClassConfig),
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,13 @@ export const SPRING_CONFIG_BINDINGS_FEATURE: AnalysisFeatureDescriptor = {
|
|||
),
|
||||
};
|
||||
|
||||
/** Durable completeness contract for implicit Java record-component accessors. */
|
||||
export const JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE: AnalysisFeatureDescriptor = {
|
||||
id: 'java.record-component-accessors',
|
||||
version: 1,
|
||||
appliesTo: (filePaths) => filePaths.some((filePath) => filePath.toLowerCase().endsWith('.java')),
|
||||
};
|
||||
|
||||
/** Durable completeness contract for Java heritage captures. */
|
||||
export const JAVA_ENUM_INTERFACE_HERITAGE_FEATURE: AnalysisFeatureDescriptor = {
|
||||
id: 'java.heritage-captures',
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import {
|
|||
captureJavaSpringConditionalFacts,
|
||||
type JavaSpringConditionalFact,
|
||||
} from './spring-conditionals.js';
|
||||
import { synthesizeJavaRecordComponentAccessorCaptures } from './record-components.js';
|
||||
|
||||
/** Declaration anchors that carry function-like arity metadata. */
|
||||
const FUNCTION_DECL_TAGS = ['@declaration.method', '@declaration.constructor'] as const;
|
||||
|
|
@ -397,6 +398,7 @@ export function emitJavaScopeCaptures(
|
|||
...synthesizeJavaInheritanceReferences(tree.rootNode),
|
||||
...synthesizeJavaExplicitConstructorReferences(tree.rootNode),
|
||||
...synthesizeJavaAnonymousClassDeclarations(tree.rootNode),
|
||||
...synthesizeJavaRecordComponentAccessorCaptures(tree.rootNode),
|
||||
...synthesizeCallableFlowCaptures(tree.rootNode, JAVA_CALLABLE_CAPTURE_OPTIONS),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,123 +1,96 @@
|
|||
/**
|
||||
* Adapter from `(ParsedImport, WorkspaceIndex)` → concrete file path.
|
||||
* Adapter from `(ParsedImport, WorkspaceIndex)` → the file(s) an import names.
|
||||
*
|
||||
* Converts Java package paths (dots → slashes) and tries:
|
||||
* 1. Exact file match: `com/example/User.java`
|
||||
* 2. Suffix match for nested layouts
|
||||
* 3. Directory match (wildcard imports)
|
||||
* 4. Progressive prefix stripping for non-standard layouts
|
||||
* Delegates to `module-resolution.ts`, which resolves a Java import the way
|
||||
* Java defines it: a fully-qualified type name looked up against the packages
|
||||
* the workspace's files DECLARE.
|
||||
*
|
||||
* Returns `null` for unresolvable / JDK imports.
|
||||
* ## What #2953 replaced, and why path shape could not work
|
||||
*
|
||||
* ## Why the scans are gone (#2908)
|
||||
* This resolver used to turn dots into slashes and hunt for a file whose path
|
||||
* ended that way — exact whole path, then any segment-suffix, then the first
|
||||
* `.java` directly inside a matching directory — retrying the whole cascade
|
||||
* with each leading segment stripped. Four legs, all describing where a file
|
||||
* SITS rather than what it DECLARES.
|
||||
*
|
||||
* Every leg above used to be answered by `for (const raw of ctx.allFilePaths)`,
|
||||
* and the stripping loop ran that scan again per stripped segment — so one
|
||||
* unresolvable `import a.b.c.D;` (the COMMON case: JDK and third-party imports
|
||||
* run the whole cascade to completion) cost four full workspace passes. This is
|
||||
* byte-for-byte the shape C# carried until #2878; both now read the same two
|
||||
* per-file-set indexes, memoized on the Set's identity:
|
||||
* Path shape is a convention, so it mostly worked, and failed hardest on the
|
||||
* case that matters: it had no way to tell an import of something outside the
|
||||
* repository from one inside it. `java.util.List` became `util/List`, then
|
||||
* `List`, and bound to any `List.java` anywhere in the tree — a fabricated
|
||||
* IMPORTS edge at full confidence, for an import naming a JDK class. Every JDK
|
||||
* and third-party import in a repository was a candidate.
|
||||
*
|
||||
* - `getWorkspaceFileIndex` — `normToRaw` (whole-path lookup) and `index`
|
||||
* (segment-suffix lookup);
|
||||
* - `getJavaDirIndex` — `firstFileDirectlyInPkgDir`'s package-directory index.
|
||||
* Two secondary defects went with it, both consequences of resolving by shape:
|
||||
*
|
||||
* ## The tie-breaks the scans encoded, and where they now live
|
||||
* - a wildcard `import com.example.*;` answered with ONE arbitrary file — the
|
||||
* first `.java` in the package directory in `allFilePaths` iteration order,
|
||||
* which the previous header documented at length as being decided by "a
|
||||
* property of the file list, not of the import". It now answers with every
|
||||
* file declaring that package, which is what the import actually names.
|
||||
* - a file's location and its package were assumed to agree. They need not:
|
||||
* `weird/path/User.java` declaring `package com.example;` is importable as
|
||||
* `com.example.User`, and `com/example/User.java` declaring nothing is in
|
||||
* the default package and importable as nothing at all. Both now resolve
|
||||
* correctly, because the declaration is what is read.
|
||||
*
|
||||
* 1. The first pass `break`s on an exact whole-path hit but keeps scanning
|
||||
* otherwise, then returns `exactFile ?? suffixFile ?? directoryChild`. So an
|
||||
* exact match wins over a suffix or directory-child match found EARLIER in
|
||||
* iteration order — hence `normToRaw` before `index`, which conflates the
|
||||
* two (see `resolveDirectMatch`).
|
||||
* 2. The stripping loop instead `return`s mid-scan on `f === tailFile ||
|
||||
* f.endsWith(tailSuffix)`, i.e. at the first hit of EITHER, and only returns
|
||||
* its directory child after the scan completes. So file/suffix beats
|
||||
* directory child within one `skip` level regardless of order, and the
|
||||
* conflated `index.get` is the CORRECT lookup there (see
|
||||
* `resolveByProgressiveStripping`).
|
||||
* 3. Wildcard imports drop their trailing `.*` before resolution, so
|
||||
* `com.example.*` resolves as the package directory.
|
||||
* 4. `.java` filter and backslash normalization, with the RAW path returned:
|
||||
* the indexes normalize for their keys and hand back the raw Set member, and
|
||||
* only a `.java` file can carry a `…/<name>.java` suffix key, so the
|
||||
* extension filter is implied on the file/suffix legs and explicit in the
|
||||
* directory index's `accept`.
|
||||
* 5. The directory-child leg used to match on the FIRST `'/' + pathLike + '/'`
|
||||
* occurrence, so `com/example/com/example/Deep.java` did NOT answer
|
||||
* `com.example`. #2881 removed that: the rule came from how the pre-index
|
||||
* scan was written, not from Java, and it made a package whose name repeats
|
||||
* higher in the path unresolvable. `firstFileDirectlyInPkgDir` now answers
|
||||
* plain "the parent directory ends with `pathLike`" (see the header of
|
||||
* `import-resolvers/package-dir-index.ts`). This leg commits to ONE file
|
||||
* with no downstream filter, so widening it can change which file an
|
||||
* already-resolving import binds to, not only turn a null into a hit.
|
||||
* WHICH file it binds to is decided by nothing in this resolver: it is
|
||||
* `allFilePaths` iteration order, i.e. the insertion order of the Set built
|
||||
* from `parsedFiles` in `scope-resolution/pipeline/run.ts`, which for a full
|
||||
* scan is the canonical sorted path order `filesystem-walker.ts` imposes on
|
||||
* its unsorted recursive-`glob` result. So the widened set's winner is a
|
||||
* property of the file list, not of the import — pinned explicitly, in both
|
||||
* insertion orders, by "pins WHICH of two competing package directories the
|
||||
* first-child leg takes" in
|
||||
* `test/unit/scope-resolution/java-import-target-parity.test.ts` (Kotlin's
|
||||
* twin, which has the same unfiltered first-child leg, is in
|
||||
* `test/unit/scope-resolution/kotlin/kotlin-import-target-parity.test.ts`).
|
||||
* The package declaration was already being extracted during the parse pass and
|
||||
* has been reachable here through `getJavaPackageFact` the whole time; nothing
|
||||
* read it. So this costs no new I/O — no `pom.xml`, no `build.gradle`, no
|
||||
* source-root inference. The workspace describes itself.
|
||||
*/
|
||||
|
||||
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
|
||||
import {
|
||||
getWorkspaceFileIndex,
|
||||
type WorkspaceFileIndex,
|
||||
} from '../../import-resolvers/workspace-file-index.js';
|
||||
import {
|
||||
buildPackageDirIndex,
|
||||
firstFileDirectlyInPkgDir,
|
||||
type PackageDirIndex,
|
||||
} from '../../import-resolvers/package-dir-index.js';
|
||||
import type { ParsedFile, ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
|
||||
import { perFileSet } from '../../import-resolvers/per-file-set.js';
|
||||
import { getJavaPackageFact } from './package-facts.js';
|
||||
import {
|
||||
buildJavaPackageIndex,
|
||||
resolveJavaModule,
|
||||
type JavaPackageIndex,
|
||||
} from './module-resolution.js';
|
||||
|
||||
export interface JavaResolveContext {
|
||||
readonly fromFile: string;
|
||||
readonly allFilePaths: ReadonlySet<string>;
|
||||
/**
|
||||
* The pass's parsed Java files — the only input this resolver needs, because
|
||||
* the package index is built from their declarations.
|
||||
*
|
||||
* Absent means "no workspace was supplied", not "the workspace declares
|
||||
* nothing": the index would be empty and every import would answer `null`.
|
||||
* The orchestrator always supplies it (`scope-resolution/pipeline/run.ts`
|
||||
* threads `context.parsedFiles`), and it must be passed THROUGH rather than
|
||||
* copied — the memo below keys on the array's identity.
|
||||
*/
|
||||
readonly parsedFiles?: readonly ParsedFile[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Package-directory index over the `.java` files, memoized on the Set's
|
||||
* identity. Feeds `firstFileDirectlyInPkgDir`, which is called once for the
|
||||
* direct match and then up to once per stripped package prefix.
|
||||
* The package index, built once per pass and read by every import.
|
||||
*
|
||||
* Keyed on the `parsedFiles` array the orchestrator already threads through the
|
||||
* pass, like PHP's `filesByDirectory` and Python's `parsedFileByPath`. The
|
||||
* instrument that can see this memo fail counts element reads on that array —
|
||||
* `countedParsedFiles` in `test/helpers/counting-file-set.ts`, asserted for
|
||||
* every language by `import-target-index-reuse.contract.test.ts`.
|
||||
*/
|
||||
const getJavaDirIndex = perFileSet(
|
||||
(allFilePaths: ReadonlySet<string>): PackageDirIndex =>
|
||||
buildPackageDirIndex(allFilePaths, (normalized) => normalized.endsWith('.java')),
|
||||
const getJavaPackageIndex = perFileSet(
|
||||
(parsedFiles: readonly ParsedFile[]): JavaPackageIndex =>
|
||||
buildJavaPackageIndex(parsedFiles, getJavaPackageFact),
|
||||
);
|
||||
|
||||
export function resolveJavaImportTarget(
|
||||
parsedImport: ParsedImport,
|
||||
workspaceIndex: WorkspaceIndex,
|
||||
): string | null {
|
||||
): string | readonly string[] | null {
|
||||
const ctx = narrowContext(workspaceIndex);
|
||||
if (ctx === null) return null;
|
||||
if (parsedImport.kind === 'dynamic-unresolved') return null;
|
||||
if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null;
|
||||
|
||||
// Strip trailing `.*` for wildcard imports: `com.example.*` → `com.example`
|
||||
let target = parsedImport.targetRaw;
|
||||
if (target.endsWith('.*')) {
|
||||
target = target.slice(0, -2);
|
||||
}
|
||||
const parsedFiles = ctx.parsedFiles;
|
||||
if (parsedFiles === undefined || parsedFiles.length === 0) return null;
|
||||
|
||||
// Package path: `com.example.User` → `com/example/User`
|
||||
const pathLike = target.replace(/\./g, '/');
|
||||
|
||||
const ws = getWorkspaceFileIndex(ctx.allFilePaths);
|
||||
const dirs = getJavaDirIndex(ctx.allFilePaths);
|
||||
|
||||
const direct = resolveDirectMatch(ws, dirs, pathLike);
|
||||
if (direct !== null) return direct;
|
||||
|
||||
// Progressive prefix stripping — handles `import com.example.User;`
|
||||
// in a repo laid out `User.java` (no `com/example/` prefix).
|
||||
return resolveByProgressiveStripping(ws, dirs, pathLike);
|
||||
return resolveJavaModule(parsedImport.targetRaw, getJavaPackageIndex(parsedFiles));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -136,59 +109,3 @@ function narrowContext(workspaceIndex: WorkspaceIndex): JavaResolveContext | nul
|
|||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* First-pass resolution against the full package path:
|
||||
* exact whole-path file > nested suffix file > first `.java` directly inside
|
||||
* the package directory.
|
||||
*/
|
||||
function resolveDirectMatch(
|
||||
ws: WorkspaceFileIndex,
|
||||
dirs: PackageDirIndex,
|
||||
pathLike: string,
|
||||
): string | null {
|
||||
const exactName = `${pathLike}.java`;
|
||||
// The scan `break`s here, so an exact whole-path match wins even when a
|
||||
// `…/<exactName>` suffix match appeared EARLIER in iteration order. The two
|
||||
// lookups therefore stay separate: `index.get` conflates them and would
|
||||
// return the earlier suffix hit.
|
||||
const exact = ws.normToRaw.get(exactName);
|
||||
if (exact !== undefined) return exact;
|
||||
// No whole-path file exists, so every segment-suffix hit is a `/<exactName>`
|
||||
// match and `index.get` yields the first one in iteration order — exactly the
|
||||
// `suffixFile` the scan kept. Only a `.java` file can carry a `.java` suffix
|
||||
// key, so the old `endsWith('.java')` filter is implied.
|
||||
const suffixFile = ws.index.get(exactName);
|
||||
if (suffixFile !== undefined) return suffixFile;
|
||||
// First `.java` file living directly inside the package directory `pathLike`
|
||||
// (at repo root or nested under a source-root prefix), not deeper — the leg
|
||||
// wildcard imports land on.
|
||||
return firstFileDirectlyInPkgDir(dirs, pathLike);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try each suffix of the package path against `.java` files and directories,
|
||||
* stripping leading segments one at a time. Models `import com.example.User;`
|
||||
* resolving to `User.java` in a repo laid out without the `com/example/` prefix.
|
||||
*/
|
||||
function resolveByProgressiveStripping(
|
||||
ws: WorkspaceFileIndex,
|
||||
dirs: PackageDirIndex,
|
||||
pathLike: string,
|
||||
): string | null {
|
||||
const segments = pathLike.split('/').filter(Boolean);
|
||||
for (let skip = 1; skip < segments.length; skip++) {
|
||||
const tail = segments.slice(skip).join('/');
|
||||
if (tail === '') continue;
|
||||
// `f === tailFile || f.endsWith('/' + tailFile)`, first in iteration order —
|
||||
// the scan returned at the first hit of EITHER, with no exact-wins rule,
|
||||
// so here the conflated suffix lookup is the right one.
|
||||
const tailFileMatch = ws.index.get(`${tail}.java`);
|
||||
if (tailFileMatch !== undefined) return tailFileMatch;
|
||||
// Collected mid-scan but returned only after it, so the file/suffix hit
|
||||
// above beats it even when this one came first in iteration order.
|
||||
const child = firstFileDirectlyInPkgDir(dirs, tail);
|
||||
if (child !== null) return child;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,10 +57,11 @@ export function interpretJavaImport(captures: CaptureMatch): ParsedImport | null
|
|||
// `import static com.example.Utils.*;`
|
||||
// The source is the class path (e.g. `com.example.Utils`).
|
||||
// Resolution should target the class file, not a wildcard directory
|
||||
// scan — `Utils.java` is the file that contains the static members.
|
||||
// scan — keeping the type path unstarred also distinguishes it from a
|
||||
// package wildcard when a same-named package exists.
|
||||
return {
|
||||
kind: 'wildcard',
|
||||
targetRaw: sourceCap.text + '.*',
|
||||
targetRaw: sourceCap.text,
|
||||
};
|
||||
}
|
||||
default:
|
||||
|
|
|
|||
167
gitnexus/src/core/ingestion/languages/java/module-resolution.ts
Normal file
167
gitnexus/src/core/ingestion/languages/java/module-resolution.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
/**
|
||||
* Java import resolution against DECLARED packages (#2953).
|
||||
*
|
||||
* A Java import is a fully-qualified type name, not a path. `com.example.model.User`
|
||||
* names the type `User` in the package `com.example.model`, and what places a
|
||||
* file in that package is its own `package` declaration — not where it sits on
|
||||
* disk. A file at `weird/path/User.java` declaring `package com.example.model;`
|
||||
* IS `com.example.model.User`; a file at `com/example/model/User.java` declaring
|
||||
* nothing is in the DEFAULT package and cannot be imported at all.
|
||||
*
|
||||
* The previous resolver worked the other way round: it turned dots into slashes
|
||||
* and looked for a file whose path ended that way, retrying with each leading
|
||||
* segment stripped. Path shape is a convention, so that mostly worked — and
|
||||
* failed in the one case that matters most, because it could not tell an import
|
||||
* of something outside the repository from one inside it. `java.util.List`
|
||||
* became `util/List`, then `List`, and bound to any `List.java` in the tree.
|
||||
* Every JDK and third-party import in a repo was a candidate for a fabricated
|
||||
* IMPORTS edge at full confidence.
|
||||
*
|
||||
* The fix needs no new I/O. Every Java file's `package` declaration is already
|
||||
* extracted during the parse pass and available here through
|
||||
* `getJavaPackageFact` — the resolver simply never read it. So resolution
|
||||
* becomes a lookup in an index the workspace already knows how to describe:
|
||||
*
|
||||
* `com.example.model.User` -> package `com.example.model` declares `User`
|
||||
* `java.util.List` -> no file declares package `java.util` -> null
|
||||
*
|
||||
* `null` for the second is the complete and correct answer: the JDK is not in
|
||||
* this repository, so there is no in-repo file the import could name.
|
||||
*/
|
||||
|
||||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
import type { JvmPackageFact } from '../jvm/package-facts.js';
|
||||
|
||||
export interface JavaPackageIndex {
|
||||
/** Declared package -> importable type name -> the file declaring it. */
|
||||
readonly typesByPackage: ReadonlyMap<string, ReadonlyMap<string, string>>;
|
||||
/** Declared package -> every file declaring it, for wildcard imports. */
|
||||
readonly filesByPackage: ReadonlyMap<string, readonly string[]>;
|
||||
/**
|
||||
* Files whose `package` header could not be read (a malformed header — see
|
||||
* `extractJvmPackageFact`). They are in no package, so nothing can import
|
||||
* them; counted so the gap is observable rather than silent.
|
||||
*/
|
||||
readonly unreadablePackageFiles: number;
|
||||
}
|
||||
|
||||
const EMPTY_INDEX: JavaPackageIndex = {
|
||||
typesByPackage: new Map(),
|
||||
filesByPackage: new Map(),
|
||||
unreadablePackageFiles: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Index the workspace by what each file DECLARES.
|
||||
*
|
||||
* The importable type name is the file's base name, which is not a convention
|
||||
* being relied on but the rule the language enforces: a type importable from
|
||||
* another package must be `public`, and a public type must live in a file named
|
||||
* after it. Additional package-private top-level types in the same file are
|
||||
* deliberately not indexed — they are unimportable from elsewhere, so an import
|
||||
* naming one is not a resolution this should find.
|
||||
*/
|
||||
export function buildJavaPackageIndex(
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
packageOf: (filePath: string) => JvmPackageFact | undefined,
|
||||
): JavaPackageIndex {
|
||||
if (parsedFiles.length === 0) return EMPTY_INDEX;
|
||||
|
||||
const typesByPackage = new Map<string, Map<string, string>>();
|
||||
const filesByPackage = new Map<string, string[]>();
|
||||
let unreadablePackageFiles = 0;
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
const filePath = parsed.filePath;
|
||||
const fact = packageOf(filePath);
|
||||
if (fact === undefined) continue;
|
||||
if (fact.status !== 'known') {
|
||||
unreadablePackageFiles++;
|
||||
continue;
|
||||
}
|
||||
// The default package (`''`) is indexed like any other so a workspace of
|
||||
// package-less files still answers its own wildcards, but Java forbids
|
||||
// importing FROM it, which `resolveJavaModule` enforces rather than
|
||||
// pretending here that the entry does not exist.
|
||||
const packageName = fact.packageName;
|
||||
|
||||
const typeName = baseTypeName(filePath);
|
||||
if (typeName !== null) {
|
||||
let types = typesByPackage.get(packageName);
|
||||
if (types === undefined) {
|
||||
types = new Map();
|
||||
typesByPackage.set(packageName, types);
|
||||
}
|
||||
// First declaration wins. Two files claiming the same package+type is not
|
||||
// legal Java; picking either is as correct as the input allows.
|
||||
if (!types.has(typeName)) types.set(typeName, filePath);
|
||||
}
|
||||
|
||||
const files = filesByPackage.get(packageName);
|
||||
if (files === undefined) filesByPackage.set(packageName, [filePath]);
|
||||
else files.push(filePath);
|
||||
}
|
||||
|
||||
return { typesByPackage, filesByPackage, unreadablePackageFiles };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one import specifier to the file(s) it names, or `null`.
|
||||
*
|
||||
* A wildcard answers with every file in the package; a type import answers with
|
||||
* one file. Anything the workspace does not declare answers `null`.
|
||||
*/
|
||||
export function resolveJavaModule(
|
||||
targetRaw: string,
|
||||
index: JavaPackageIndex,
|
||||
): string | readonly string[] | null {
|
||||
if (targetRaw === '') return null;
|
||||
|
||||
if (targetRaw.endsWith('.*')) {
|
||||
const stem = targetRaw.slice(0, -2);
|
||||
const inPackage = index.filesByPackage.get(stem);
|
||||
// Only package wildcards retain `.*`. Static wildcards are interpreted with
|
||||
// the owning type path so a same-named package cannot capture the import.
|
||||
return inPackage !== undefined && stem !== '' ? inPackage : null;
|
||||
}
|
||||
|
||||
return resolveTypeName(targetRaw, index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a qualified name into the longest DECLARED package prefix and the type
|
||||
* that follows it.
|
||||
*
|
||||
* Longest-first is what makes both of these land correctly without a rule about
|
||||
* capitalization, which Java does not actually enforce:
|
||||
*
|
||||
* `com.example.model.User` -> package `com.example.model`, type `User`
|
||||
* `com.example.Utils.method` -> package `com.example`, type `Utils`
|
||||
*
|
||||
* The second is a static member import; its trailing segments name members
|
||||
* inside the type, and the file the import binds to is the type's.
|
||||
*/
|
||||
function resolveTypeName(qualified: string, index: JavaPackageIndex): string | null {
|
||||
const parts = qualified.split('.').filter((part) => part !== '');
|
||||
// A single bare segment names a type in the default package, which Java
|
||||
// forbids importing. Nothing to resolve, and nothing to guess at.
|
||||
if (parts.length < 2) return null;
|
||||
|
||||
for (let split = parts.length - 1; split >= 1; split--) {
|
||||
const packageName = parts.slice(0, split).join('.');
|
||||
const types = index.typesByPackage.get(packageName);
|
||||
if (types === undefined) continue;
|
||||
const file = types.get(parts[split]);
|
||||
if (file !== undefined) return file;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `src/main/java/com/example/User.java` -> `User`. */
|
||||
function baseTypeName(filePath: string): string | null {
|
||||
const slash = filePath.replace(/\\/g, '/').lastIndexOf('/');
|
||||
const base = slash === -1 ? filePath : filePath.slice(slash + 1);
|
||||
if (!base.endsWith('.java')) return null;
|
||||
const name = base.slice(0, -'.java'.length);
|
||||
return name === '' ? null : name;
|
||||
}
|
||||
|
|
@ -89,7 +89,13 @@ const JAVA_SCOPE_QUERY = `
|
|||
])) @class-annotation.class
|
||||
|
||||
;; Declarations — methods / constructors
|
||||
;;
|
||||
;; A generic METHOD's parameters are read for the same reason a generic type's
|
||||
;; are (#2912 review): \`<T> boolean runAny(Validator<T> v)\` writes a receiver
|
||||
;; whose argument is a type VARIABLE, and a pass that cannot tell that from a
|
||||
;; concrete type prunes every implementor from the call's dispatch fan-out.
|
||||
(method_declaration
|
||||
type_parameters: (type_parameters)? @declaration.type-parameters
|
||||
name: (identifier) @declaration.name) @declaration.method
|
||||
|
||||
(constructor_declaration
|
||||
|
|
|
|||
233
gitnexus/src/core/ingestion/languages/java/record-components.ts
Normal file
233
gitnexus/src/core/ingestion/languages/java/record-components.ts
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
import { SupportedLanguages, type CaptureMatch } from 'gitnexus-shared';
|
||||
import type { CaptureMap } from '../../language-provider.js';
|
||||
import { createMethodExtractor } from '../../method-extractors/generic.js';
|
||||
import { javaMethodConfig } from '../../method-extractors/configs/jvm.js';
|
||||
import { extractAnnotations } from '../../field-extractors/configs/helpers.js';
|
||||
import type {
|
||||
ExtractedMethods,
|
||||
MethodExtractor,
|
||||
MethodExtractorContext,
|
||||
MethodInfo,
|
||||
} from '../../method-types.js';
|
||||
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
const javaExplicitMethodExtractor = createMethodExtractor(javaMethodConfig);
|
||||
|
||||
function recordComponents(recordNode: SyntaxNode): SyntaxNode[] {
|
||||
const parameters = recordNode.childForFieldName('parameters');
|
||||
if (parameters === null) return [];
|
||||
return parameters.namedChildren.filter(
|
||||
(node): node is SyntaxNode =>
|
||||
node !== null && (node.type === 'formal_parameter' || node.type === 'spread_parameter'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A record component is named by a real `identifier` and nothing else.
|
||||
*
|
||||
* Two node shapes reach this that are not one, and both would mint a graph node
|
||||
* for source that does not compile:
|
||||
*
|
||||
* - `record M(int x, y) {}` — a dropped type. tree-sitter recovers by
|
||||
* synthesizing `name: (MISSING identifier)`, a zero-width node whose text is
|
||||
* `''`. It still satisfies the query's `name: (identifier)`, so testing the
|
||||
* node TYPE alone does not reject it.
|
||||
* - `record R(int _) {}` — the grammar declares both `formal_parameter.name`
|
||||
* and `variable_declarator.name` as `identifier | underscore_pattern`, and
|
||||
* `_` parses with no error at all. `_` is illegal as a component name, and
|
||||
* admitting it here while the query rejects it is what let the structure and
|
||||
* scope paths disagree.
|
||||
*
|
||||
* Same degenerate-node shape as `javaBaseLookupNameNode` in captures.ts (#2935).
|
||||
*/
|
||||
function isRecordComponentName(node: SyntaxNode | null | undefined): node is SyntaxNode {
|
||||
return (
|
||||
node !== null &&
|
||||
node !== undefined &&
|
||||
node.type === 'identifier' &&
|
||||
!node.isMissing &&
|
||||
node.text.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function recordComponentNameNode(component: SyntaxNode): SyntaxNode | null {
|
||||
const name =
|
||||
component.type === 'formal_parameter'
|
||||
? component.childForFieldName('name')
|
||||
: (component.namedChildren
|
||||
.find((node) => node?.type === 'variable_declarator')
|
||||
?.childForFieldName('name') ?? null);
|
||||
return isRecordComponentName(name) ? name : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoised per record node. `shouldSkipJavaRecordComponentDefinition` is called
|
||||
* once per component capture, so recomputing this would rescan the whole record
|
||||
* body per component — O(components x body members) for a single record. The
|
||||
* scope-capture path hoists the call out of its own loop instead; this cache is
|
||||
* what gives the structure path the same cost. Keyed weakly on the AST node, so
|
||||
* it drops with the tree at the end of the file's parse.
|
||||
*/
|
||||
const explicitZeroArgAccessorNamesCache = new WeakMap<SyntaxNode, Set<string>>();
|
||||
|
||||
function explicitZeroArgAccessorNames(recordNode: SyntaxNode): Set<string> {
|
||||
const memoized = explicitZeroArgAccessorNamesCache.get(recordNode);
|
||||
if (memoized !== undefined) return memoized;
|
||||
const names = computeExplicitZeroArgAccessorNames(recordNode);
|
||||
explicitZeroArgAccessorNamesCache.set(recordNode, names);
|
||||
return names;
|
||||
}
|
||||
|
||||
function computeExplicitZeroArgAccessorNames(recordNode: SyntaxNode): Set<string> {
|
||||
const names = new Set<string>();
|
||||
const body = recordNode.childForFieldName('body');
|
||||
if (body === null) return names;
|
||||
|
||||
for (const node of body.namedChildren) {
|
||||
if (node === null || node.type !== 'method_declaration') continue;
|
||||
const name = node.childForFieldName('name')?.text;
|
||||
const parameters = node.childForFieldName('parameters');
|
||||
const parameterCount =
|
||||
parameters?.namedChildren.filter(
|
||||
(parameter) =>
|
||||
parameter !== null &&
|
||||
(parameter.type === 'formal_parameter' || parameter.type === 'spread_parameter'),
|
||||
).length ?? 0;
|
||||
if (name !== undefined && parameterCount === 0) names.add(name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function recordComponentReturnType(component: SyntaxNode): string | null {
|
||||
const typeNode =
|
||||
component.childForFieldName('type') ??
|
||||
(component.type === 'spread_parameter'
|
||||
? component.namedChildren.find(
|
||||
(node) => node?.type !== 'modifiers' && node?.type !== 'variable_declarator',
|
||||
)
|
||||
: undefined);
|
||||
const type = typeNode?.text;
|
||||
if (type === undefined) return null;
|
||||
return component.type === 'spread_parameter' ? `${type}[]` : type;
|
||||
}
|
||||
|
||||
function implicitAccessorInfo(
|
||||
component: SyntaxNode,
|
||||
context: MethodExtractorContext,
|
||||
): MethodInfo | null {
|
||||
const name = recordComponentNameNode(component)?.text;
|
||||
if (name === undefined) return null;
|
||||
|
||||
return {
|
||||
name,
|
||||
receiverType: null,
|
||||
returnType: recordComponentReturnType(component),
|
||||
parameters: [],
|
||||
visibility: 'public',
|
||||
isStatic: false,
|
||||
isAbstract: false,
|
||||
isFinal: false,
|
||||
// JLS 8.10.3 / 9.7.4: a component annotation reaches the generated accessor
|
||||
// when its @Target admits METHOD (or TYPE_USE, in the return-type position).
|
||||
// ponytail: over-approximate — we propagate every component annotation,
|
||||
// because @Target lives in another file and parsing is per-file, so the
|
||||
// target set is not knowable here. Nothing reads Method annotations today:
|
||||
// `annotations` is not a column in METHOD_SCHEMA/FUNCTION_SCHEMA
|
||||
// (src/core/lbug/schema.ts), so it lives only in the in-memory graph for one
|
||||
// analyze run, and the sole in-memory reader (springDiFieldMatcher) is gated
|
||||
// to `Property` nodes. If that column is ever added, revisit this: the set
|
||||
// would then become an agent-visible claim that may over-state the target.
|
||||
annotations: extractAnnotations(component, 'modifiers'),
|
||||
sourceFile: context.filePath,
|
||||
line: component.startPosition.row + 1,
|
||||
column: component.startPosition.column,
|
||||
};
|
||||
}
|
||||
|
||||
/** Java records synthesize one public, zero-argument accessor per component. */
|
||||
export const javaRecordMethodExtractor: MethodExtractor = {
|
||||
...javaExplicitMethodExtractor,
|
||||
language: SupportedLanguages.Java,
|
||||
extract(node: SyntaxNode, context: MethodExtractorContext): ExtractedMethods | null {
|
||||
const extracted = javaExplicitMethodExtractor.extract(node, context);
|
||||
if (extracted === null || node.type !== 'record_declaration') return extracted;
|
||||
|
||||
const explicitAccessors = explicitZeroArgAccessorNames(node);
|
||||
const implicitAccessors = recordComponents(node)
|
||||
.filter((component) => {
|
||||
const name = recordComponentNameNode(component)?.text;
|
||||
return name !== undefined && !explicitAccessors.has(name);
|
||||
})
|
||||
.map((component) => implicitAccessorInfo(component, context))
|
||||
.filter((method): method is MethodInfo => method !== null);
|
||||
|
||||
return { ...extracted, methods: [...extracted.methods, ...implicitAccessors] };
|
||||
},
|
||||
};
|
||||
|
||||
/** Scope declarations matching the structure-phase synthetic accessor nodes. */
|
||||
export function synthesizeJavaRecordComponentAccessorCaptures(
|
||||
rootNode: SyntaxNode,
|
||||
): CaptureMatch[] {
|
||||
const captures: CaptureMatch[] = [];
|
||||
for (const recordNode of rootNode.descendantsOfType('record_declaration')) {
|
||||
const explicitAccessors = explicitZeroArgAccessorNames(recordNode);
|
||||
for (const component of recordComponents(recordNode)) {
|
||||
const nameNode = recordComponentNameNode(component);
|
||||
const returnType = recordComponentReturnType(component);
|
||||
if (nameNode === null || returnType === null || explicitAccessors.has(nameNode.text))
|
||||
continue;
|
||||
|
||||
captures.push({
|
||||
'@scope.function': nodeToCapture('@scope.function', component),
|
||||
});
|
||||
captures.push({
|
||||
'@declaration.method': nodeToCapture('@declaration.method', component),
|
||||
'@declaration.name': nodeToCapture('@declaration.name', nameNode),
|
||||
'@declaration.parameter-count': syntheticCapture(
|
||||
'@declaration.parameter-count',
|
||||
component,
|
||||
'0',
|
||||
),
|
||||
'@declaration.required-parameter-count': syntheticCapture(
|
||||
'@declaration.required-parameter-count',
|
||||
component,
|
||||
'0',
|
||||
),
|
||||
'@declaration.return-type': syntheticCapture(
|
||||
'@declaration.return-type',
|
||||
component,
|
||||
returnType,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
return captures;
|
||||
}
|
||||
|
||||
/**
|
||||
* The structure query sees every record component. Suppress that synthetic
|
||||
* definition when the record body provides the canonical zero-argument
|
||||
* accessor explicitly, leaving the explicit method as the single authority.
|
||||
*/
|
||||
export function shouldSkipJavaRecordComponentDefinition(captureMap: CaptureMap): boolean {
|
||||
const component = captureMap['definition.method'];
|
||||
if (component?.type !== 'formal_parameter' && component?.type !== 'spread_parameter') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parameters = component.parent;
|
||||
const recordNode = parameters?.parent;
|
||||
if (parameters?.type !== 'formal_parameters' || recordNode?.type !== 'record_declaration') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Same predicate the scope path applies, so the two can never disagree about
|
||||
// which components have an accessor. The query's `name: (identifier)` is
|
||||
// satisfied by tree-sitter's zero-width MISSING recovery token, so the
|
||||
// structure path has to re-check what the query cannot express.
|
||||
const nameNode = captureMap['name'];
|
||||
if (!isRecordComponentName(nameNode)) return true;
|
||||
|
||||
return explicitZeroArgAccessorNames(recordNode).has(nameNode.text);
|
||||
}
|
||||
|
|
@ -54,8 +54,10 @@ const javaScopeResolver: ScopeResolver = {
|
|||
return undefined;
|
||||
},
|
||||
|
||||
resolveImportTarget: (targetRaw, fromFile, allFilePaths) => {
|
||||
const ws: JavaResolveContext = { fromFile, allFilePaths };
|
||||
resolveImportTarget: (targetRaw, fromFile, allFilePaths, _resolutionConfig, context) => {
|
||||
// `context.parsedFiles` is the whole input now: a Java import names a type
|
||||
// in a DECLARED package, and the declarations live on those files (#2953).
|
||||
const ws: JavaResolveContext = { fromFile, allFilePaths, parsedFiles: context?.parsedFiles };
|
||||
return resolveJavaImportTarget(
|
||||
{ kind: 'named', localName: '_', importedName: '_', targetRaw },
|
||||
ws,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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])],
|
||||
|
|
|
|||
|
|
@ -121,7 +121,13 @@ const KOTLIN_SCOPE_QUERY = `
|
|||
])) @class-annotation.class
|
||||
|
||||
;; Declarations — functions / methods / properties
|
||||
;;
|
||||
;; A generic FUNCTION's parameters are read for the same reason a generic type's
|
||||
;; are (#2912 review): \`fun <T> runAny(v: Validator<T>)\` writes a receiver whose
|
||||
;; argument is a type VARIABLE, and a pass that cannot tell that from a concrete
|
||||
;; type prunes every implementor from the call's dispatch fan-out.
|
||||
(function_declaration
|
||||
(type_parameters)? @declaration.type-parameters
|
||||
(simple_identifier) @declaration.name) @declaration.function
|
||||
|
||||
;; Lambda bound to a val/var: val handler = { x: Int -> target(x) }
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import {
|
||||
findChild,
|
||||
nodeIfType,
|
||||
nodeToCapture,
|
||||
syntheticCapture,
|
||||
|
|
@ -252,10 +253,22 @@ function synthesizeRustInheritanceReferences(root: SyntaxNode): CaptureMatch[] {
|
|||
const traitName = bareTypeIdentifier(traitField);
|
||||
const structName = bareTypeIdentifier(typeField);
|
||||
if (traitName === null || structName === null) return;
|
||||
// The trait's generic ARGUMENTS (`impl Validator<String> for V`), so
|
||||
// interface dispatch can tell one instantiation of a trait from another
|
||||
// (#2912). Emitted as a sub-tag rather than by widening the anchor: the
|
||||
// anchor is the bare `type_identifier` inside the `generic_type`, and its
|
||||
// range is part of the inheritance edge's id.
|
||||
const traitArguments =
|
||||
traitField.type === 'generic_type' ? findChild(traitField, 'type_arguments') : null;
|
||||
out.push({
|
||||
'@reference.inherits': nodeToCapture('@reference.inherits', traitName),
|
||||
'@reference.name': nodeToCapture('@reference.name', traitName),
|
||||
'@reference.receiver': syntheticCapture('@reference.receiver', structName, structName.text),
|
||||
...(traitArguments === null
|
||||
? {}
|
||||
: {
|
||||
'@reference.type-arguments': nodeToCapture('@reference.type-arguments', traitArguments),
|
||||
}),
|
||||
});
|
||||
});
|
||||
return out;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js';
|
||||
import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js';
|
||||
import type { HeritageTypeArgumentSink } from '../../scope-resolution/utils/generic-instantiation.js';
|
||||
import type { KnowledgeGraph } from '../../../graph/types.js';
|
||||
import { generateId } from '../../../../lib/utils.js';
|
||||
|
||||
|
|
@ -54,6 +55,7 @@ function emitRustTraitImplEdges(
|
|||
parsedFiles: readonly ParsedFile[],
|
||||
nodeLookup: GraphNodeLookup,
|
||||
scopes: ScopeResolutionIndexes | undefined,
|
||||
recordTypeArguments?: HeritageTypeArgumentSink,
|
||||
): void {
|
||||
if (scopes === undefined) return;
|
||||
|
||||
|
|
@ -83,6 +85,14 @@ function emitRustTraitImplEdges(
|
|||
const traitGraphId = resolveDefGraphId(traitDef.filePath, traitDef, nodeLookup);
|
||||
if (structGraphId === undefined || traitGraphId === undefined) continue;
|
||||
|
||||
// The instantiation the impl was written with — `impl Validator<String>
|
||||
// for V` (#2912). Recorded against THIS edge's ids, not the pre-pass's:
|
||||
// the pre-pass sources its edge from the enclosing def, and interface
|
||||
// dispatch crosses the corrected one emitted here.
|
||||
if (site.typeArguments !== undefined) {
|
||||
recordTypeArguments?.(structGraphId, traitGraphId, site.typeArguments);
|
||||
}
|
||||
|
||||
const edgeKey = `${structGraphId}->${traitGraphId}`;
|
||||
if (emitted.has(edgeKey)) continue;
|
||||
emitted.add(edgeKey);
|
||||
|
|
@ -159,8 +169,8 @@ export const rustScopeResolver: ScopeResolver = {
|
|||
|
||||
buildMro: (graph, parsedFiles, nodeLookup) => buildRustMro(graph, parsedFiles, nodeLookup),
|
||||
|
||||
emitHeritageEdges: (graph, parsedFiles, nodeLookup, scopes) =>
|
||||
emitRustTraitImplEdges(graph, parsedFiles, nodeLookup, scopes),
|
||||
emitHeritageEdges: (graph, parsedFiles, nodeLookup, scopes, recordTypeArguments) =>
|
||||
emitRustTraitImplEdges(graph, parsedFiles, nodeLookup, scopes, recordTypeArguments),
|
||||
|
||||
populateOwners: (parsed: ParsedFile) => populateRustOwners(parsed),
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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),
|
||||
}),
|
||||
|
||||
|
|
|
|||
338
gitnexus/src/core/ingestion/languages/typescript/tsconfig.ts
Normal file
338
gitnexus/src/core/ingestion/languages/typescript/tsconfig.ts
Normal 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}`;
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>`.
|
||||
|
|
|
|||
|
|
@ -316,6 +316,7 @@ export const csharpMethodConfig: MethodExtractionConfig = {
|
|||
annotations: [], // C# has no syntax for attributes on primary constructors
|
||||
sourceFile: context.filePath,
|
||||
line: paramList.startPosition.row + 1,
|
||||
column: paramList.startPosition.column,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -256,5 +256,6 @@ function buildMethod(
|
|||
annotations: config.extractAnnotations?.(node) ?? [],
|
||||
sourceFile: context.filePath,
|
||||
line: node.startPosition.row + 1,
|
||||
column: node.startPosition.column,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,22 @@ export interface MethodInfo {
|
|||
annotations: string[];
|
||||
sourceFile: string;
|
||||
line: number;
|
||||
/**
|
||||
* 0-based `startPosition.column` of the node `line` was derived from.
|
||||
*
|
||||
* `line` alone does NOT identify a callable. A callable that is SYNTHESIZED
|
||||
* at a position that is not its own declaration shares its owner's line: a
|
||||
* Java record's implicit component accessor is minted at the COMPONENT, and a
|
||||
* C# 12 primary constructor at the owner's `parameter_list`. So both
|
||||
* `record P(int x, int y) { int x(int s) {…} }` and
|
||||
* `class Point(int x, int y) { public Point(int x) : this(x, 0) {} }` give two
|
||||
* different callables the same (name, line) (#2936).
|
||||
*
|
||||
* Required, not optional: the per-class map in parse-worker keys on it, and
|
||||
* an absent column would key an entry no lookup could ever reach — a silent,
|
||||
* whole-language loss of method enrichment rather than a compile error.
|
||||
*/
|
||||
column: number;
|
||||
}
|
||||
|
||||
export interface MethodExtractorContext {
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ import type {
|
|||
CallableFlowOperand,
|
||||
CallableFlowPassingMode,
|
||||
CallableFlowSite,
|
||||
Capture,
|
||||
CaptureMatch,
|
||||
ImportEdge,
|
||||
ParameterTypeClass,
|
||||
|
|
@ -97,7 +98,11 @@ import type {
|
|||
import { buildPositionIndex, buildScopeTree, canParentScope, makeScopeId } from 'gitnexus-shared';
|
||||
import type { LanguageProvider } from './language-provider.js';
|
||||
import { isValidReceiverChain } from './utils/receiver-chain-codec.js';
|
||||
import { extractTemplateArguments } from './utils/template-arguments.js';
|
||||
import {
|
||||
extractTemplateArguments,
|
||||
stripTrailingCallSuffix,
|
||||
typeApplicationArguments,
|
||||
} from './utils/template-arguments.js';
|
||||
import { parseTypeParameterList } from './utils/type-parameters.js';
|
||||
|
||||
// ─── Narrow hook surface the extractor actually uses ───────────────────────
|
||||
|
|
@ -1255,6 +1260,12 @@ function pass5CollectReferences(
|
|||
// sibling via the full-path QualifiedNameIndex before the simple-tail walk
|
||||
// (#1982). Absent for unqualified references — resolution stays unchanged.
|
||||
const qualifiedCap = match['@reference.qualified-name'];
|
||||
// Generic ARGUMENTS written on a heritage reference (`: IValidator<string>`);
|
||||
// `inherits` only, because a call/read/write anchor spans the whole call
|
||||
// expression, whose `<…>` would be an argument list, a comparison, or
|
||||
// nothing at all — widening the kind would mint confident nonsense (#2912).
|
||||
const typeArguments =
|
||||
kind === 'inherits' ? heritageTypeArguments(match, anchor, nameCap) : undefined;
|
||||
const inScopeId = positionIndex.atPosition(
|
||||
filePath,
|
||||
anchor.range.startLine,
|
||||
|
|
@ -1306,6 +1317,7 @@ function pass5CollectReferences(
|
|||
...(qualifiedCap?.text !== undefined && qualifiedCap.text.length > 0
|
||||
? { rawQualifiedName: qualifiedCap.text }
|
||||
: {}),
|
||||
...(typeArguments !== undefined ? { typeArguments } : {}),
|
||||
...(propertyKeyCap?.text !== undefined && propertyKeyCap.text.length > 0
|
||||
? { propertyKey: propertyKeyCap.text }
|
||||
: {}),
|
||||
|
|
@ -1322,6 +1334,60 @@ function pass5CollectReferences(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The generic arguments a heritage reference was written with, by whichever of
|
||||
* the two routes this emitter uses (#2912).
|
||||
*
|
||||
* `@reference.type-arguments` is the explicit route, for an emitter whose anchor
|
||||
* is the bare NAME node (Rust's `impl Trait for S` anchors on the trait
|
||||
* identifier inside a `generic_type`). It wins where present: moving such an
|
||||
* anchor to cover the arguments would change the site's range, and that range is
|
||||
* part of every inheritance EDGE ID — a spelling detail must not renumber the
|
||||
* graph. Every other emitter already anchors on the whole base, so its spelling
|
||||
* is read directly and no query changed.
|
||||
*/
|
||||
function heritageTypeArguments(
|
||||
match: CaptureMatch,
|
||||
anchor: Capture,
|
||||
nameCap: Capture,
|
||||
): readonly string[] | undefined {
|
||||
const explicit = match['@reference.type-arguments']?.text;
|
||||
return explicit !== undefined
|
||||
? typeApplicationArguments(explicit)
|
||||
: referenceTypeArguments(anchor.text, nameCap.text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type arguments written on a heritage reference, read from the anchor's own
|
||||
* spelling — `IValidator<string>` → `['string']` (#2912).
|
||||
*
|
||||
* Two shapes are handled before the spelling is read as an application:
|
||||
*
|
||||
* - A trailing CONSTRUCTOR INVOCATION is dropped. `record R : Base<int>(x)`
|
||||
* and Kotlin `class C : Bar<Int>()` write a call in the heritage position;
|
||||
* the call is not part of the type, and leaving it attached would make the
|
||||
* list fail to close at the end and lose the arguments entirely.
|
||||
* - The application's base must BE the referenced name (`Other::Inner<T>`
|
||||
* ends with `Inner`). An anchor that spans more than the base type is not
|
||||
* read at all rather than read wrongly.
|
||||
*
|
||||
* `undefined` for a non-generic base and for every spelling that is not exactly
|
||||
* one balanced argument list — absence is the "unknown" value that consumers
|
||||
* fail open on, so declining is always safe here.
|
||||
*/
|
||||
function referenceTypeArguments(
|
||||
anchorText: string,
|
||||
baseName: string,
|
||||
): readonly string[] | undefined {
|
||||
const text = stripTrailingCallSuffix(anchorText.trim());
|
||||
const opener = text.search(OPENING_BRACKET);
|
||||
if (opener === -1) return undefined;
|
||||
if (!text.slice(0, opener).trimEnd().endsWith(baseName)) return undefined;
|
||||
return typeApplicationArguments(text);
|
||||
}
|
||||
|
||||
const OPENING_BRACKET = /[<[]/;
|
||||
|
||||
function referenceKindFromAnchor(name: string): ReferenceKind | undefined {
|
||||
const suffix = name.slice('@reference.'.length);
|
||||
// Strip sub-tag after the kind (`@reference.call.member` → `call`).
|
||||
|
|
@ -1720,6 +1786,10 @@ const KNOWN_SUB_TAGS: ReadonlySet<string> = new Set<string>([
|
|||
'@type-binding.type',
|
||||
'@reference.name',
|
||||
'@reference.qualified-name',
|
||||
// The generic arguments a heritage base was written with, when the emitter's
|
||||
// anchor is the bare name and cannot carry them (#2912). A sub-tag for the
|
||||
// usual reason: it spans a sibling node of the anchor, never the site itself.
|
||||
'@reference.type-arguments',
|
||||
'@reference.property-key',
|
||||
'@reference.callee-position',
|
||||
'@reference.embedded-pointer',
|
||||
|
|
|
|||
|
|
@ -297,6 +297,7 @@ import { LanguageProvider } from '../../language-provider.js';
|
|||
import { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import type { SemanticModel } from '../../model/semantic-model.js';
|
||||
import type { ConversionRankFn } from '../passes/overload-narrowing.js';
|
||||
import type { HeritageTypeArgumentSink } from '../utils/generic-instantiation.js';
|
||||
import type { WorkspaceResolutionIndex } from '../workspace-index.js';
|
||||
|
||||
/** A LinearizeStrategy receives the full ancestor map so C3-style
|
||||
|
|
@ -586,6 +587,16 @@ export interface ScopeResolver {
|
|||
* shape. Must be idempotent (the orchestrator may call it more than once
|
||||
* during re-resolution).
|
||||
*
|
||||
* `recordTypeArguments` is the same sink `preEmitInheritanceEdges` writes to:
|
||||
* the generic INSTANTIATION a heritage clause was written with, so
|
||||
* interface-dispatch fan-out can refuse an implementor of an incompatible one
|
||||
* (#2912). An implementation that emits an edge for a generic base
|
||||
* (`impl Validator<String> for V`, `class V implements Validator<String>`)
|
||||
* should call it with the same (source, target) graph ids it just used;
|
||||
* anything not recorded reads as "unknown" and keeps the pre-#2912 fan-out.
|
||||
* Ignoring it entirely is correct for a language whose heritage carries no
|
||||
* type arguments (Ruby `include`).
|
||||
*
|
||||
* Default: undefined (no extra heritage edges needed).
|
||||
*/
|
||||
readonly emitHeritageEdges?: (
|
||||
|
|
@ -593,6 +604,7 @@ export interface ScopeResolver {
|
|||
parsedFiles: readonly ParsedFile[],
|
||||
nodeLookup: GraphNodeLookup,
|
||||
scopes?: ScopeResolutionIndexes,
|
||||
recordTypeArguments?: HeritageTypeArgumentSink,
|
||||
) => void;
|
||||
|
||||
/**
|
||||
|
|
@ -1006,6 +1018,25 @@ export interface ScopeResolver {
|
|||
*/
|
||||
readonly isStaticOnly?: (def: SymbolDefinition) => boolean;
|
||||
|
||||
/**
|
||||
* Optional canonicalizer for a written GENERIC TYPE ARGUMENT, so two
|
||||
* spellings of one type compare equal during interface-dispatch
|
||||
* instantiation matching (#2912).
|
||||
*
|
||||
* The case it exists for is a language with predefined ALIASES: C# `string`
|
||||
* and `String` are the same type, so `IValidator<string>` must still fan out
|
||||
* to `class V : IValidator<String>`. Without the hook the two spellings look
|
||||
* like two instantiations and the implementor is pruned — a missing edge,
|
||||
* which is the failure direction #2912 is most concerned to avoid.
|
||||
*
|
||||
* Called ONLY on the two sides of one argument comparison, never on a name
|
||||
* used for lookup, so it may map to whatever canonical form the language
|
||||
* prefers (`string` → `String`, or the reverse) as long as it is consistent.
|
||||
* Languages whose types have one spelling each leave it undefined and the
|
||||
* comparison stays exact.
|
||||
*/
|
||||
readonly normalizeTypeArgument?: (name: string) => string;
|
||||
|
||||
/**
|
||||
* Optional predicate to gate free-call fallback emission by caller-side
|
||||
* visibility. When provided, `pickUniqueGlobalCallable` rejects candidates
|
||||
|
|
|
|||
|
|
@ -24,7 +24,11 @@ import type { ScopeId, SymbolDefinition, TypeRef } from 'gitnexus-shared';
|
|||
import type { ElementAccessRoute, ScopeResolver } from '../contract/scope-resolver.js';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import type { WorkspaceResolutionIndex } from '../workspace-index.js';
|
||||
import { erasedTypeApplication, stripTemplateArguments } from '../../utils/template-arguments.js';
|
||||
import {
|
||||
erasedTypeApplication,
|
||||
matchingOpenParen,
|
||||
stripTemplateArguments,
|
||||
} from '../../utils/template-arguments.js';
|
||||
import type { DecodedReceiverChain } from '../../utils/receiver-chain-codec.js';
|
||||
import { decodeReceiverChain } from '../../utils/receiver-chain-codec.js';
|
||||
import type { DecorationStripper } from '../scope/walkers.js';
|
||||
|
|
@ -75,7 +79,22 @@ function parseMapTupleSentinel(text: string): { tupleIdx: number; rhs: string }
|
|||
return { tupleIdx: Number(idxStr), rhs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Notified with the spelling a receiver position was typed from and the class
|
||||
* it resolved to — see {@link noteReceiverType}. Pure side channel: this file
|
||||
* never reads it back, and resolution is identical whether or not it is set.
|
||||
*/
|
||||
type ReceiverTypeRecorder = (spelling: string, defId: string) => void;
|
||||
|
||||
interface ResolveCompoundReceiverOptions {
|
||||
/**
|
||||
* Optional sink for the DECLARED TYPE SPELLINGS this fold typed receiver
|
||||
* positions from (#2912). The fold returns a class, and a class has lost the
|
||||
* generic arguments that decide which implementations an interface-typed
|
||||
* receiver can dispatch to; the caller keeps the last report whose def id
|
||||
* matches the returned class and reads the arguments off that spelling.
|
||||
*/
|
||||
readonly recordReceiverType?: ReceiverTypeRecorder;
|
||||
/** When true (default), if method lookup fails on the receiver's
|
||||
* class, walk its fields and try the lookup on each field's class.
|
||||
* Phase-9C "unified fixpoint" — Python-shaped heuristic. */
|
||||
|
|
@ -348,17 +367,65 @@ function classOfDeclaredType(
|
|||
typeRef: TypeRef,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
stripDecoration?: DecorationStripper,
|
||||
recordReceiverType?: ReceiverTypeRecorder,
|
||||
): SymbolDefinition | undefined {
|
||||
// `declaredAtScope`, never a scope the caller chose: all five sites passed
|
||||
// exactly this `TypeRef`'s own anchor, and taking it as a parameter is what
|
||||
// would let a sixth quietly not — which is the hole this helper exists to
|
||||
// close, one level up.
|
||||
return resolveClassBindingForName(
|
||||
const spelling = erasedTypeApplication(typeRef) ?? typeRef.rawName;
|
||||
const def = resolveClassBindingForName(
|
||||
typeRef.declaredAtScope,
|
||||
erasedTypeApplication(typeRef) ?? typeRef.rawName,
|
||||
spelling,
|
||||
scopes,
|
||||
stripDecoration,
|
||||
);
|
||||
return noteReceiverType(recordReceiverType, spelling, def);
|
||||
}
|
||||
|
||||
/**
|
||||
* Report the SPELLING a receiver position was typed from, alongside the class
|
||||
* it resolved to (#2912).
|
||||
*
|
||||
* The fold answers "which class", which is all dispatch needed until generic
|
||||
* instantiation mattered: `IValidator<string>` and `IValidator<int>` fold to
|
||||
* the same declaration. The spelling is the only place the arguments survive,
|
||||
* and it exists at every one of these lookups already — reporting it costs a
|
||||
* function call and changes no resolution.
|
||||
*
|
||||
* Pairing it with the def id is what makes it usable: the caller keeps the LAST
|
||||
* report and uses it only if it names the class the fold ultimately returned,
|
||||
* so a route that typed an intermediate position, or a later route that
|
||||
* answered differently, cannot lend its arguments to another class.
|
||||
*/
|
||||
function noteReceiverType(
|
||||
record: ReceiverTypeRecorder | undefined,
|
||||
spelling: string,
|
||||
def: SymbolDefinition | undefined,
|
||||
): SymbolDefinition | undefined {
|
||||
if (def !== undefined) record?.(spelling, def.nodeId);
|
||||
return def;
|
||||
}
|
||||
|
||||
/**
|
||||
* The class a CALL's return type names, reported to the receiver-type side
|
||||
* channel — the return-type twin of {@link classOfDeclaredType}.
|
||||
*
|
||||
* The pairing it exists to keep in one place: the lookup goes through
|
||||
* `rawName`, while the SPELLING reported alongside it is the erased type
|
||||
* application, so an `IValidator<string>` return is reported with its
|
||||
* arguments intact. The spelling is built only once the lookup has actually
|
||||
* found a class, because it is discarded otherwise — and every fold hop
|
||||
* through a call reaches this, generic or not.
|
||||
*/
|
||||
function classOfReturnType(
|
||||
retType: TypeRef,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
record: ReceiverTypeRecorder | undefined,
|
||||
): SymbolDefinition | undefined {
|
||||
const def = findClassBindingInScope(retType.declaredAtScope, retType.rawName, scopes);
|
||||
if (def === undefined || record === undefined) return def;
|
||||
return noteReceiverType(record, erasedTypeApplication(retType) ?? retType.rawName, def);
|
||||
}
|
||||
|
||||
function typeOfMemberOnClass(
|
||||
|
|
@ -374,7 +441,12 @@ function typeOfMemberOnClass(
|
|||
const classScope = classScopeByDefId.get(ownerId);
|
||||
const memberType = classScope?.typeBindings.get(memberName);
|
||||
if (memberType !== undefined) {
|
||||
const def = classOfDeclaredType(memberType, scopes, options.stripTypePreservingDecoration);
|
||||
const def = classOfDeclaredType(
|
||||
memberType,
|
||||
scopes,
|
||||
options.stripTypePreservingDecoration,
|
||||
options.recordReceiverType,
|
||||
);
|
||||
// The declared type is reported even when it resolved to no class:
|
||||
// `Promise<User>` and `[]Repo` name nothing in the workspace, and an
|
||||
// await or index step unwrapping them is exactly how they become
|
||||
|
|
@ -404,7 +476,12 @@ function typeOfMemberOnClass(
|
|||
// Same stripper the primary branch above passes. Omitting it here
|
||||
// meant a decorated declared type (`*Host`) resolved on one branch and
|
||||
// not the other, for the same member of the same class.
|
||||
const def = classOfDeclaredType(hoisted, scopes, options.stripTypePreservingDecoration);
|
||||
const def = classOfDeclaredType(
|
||||
hoisted,
|
||||
scopes,
|
||||
options.stripTypePreservingDecoration,
|
||||
options.recordReceiverType,
|
||||
);
|
||||
// Identical to the primary branch: a declared type that named no
|
||||
// class is still a usable position when the next step unwraps it.
|
||||
// Returning `undefined` here made `svc.getMap()['k'].run()` decline
|
||||
|
|
@ -569,9 +646,68 @@ export function foldReceiverChain(
|
|||
}
|
||||
// A chain that ended without a class returns undefined naturally — no
|
||||
// separate guard, because `def` IS the signal.
|
||||
//
|
||||
// The receiver-type report is made HERE, from the final `FoldState`, because
|
||||
// that record pairs the class with the spelling that produced it BY
|
||||
// CONSTRUCTION — same step, same lookup. The individual `classOfDeclaredType`
|
||||
// calls inside the fold also report, including from steps that were later
|
||||
// folded past, so the last of those is not reliably about the class the fold
|
||||
// returns. Reporting the final state last makes it the one that stands.
|
||||
if (current.def !== undefined && current.declaredType !== undefined) {
|
||||
options.recordReceiverType?.(current.declaredType, current.def.nodeId);
|
||||
}
|
||||
return current.def;
|
||||
}
|
||||
|
||||
/** A resolved compound receiver, together with the declared spelling that typed
|
||||
* the position it came from — see {@link resolveCompoundReceiverTyped}. */
|
||||
export interface TypedCompoundReceiver {
|
||||
readonly def: SymbolDefinition;
|
||||
/**
|
||||
* The receiver's declared type AS WRITTEN (`IValidator<string>`), or
|
||||
* `undefined` where the route that answered had no declared type to report — a
|
||||
* construction expression, a namespace target, a static class receiver. The
|
||||
* fan-out reads its generic arguments off this and restores the unfiltered
|
||||
* behaviour when it is absent, so declining is always safe (#2912).
|
||||
*/
|
||||
readonly declaredSpelling: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link resolveCompoundReceiverClass}, paired with the spelling that typed the
|
||||
* position (#2912).
|
||||
*
|
||||
* The sink is created and read HERE, per call, which is the whole point: a
|
||||
* recorder that outlives one resolution has to be reset by hand before every
|
||||
* call, and the retry shapes in this pass make two calls in a row — a reset
|
||||
* missed at one of them silently attributes the previous receiver's spelling to
|
||||
* this one. A local cannot be forgotten.
|
||||
*
|
||||
* The def-id guard is the second half. Lookups that lost — an MRO walk that
|
||||
* moved on, a fold step later folded past — report too, so a report counts only
|
||||
* when it names the class actually returned. `foldReceiverChain` reports its
|
||||
* final state last for exactly this reason, so the structural route wins.
|
||||
*/
|
||||
export function resolveCompoundReceiverTyped(
|
||||
receiverText: string,
|
||||
inScope: ScopeId,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
index: WorkspaceResolutionIndex,
|
||||
options: ResolveCompoundReceiverOptions = {},
|
||||
): TypedCompoundReceiver | undefined {
|
||||
let spelling: string | undefined;
|
||||
let spellingDefId: string | undefined;
|
||||
const def = resolveCompoundReceiverClass(receiverText, inScope, scopes, index, {
|
||||
...options,
|
||||
recordReceiverType: (reported, defId) => {
|
||||
spelling = reported;
|
||||
spellingDefId = defId;
|
||||
},
|
||||
});
|
||||
if (def === undefined) return undefined;
|
||||
return { def, declaredSpelling: spellingDefId === def.nodeId ? spelling : undefined };
|
||||
}
|
||||
|
||||
export function resolveCompoundReceiverClass(
|
||||
receiverText: string,
|
||||
inScope: ScopeId,
|
||||
|
|
@ -676,7 +812,12 @@ export function resolveCompoundReceiverClass(
|
|||
return findClassBindingInScope(rhsTb.declaredAtScope, arg, scopes);
|
||||
}
|
||||
|
||||
const viaTb = classOfDeclaredType(tb, scopes, options.stripTypePreservingDecoration);
|
||||
const viaTb = classOfDeclaredType(
|
||||
tb,
|
||||
scopes,
|
||||
options.stripTypePreservingDecoration,
|
||||
options.recordReceiverType,
|
||||
);
|
||||
if (viaTb !== undefined) return viaTb;
|
||||
|
||||
// Member-alias / call-result shapes store the RHS path on rawName
|
||||
|
|
@ -769,7 +910,7 @@ export function resolveCompoundReceiverClass(
|
|||
const viaReturn =
|
||||
retType === undefined
|
||||
? undefined
|
||||
: findClassBindingInScope(retType.declaredAtScope, retType.rawName, scopes);
|
||||
: classOfReturnType(retType, scopes, options.recordReceiverType);
|
||||
if (viaReturn !== undefined) return viaReturn;
|
||||
}
|
||||
// Inline construction — `Service(db).m()` / `new Service(db).m()`.
|
||||
|
|
@ -891,7 +1032,7 @@ export function resolveCompoundReceiverClass(
|
|||
}
|
||||
|
||||
if (retType === undefined) return undefined;
|
||||
return findClassBindingInScope(retType.declaredAtScope, retType.rawName, scopes);
|
||||
return classOfReturnType(retType, scopes, options.recordReceiverType);
|
||||
}
|
||||
|
||||
// Mixed dotted + call chain: `obj.field.method().field.method()…`.
|
||||
|
|
@ -967,7 +1108,7 @@ export function resolveCompoundReceiverClass(
|
|||
// two had a fixture. See `classOfDeclaredType` for why this cannot change a
|
||||
// `TypeRef` that was never reduced.
|
||||
let currentClass: SymbolDefinition | undefined = headType
|
||||
? classOfDeclaredType(headType, scopes)
|
||||
? classOfDeclaredType(headType, scopes, undefined, options.recordReceiverType)
|
||||
: findClassBindingInScope(inScope, headMemberName, scopes);
|
||||
// Whether the walk currently sits on the CLASS ITSELF rather than on a
|
||||
// value of that class. Seeded true only when the head resolved straight to
|
||||
|
|
@ -1097,7 +1238,7 @@ export function resolveCompoundReceiverClass(
|
|||
// grounds fell through here — a declined fold is documented as "no answer",
|
||||
// never a veto — and this walk re-minted `other.py:Mapped` from the
|
||||
// workspace index. Same rule, same lookup, so the two routes now agree.
|
||||
let nextClass = classOfDeclaredType(memberType, scopes);
|
||||
let nextClass = classOfDeclaredType(memberType, scopes, undefined, options.recordReceiverType);
|
||||
if (nextClass === undefined) {
|
||||
const fromMap = unwrapMapValueToClass(memberType, scopes);
|
||||
if (fromMap !== undefined) nextClass = fromMap;
|
||||
|
|
@ -1167,22 +1308,6 @@ function isInitializerContext(startScope: ScopeId, scopes: ScopeResolutionIndexe
|
|||
return false;
|
||||
}
|
||||
|
||||
/** Find the index of the `(` that matches the trailing `)` of a
|
||||
* call-expression text. Returns -1 if unbalanced. */
|
||||
function matchingOpenParen(text: string): number {
|
||||
if (!text.endsWith(')')) return -1;
|
||||
let depth = 0;
|
||||
for (let i = text.length - 1; i >= 0; i--) {
|
||||
const ch = text[i];
|
||||
if (ch === ')') depth++;
|
||||
else if (ch === '(') {
|
||||
depth--;
|
||||
if (depth === 0) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Max peel iterations for `stripCastWrappers`. Real cast nesting —
|
||||
* including decompiler output like `((Target)((Object)expr))` —
|
||||
* is a handful of levels, and each cast level costs at most two
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
|
|||
import type { WorkspaceResolutionIndex } from '../workspace-index.js';
|
||||
import { collectNamespaceTargets } from '../scope/namespace-targets.js';
|
||||
import {
|
||||
bindsTypeParameter,
|
||||
findClassBindingInScope,
|
||||
findEnclosingClassDef,
|
||||
isReceiverOwnedButUnbound,
|
||||
|
|
@ -86,8 +87,17 @@ import {
|
|||
type CalleeIdCaptureCtx,
|
||||
} from '../graph-bridge/edges.js';
|
||||
import type { CalleeIdSink } from '../graph-bridge/callee-id-sink.js';
|
||||
import { resolveCompoundReceiverClass } from '../passes/compound-receiver.js';
|
||||
import { erasedTypeApplication } from '../../utils/template-arguments.js';
|
||||
import {
|
||||
resolveCompoundReceiverClass,
|
||||
resolveCompoundReceiverTyped,
|
||||
} from '../passes/compound-receiver.js';
|
||||
import { erasedTypeApplication, typeApplicationArguments } from '../../utils/template-arguments.js';
|
||||
import {
|
||||
heritageTypeArgumentsKey,
|
||||
stepHeritageInstantiation,
|
||||
type GroundedTypeArgument,
|
||||
type HeritageTypeArguments,
|
||||
} from '../utils/generic-instantiation.js';
|
||||
import { resolveDefGraphId } from '../graph-bridge/ids.js';
|
||||
import {
|
||||
narrowOverloadCandidates,
|
||||
|
|
@ -124,6 +134,7 @@ type ReceiverBoundProviderSubset = Pick<
|
|||
| 'conversionOnlyArgTypePrefixes'
|
||||
| 'constraintCompatibility'
|
||||
| 'isStaticOnly'
|
||||
| 'normalizeTypeArgument'
|
||||
>;
|
||||
|
||||
/** A bare, undecorated identifier and nothing else — see {@link isBareTypeName}. */
|
||||
|
|
@ -298,6 +309,14 @@ export function emitReceiverBoundCalls(
|
|||
* degrades a drop's label to `unknown` (the safe direction) and changes no
|
||||
* edge. */
|
||||
readonly isBuiltInName?: (name: string) => boolean;
|
||||
/** The generic arguments each heritage clause instantiated its base with,
|
||||
* from the passes that emitted those heritage edges — the inheritance
|
||||
* pre-pass, and the language resolvers that emit their own (Rust `impl T
|
||||
* for S`, Dart `implements` / `with`) (#2912). Read
|
||||
* ONLY by the interface-dispatch fan-out, to refuse an implementor of an
|
||||
* incompatible instantiation. Absent ⇒ every heritage instantiation reads
|
||||
* as unknown ⇒ the pre-#2912 fan-out, unchanged. */
|
||||
readonly heritageTypeArguments?: HeritageTypeArguments;
|
||||
} = {},
|
||||
): ReceiverBoundResult {
|
||||
let emitted = 0;
|
||||
|
|
@ -331,6 +350,26 @@ export function emitReceiverBoundCalls(
|
|||
// DefIds, and `pickOverload` keys member lookup by those DefIds. Preserving
|
||||
// every part makes dispatch independent of declaration order.
|
||||
const graphIdToClassDefs = new Map<string, SymbolDefinition[]>();
|
||||
// The same correspondence read the other way, so the dispatch walk can name a
|
||||
// heritage EDGE (which is keyed by graph ids) from the two DEFS it holds.
|
||||
const classGraphIdByDefId = new Map<string, string>();
|
||||
/**
|
||||
* Does THIS language record generic type parameters (#2912)?
|
||||
*
|
||||
* `SymbolDefinition.typeParameters` is absent both for a non-generic
|
||||
* declaration and for every declaration in a language whose captures do not
|
||||
* emit `@declaration.type-parameters`, and instantiation filtering needs the
|
||||
* two told apart: in the second case a heritage argument `T` is a type
|
||||
* VARIABLE that would otherwise read as a concrete type named "T", and
|
||||
* `class Box<T> : IValidator<T>` would be pruned out of every instantiation.
|
||||
*
|
||||
* Evidence rather than a declared capability, because the evidence is exactly
|
||||
* as good and costs nothing: one run resolves one language (`phase.ts` loops
|
||||
* per language), so a single generic declaration anywhere in it proves the
|
||||
* captures record parameters. A run where none exists cannot be harmed by the
|
||||
* answer — with no generic declaration there is no type variable to mistake.
|
||||
*/
|
||||
let languageCapturesTypeParameters = false;
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const def of parsed.localDefs) {
|
||||
if (!isClassLike(def.type)) continue;
|
||||
|
|
@ -342,6 +381,10 @@ export function emitReceiverBoundCalls(
|
|||
graphIdToClassDefs.set(graphId, defs);
|
||||
}
|
||||
defs.push(def);
|
||||
classGraphIdByDefId.set(def.nodeId, graphId);
|
||||
if (def.typeParameters !== undefined && def.typeParameters.length > 0) {
|
||||
languageCapturesTypeParameters = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Direct subtypes of a type, keyed by the SUPERtype's def id.
|
||||
|
|
@ -433,6 +476,34 @@ export function emitReceiverBoundCalls(
|
|||
return graph.getNode(graphId)?.properties.isStatic === true;
|
||||
};
|
||||
|
||||
/**
|
||||
* What does this written type argument NAME, as seen from `scopeId` (#2912)?
|
||||
*
|
||||
* The scope is load-bearing: a heritage argument is resolved from the
|
||||
* declaring class's own scope and a receiver argument from the call site's,
|
||||
* because a name means what it means where it was WRITTEN. Resolving both
|
||||
* makes `Models.User` and an imported `User` one type, which a string
|
||||
* comparison could only get wrong.
|
||||
*
|
||||
* Neither answer is an error: a name that binds nothing and is not built in
|
||||
* comes back ungrounded, which the matcher reads as "unknown" and keeps.
|
||||
*
|
||||
* A TYPE PARAMETER is reported as such rather than left to the ungrounded
|
||||
* path, because `resolveClassBindingForName` answers a bounded one with its
|
||||
* BOUND's declaration — grounded, and the wrong thing to compare.
|
||||
*/
|
||||
const groundTypeArgument = (name: string, scopeId: string | undefined): GroundedTypeArgument => {
|
||||
const def =
|
||||
scopeId === undefined ? undefined : resolveClassBindingForName(scopeId, name, scopes);
|
||||
return {
|
||||
...(def !== undefined ? { definitionId: def.nodeId } : {}),
|
||||
builtIn: options.isBuiltInName?.(name) === true,
|
||||
...(scopeId !== undefined && bindsTypeParameter(scopeId, name, scopes)
|
||||
? { typeVariable: true }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Emit secondary CALLS edges with reason='interface-dispatch' when the primary
|
||||
* receiver-typed edge targeted an Interface's method.
|
||||
|
|
@ -454,6 +525,17 @@ export function emitReceiverBoundCalls(
|
|||
* override further down is an equally real runtime target — dispatch is an
|
||||
* over-approximation by design, and stopping early would silently prefer the
|
||||
* base.
|
||||
*
|
||||
* The closure is walked carrying the receiver's generic INSTANTIATION (#2912).
|
||||
* `IValidator<string>` and `IValidator<int>` are one declaration and therefore
|
||||
* one subtype list, so without the substitution a `IValidator<string>` call
|
||||
* reaches `IntValidator.Check(int)` — a target no dispatch can produce. Each
|
||||
* hop unifies the arguments the subtype wrote against the ones the supertype
|
||||
* is known to hold; an incompatible hop is skipped BEFORE the visit is
|
||||
* recorded, so a type reachable by a second, compatible path still gets its
|
||||
* edge, and skipped WITHOUT descending, because its own subtypes inherit the
|
||||
* mismatch.
|
||||
* Every uncertainty keeps the target — see `generic-instantiation.ts`.
|
||||
*/
|
||||
const emitInterfaceDispatchFor = (
|
||||
ownerDef: SymbolDefinition,
|
||||
|
|
@ -462,9 +544,26 @@ export function emitReceiverBoundCalls(
|
|||
site: ParsedFile['referenceSites'][number],
|
||||
confidence: number,
|
||||
calleeCapture: CalleeIdCaptureCtx | undefined,
|
||||
/** The receiver's declared type AS WRITTEN (`IValidator<string>`), or
|
||||
* `undefined` where the case could not recover it — which restores the
|
||||
* unfiltered fan-out for that site rather than guessing.
|
||||
*
|
||||
* The SPELLING rather than the parsed arguments, so the parse happens after
|
||||
* the two gates below rather than at every resolved receiver site: all five
|
||||
* cases call this unconditionally, and the overwhelming majority of
|
||||
* receivers are concrete classes that return at the first line. */
|
||||
receiverTypeSpelling: string | undefined,
|
||||
): number => {
|
||||
if (ownerDef.type !== 'Interface') return 0;
|
||||
if (subtypesBySupertypeDefId.get(ownerDef.nodeId) === undefined) return 0;
|
||||
const receiverTypeArguments =
|
||||
receiverTypeSpelling === undefined
|
||||
? undefined
|
||||
: typeApplicationArguments(receiverTypeSpelling);
|
||||
// Captures only `site`, so it is built once per SITE rather than once per
|
||||
// subtype visited. Its partner below cannot be: it is keyed by the subtype.
|
||||
const resolveSupertypeArgument = (name: string): GroundedTypeArgument =>
|
||||
groundTypeArgument(name, site.inScope);
|
||||
|
||||
// Collect concrete targets across the closure first, so the cap below counts
|
||||
// real dispatch targets rather than types visited. Source-written owners
|
||||
|
|
@ -483,16 +582,31 @@ export function emitReceiverBoundCalls(
|
|||
type DispatchTraversal = {
|
||||
readonly typeId: string;
|
||||
readonly ancestorImplementationCount: number;
|
||||
/** The instantiation this type is known to hold ON THIS PATH (#2912), or
|
||||
* `undefined` where it is not known — which restores the unfiltered
|
||||
* fan-out for the subtree below it rather than guessing. */
|
||||
readonly typeArguments: readonly string[] | undefined;
|
||||
};
|
||||
const targetByMemberId = new Map<string, DispatchTarget>();
|
||||
const bestIncomingCount = new Map<string, number>([[ownerDef.nodeId, 0]]);
|
||||
const queue: DispatchTraversal[] = [
|
||||
{ typeId: ownerDef.nodeId, ancestorImplementationCount: 0 },
|
||||
{
|
||||
typeId: ownerDef.nodeId,
|
||||
ancestorImplementationCount: 0,
|
||||
typeArguments: receiverTypeArguments,
|
||||
},
|
||||
];
|
||||
let head = 0;
|
||||
let discoveryOrder = 0;
|
||||
while (head < queue.length) {
|
||||
const current = queue[head++]!;
|
||||
// The whole instantiation apparatus hangs off ONE question: is the
|
||||
// supertype's own instantiation known? It is not for a non-generic
|
||||
// receiver, nor for any language that captures no heritage arguments, so
|
||||
// those walks skip every lookup below and emit exactly what they did
|
||||
// before #2912.
|
||||
const superGraphId =
|
||||
current.typeArguments === undefined ? undefined : classGraphIdByDefId.get(current.typeId);
|
||||
for (const subDef of subtypesBySupertypeDefId.get(current.typeId) ?? []) {
|
||||
const previousIncomingCount = bestIncomingCount.get(subDef.nodeId);
|
||||
if (
|
||||
|
|
@ -501,6 +615,42 @@ export function emitReceiverBoundCalls(
|
|||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// What THIS heritage clause instantiated its base with. `superGraphId`
|
||||
// already answers "is the supertype's instantiation known?", so it gates
|
||||
// the whole lookup once instead of being re-asked at each step below.
|
||||
let subtypeArguments: readonly string[] | undefined;
|
||||
if (superGraphId !== undefined) {
|
||||
const subGraphId = classGraphIdByDefId.get(subDef.nodeId);
|
||||
const heritageArguments =
|
||||
subGraphId === undefined
|
||||
? undefined
|
||||
: options.heritageTypeArguments?.get(
|
||||
heritageTypeArgumentsKey(subGraphId, superGraphId),
|
||||
);
|
||||
if (heritageArguments !== undefined) {
|
||||
const subtypeScopeId = index.classScopeByDefId.get(subDef.nodeId)?.id;
|
||||
const step = stepHeritageInstantiation({
|
||||
supertypeArguments: current.typeArguments,
|
||||
heritageArguments,
|
||||
subtypeParameters: subDef.typeParameters,
|
||||
// The "this subtype declares parameters" disjunct an earlier
|
||||
// revision carried here could never decide: `subDef` comes out of
|
||||
// the same loop that sets this flag, from exactly these defs, so a
|
||||
// subtype with parameters has already set it.
|
||||
subtypeParametersComplete: languageCapturesTypeParameters,
|
||||
resolveSupertypeArgument,
|
||||
resolveHeritageArgument: (name) => groundTypeArgument(name, subtypeScopeId),
|
||||
normalize: provider.normalizeTypeArgument,
|
||||
});
|
||||
// Skipped BEFORE the visit is recorded, so a type reachable by a
|
||||
// second, compatible path still gets its edge; and without
|
||||
// descending, because its own subtypes inherit the mismatch.
|
||||
if (!step.compatible) continue;
|
||||
subtypeArguments = step.subtypeArguments;
|
||||
}
|
||||
}
|
||||
|
||||
bestIncomingCount.set(subDef.nodeId, current.ancestorImplementationCount);
|
||||
|
||||
const implMember = pickOverload(subDef.nodeId, memberName, site, model, provider);
|
||||
|
|
@ -533,6 +683,7 @@ export function emitReceiverBoundCalls(
|
|||
queue.push({
|
||||
typeId: subDef.nodeId,
|
||||
ancestorImplementationCount: descendantImplementationCount,
|
||||
typeArguments: subtypeArguments,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -789,7 +940,7 @@ export function emitReceiverBoundCalls(
|
|||
receiverName.includes('(') ||
|
||||
site.receiverChain !== undefined
|
||||
) {
|
||||
const currentClass = resolveCompoundReceiverClass(
|
||||
const resolved = resolveCompoundReceiverTyped(
|
||||
receiverName,
|
||||
site.inScope,
|
||||
scopes,
|
||||
|
|
@ -798,8 +949,9 @@ export function emitReceiverBoundCalls(
|
|||
// captured chain describes it and the structural fold applies.
|
||||
{ ...fileCompoundOpts, receiverChain: site.receiverChain },
|
||||
);
|
||||
const currentClass = resolved?.def;
|
||||
compoundReceiverUnresolved = currentClass === undefined;
|
||||
if (currentClass !== undefined) {
|
||||
if (resolved !== undefined && currentClass !== undefined) {
|
||||
const chain = [currentClass.nodeId, ...scopes.methodDispatch.mroFor(currentClass.nodeId)];
|
||||
let memberDef: SymbolDefinition | undefined;
|
||||
let ambiguousOwnerId: string | undefined;
|
||||
|
|
@ -902,6 +1054,10 @@ export function emitReceiverBoundCalls(
|
|||
// Deliberately not "fixed" here: changing Case 0's primary
|
||||
// confidence is a separate behavioural change affecting every
|
||||
// language, and is out of scope for #2813.
|
||||
//
|
||||
// The instantiation the FOLD typed this receiver from — the
|
||||
// declared spelling of `this.repo` / `svc.get().repo`, which the
|
||||
// folded class alone no longer carries (#2912).
|
||||
emitted += emitInterfaceDispatchFor(
|
||||
currentClass,
|
||||
memberName,
|
||||
|
|
@ -909,6 +1065,7 @@ export function emitReceiverBoundCalls(
|
|||
site,
|
||||
0.85,
|
||||
calleeCapture,
|
||||
resolved.declaredSpelling,
|
||||
);
|
||||
// Always mark handled when the site was resolved, even
|
||||
// if the edge was deduplicated (collapse mode), so
|
||||
|
|
@ -1379,15 +1536,18 @@ export function emitReceiverBoundCalls(
|
|||
// already contain `()` (Ruby member-call-return captures),
|
||||
// pass through directly — the compound resolver handles the
|
||||
// full expression including the call syntax.
|
||||
let ownerDef = resolveCompoundReceiverClass(
|
||||
// Each attempt carries its OWN spelling: the retry below used to reuse a
|
||||
// recorder reset once, before the first call, so a spelling reported by
|
||||
// the attempt that FAILED could be read as the retry's.
|
||||
let resolved = resolveCompoundReceiverTyped(
|
||||
typeRef.rawName,
|
||||
typeRef.declaredAtScope,
|
||||
scopes,
|
||||
index,
|
||||
fileCompoundOpts,
|
||||
);
|
||||
if (ownerDef === undefined && !typeRef.rawName.includes('(')) {
|
||||
ownerDef = resolveCompoundReceiverClass(
|
||||
if (resolved === undefined && !typeRef.rawName.includes('(')) {
|
||||
resolved = resolveCompoundReceiverTyped(
|
||||
typeRef.rawName + '()',
|
||||
typeRef.declaredAtScope,
|
||||
scopes,
|
||||
|
|
@ -1395,7 +1555,8 @@ export function emitReceiverBoundCalls(
|
|||
fileCompoundOpts,
|
||||
);
|
||||
}
|
||||
if (ownerDef !== undefined) {
|
||||
const ownerDef = resolved?.def;
|
||||
if (resolved !== undefined && ownerDef !== undefined) {
|
||||
const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)];
|
||||
let memberDef: SymbolDefinition | undefined;
|
||||
let ambiguousOwnerId: string | undefined;
|
||||
|
|
@ -1496,6 +1657,7 @@ export function emitReceiverBoundCalls(
|
|||
// value instead because ITS primary varies that way; Case 3b's
|
||||
// primary, like Case 0's, does not, so there is no 1.0 arm here to
|
||||
// mirror.
|
||||
// Same fold, same recovered spelling as Case 0.
|
||||
emitted += emitInterfaceDispatchFor(
|
||||
ownerDef,
|
||||
memberName,
|
||||
|
|
@ -1503,6 +1665,7 @@ export function emitReceiverBoundCalls(
|
|||
site,
|
||||
0.85,
|
||||
calleeCapture,
|
||||
resolved.declaredSpelling,
|
||||
);
|
||||
// Always mark handled when the site was resolved, even
|
||||
// if the edge was deduplicated (collapse mode), so
|
||||
|
|
@ -1776,6 +1939,12 @@ export function emitReceiverBoundCalls(
|
|||
// Interface dispatch: when the primary owner is an
|
||||
// Interface, emit secondary CALLS edges to every
|
||||
// implementing class's same-named method.
|
||||
//
|
||||
// This case is the one that KNOWS the instantiation: the receiver
|
||||
// has a declared type, and `typeApplication` is that type restored
|
||||
// to its written `Base<Args>` spelling (`rawName` is the erasure).
|
||||
// A language whose `rawName` was never erased carries the arguments
|
||||
// itself, so both spellings are read (#2912).
|
||||
emitted += emitInterfaceDispatchFor(
|
||||
ownerDef,
|
||||
memberName,
|
||||
|
|
@ -1783,6 +1952,7 @@ export function emitReceiverBoundCalls(
|
|||
site,
|
||||
confidence,
|
||||
calleeCapture,
|
||||
typeApplication ?? typeRef.rawName,
|
||||
);
|
||||
// Always mark handled when the site was resolved, even
|
||||
// if the edge was deduplicated (collapse mode), so
|
||||
|
|
@ -2054,6 +2224,8 @@ export function emitReceiverBoundCalls(
|
|||
// way. Omitting it would make the static spelling emit fewer
|
||||
// targets than the identical instance field, which is the very
|
||||
// spelling-dependence #2829/#2842 closed elsewhere.
|
||||
// The field's DECLARED type is the spelling the source wrote, so
|
||||
// its arguments are available here exactly as in Case 4 (#2912).
|
||||
emitted += emitInterfaceDispatchFor(
|
||||
receiverClass,
|
||||
memberName,
|
||||
|
|
@ -2061,6 +2233,7 @@ export function emitReceiverBoundCalls(
|
|||
site,
|
||||
confidence,
|
||||
calleeCapture,
|
||||
fieldDeclaredType,
|
||||
);
|
||||
handledSites.add(siteKey);
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -95,6 +95,10 @@ import {
|
|||
} from '../passes/callable-value-flow.js';
|
||||
import type { ScopeResolver, UndecidedSatisfaction } from '../contract/scope-resolver.js';
|
||||
import { findEnclosingClassDef, resolveInheritanceBaseInScope } from '../scope/walkers.js';
|
||||
import {
|
||||
heritageTypeArgumentsKey,
|
||||
type HeritageTypeArgumentSink,
|
||||
} from '../utils/generic-instantiation.js';
|
||||
import { buildWorkspaceResolutionIndex } from '../workspace-index.js';
|
||||
import type { ResolutionOutcome, ResolutionOutcomeRecorder } from '../resolution-outcome.js';
|
||||
import { logHeapProbe } from '../../utils/heap-probe.js';
|
||||
|
|
@ -149,11 +153,22 @@ function emitInheritanceEdgeDirect(
|
|||
* reference-edge bridge from re-emitting the same sites later.
|
||||
*
|
||||
* @returns Site keys to seed the downstream handled-site skip set.
|
||||
*
|
||||
* The generic INSTANTIATION each heritage edge was written with (#2912) goes to
|
||||
* `recordTypeArguments` rather than out through the return, because the caller
|
||||
* shares that sink with the language heritage hook — see
|
||||
* `HeritageTypeArguments` in `utils/generic-instantiation.ts`. This pass is
|
||||
* where the pairing exists at all: the site carries the arguments and this is
|
||||
* the only code that resolves the site to a (subtype, supertype) pair, so
|
||||
* recording it here costs one map write per generic heritage edge, while
|
||||
* recovering it downstream would mean redoing the resolution against a graph
|
||||
* edge that no longer carries the spelling.
|
||||
*/
|
||||
function preEmitInheritanceEdges(
|
||||
graph: KnowledgeGraph,
|
||||
scopes: ReturnType<typeof finalizeScopeModel>,
|
||||
nodeLookup: ReturnType<typeof buildGraphNodeLookup>,
|
||||
recordTypeArguments: HeritageTypeArgumentSink,
|
||||
): Set<string> {
|
||||
const handledSites = new Set<string>();
|
||||
const seen = new Set<string>();
|
||||
|
|
@ -207,6 +222,12 @@ function preEmitInheritanceEdges(
|
|||
const edgeType: 'EXTENDS' | 'IMPLEMENTS' =
|
||||
targetDef.type === 'Interface' || targetDef.type === 'Trait' ? 'IMPLEMENTS' : 'EXTENDS';
|
||||
emitInheritanceEdgeDirect(graph, seen, existing, callerGraphId, targetGraphId, edgeType, site);
|
||||
// The instantiation this heritage clause wrote (`: IValidator<string>`),
|
||||
// keyed by the same graph-id pair the edge itself carries. Only generic
|
||||
// bases produce an entry; the sink owns the first-writer-wins rule.
|
||||
if (site.typeArguments !== undefined) {
|
||||
recordTypeArguments(callerGraphId, targetGraphId, site.typeArguments);
|
||||
}
|
||||
}
|
||||
|
||||
return handledSites;
|
||||
|
|
@ -704,16 +725,38 @@ export function runScopeResolution(
|
|||
},
|
||||
});
|
||||
logHeapProbe('sr-post-finalize', `lang=${provider.language}`);
|
||||
// One store and ONE writer rule for heritage instantiations (#2912), shared by
|
||||
// the pre-pass below and by the language hook further down — a heritage shape
|
||||
// the pre-pass cannot express (Rust `impl T for S`, Dart `implements`) records
|
||||
// through the same sink. FIRST writer wins: a repeated (sub, super) pair is a
|
||||
// partial declaration or a re-listed base, and letting a later entry overwrite
|
||||
// the first would make dispatch depend on file order.
|
||||
const heritageTypeArguments = new Map<string, readonly string[]>();
|
||||
const recordHeritageTypeArguments: HeritageTypeArgumentSink = (
|
||||
subtypeGraphId,
|
||||
supertypeGraphId,
|
||||
typeArguments,
|
||||
) => {
|
||||
if (typeArguments.length === 0) return;
|
||||
const key = heritageTypeArgumentsKey(subtypeGraphId, supertypeGraphId);
|
||||
if (!heritageTypeArguments.has(key)) heritageTypeArguments.set(key, typeArguments);
|
||||
};
|
||||
const preEmittedInheritanceSites = callableFlowOnly
|
||||
? new Set<string>()
|
||||
: preEmitInheritanceEdges(graph, finalized, nodeLookup);
|
||||
: preEmitInheritanceEdges(graph, finalized, nodeLookup, recordHeritageTypeArguments);
|
||||
// Call-based heritage hook (e.g., Ruby include/extend/prepend) — emits
|
||||
// IMPLEMENTS edges that `preEmitInheritanceEdges` cannot produce because
|
||||
// the heritage declarations are syntactic method calls, not grammar-level
|
||||
// heritage clauses. Must run BEFORE `buildMro` so MRO construction sees
|
||||
// the freshly-emitted IMPLEMENTS edges.
|
||||
if (!callableFlowOnly) {
|
||||
provider.emitHeritageEdges?.(graph, parsedFiles, nodeLookup, finalized);
|
||||
provider.emitHeritageEdges?.(
|
||||
graph,
|
||||
parsedFiles,
|
||||
nodeLookup,
|
||||
finalized,
|
||||
recordHeritageTypeArguments,
|
||||
);
|
||||
}
|
||||
// Implicit IMPORTS-edge hook — for languages whose files have compiler-
|
||||
// implicit cross-file visibility (no syntactic import statement). The
|
||||
|
|
@ -980,6 +1023,10 @@ export function runScopeResolution(
|
|||
// receiver (`console.log`, `fetch(...)`). Same hook, same spelling as
|
||||
// the `emitFreeCallFallback` wiring below.
|
||||
isBuiltInName: provider.languageProvider.isBuiltInName,
|
||||
// What each heritage clause instantiated its base with, so the
|
||||
// interface-dispatch fan-out can refuse an incompatible instantiation
|
||||
// (#2912). Empty under `callableFlowOnly`, which emits no dispatch.
|
||||
heritageTypeArguments,
|
||||
},
|
||||
);
|
||||
const receiverExtras = receiverBound.emitted;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,345 @@
|
|||
/**
|
||||
* Generic-instantiation compatibility for interface-dispatch fan-out (#2912).
|
||||
*
|
||||
* ── THE PROBLEM ──────────────────────────────────────────────────────────────
|
||||
*
|
||||
* Heritage edges are stored between DECLARATIONS, and a declaration answers for
|
||||
* every instantiation of itself: `class UserValidator : IValidator<string>` and
|
||||
* `class IntValidator : IValidator<int>` both land in `IValidator`'s subtype
|
||||
* list, indistinguishable once the arguments are erased. A call through an
|
||||
* `IValidator<string>` receiver then fans out to `IntValidator.Check(int)` — a
|
||||
* target no runtime dispatch can produce, because the two instantiations are
|
||||
* unrelated types.
|
||||
*
|
||||
* ── THE MODEL ────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* The subtype closure is walked carrying a SUBSTITUTION, exactly as a type
|
||||
* checker would. Each hop takes the arguments the supertype is currently known
|
||||
* to be instantiated with and the arguments the subtype WROTE on that supertype,
|
||||
* and unifies them positionally:
|
||||
*
|
||||
* receiver `IValidator<string>` → super args ['string']
|
||||
* `UserValidator : IValidator<string>` ['string'] ≡ ['string'] → keep
|
||||
* `IntValidator : IValidator<int>` ['int'] ✗ → prune
|
||||
* `Wrapper<T> : IValidator<T>` ['T'] binds T = string → keep,
|
||||
* and the next hop sees `Wrapper` instantiated with ['string'], so
|
||||
* `IntWrapper : Wrapper<int>` prunes and `StrWrapper : Wrapper<string>`
|
||||
* survives.
|
||||
*
|
||||
* ── WHY EVERY UNCERTAINTY FAILS OPEN ─────────────────────────────────────────
|
||||
*
|
||||
* Dispatch fan-out is an over-approximation by design: a missing edge is a
|
||||
* silently wrong answer to "what can this call reach", while a surplus edge is
|
||||
* the pre-existing, documented imprecision. So this only ever prunes on POSITIVE
|
||||
* evidence that two instantiations differ, and returns `compatible` for every
|
||||
* shape it cannot decide — unknown arguments on either side, an arity it cannot
|
||||
* line up, or an argument that might be a type variable this pipeline did not
|
||||
* capture. `SymbolDefinition.typeParameters` and `ReferenceSite.typeArguments`
|
||||
* are both absent for languages whose captures do not populate them, and absence
|
||||
* means "unknown", never "not generic"; a language that captures neither is
|
||||
* therefore left with exactly the pre-#2912 fan-out.
|
||||
*
|
||||
* That is also why the arguments are RESOLVED rather than string-compared. Two
|
||||
* spellings that differ are only certainly different types when both bind to
|
||||
* something this pipeline can see — an imported `User` and a `Models.User` are
|
||||
* one type, and a lone `T` may be a type variable the capture layer never
|
||||
* recorded. The caller supplies the evidence (scope lookup + built-in names);
|
||||
* anything it cannot ground keeps the target.
|
||||
*/
|
||||
|
||||
import type { TypeParameter } from 'gitnexus-shared';
|
||||
|
||||
/**
|
||||
* What a written type argument turned out to name, as far as the pipeline can
|
||||
* tell from where it was written.
|
||||
*
|
||||
* A spelling is GROUNDED when either field answers: it bound to a declaration,
|
||||
* or the language calls the name built in. Two grounded arguments that are not
|
||||
* the same type are the only evidence that licenses a prune. An ungrounded
|
||||
* spelling is `unknown` — it may be an external type, but it may equally be a
|
||||
* TYPE VARIABLE in a language whose captures do not record type parameters, and
|
||||
* pruning on that would delete `class Box<T> : IValidator<T>` from every
|
||||
* instantiation's fan-out.
|
||||
*/
|
||||
export interface GroundedTypeArgument {
|
||||
/** Identity of the declaration this spelling bound to, when it bound to one.
|
||||
* Comparing identities rather than spellings is what makes `Models.User` and
|
||||
* an imported `User` one type. */
|
||||
readonly definitionId?: string;
|
||||
/** The language declares this name built in (`string`, `int`). */
|
||||
readonly builtIn: boolean;
|
||||
/**
|
||||
* The name is a TYPE PARAMETER of a declaration enclosing where it was
|
||||
* written — the `T` of `void Run<T>(IValidator<T> v)` at the call site, or of
|
||||
* an outer class around a nested one's heritage clause.
|
||||
*
|
||||
* It stands for a different type at every instantiation, so it cannot be
|
||||
* compared with anything, and it must be recognised SEPARATELY from
|
||||
* ungrounded: a bounded `T extends User` grounds to its bound's declaration,
|
||||
* and comparing that bound against a concrete argument would prune every
|
||||
* implementor of a call written through `IValidator<T>`.
|
||||
*/
|
||||
readonly typeVariable?: boolean;
|
||||
}
|
||||
|
||||
/** Resolve a written type argument from the scope it was written in. */
|
||||
type TypeArgumentResolver = (name: string) => GroundedTypeArgument;
|
||||
|
||||
/**
|
||||
* Generic arguments written on a heritage clause, keyed by the GRAPH-ID pair of
|
||||
* the edge they were written on — see {@link heritageTypeArgumentsKey}.
|
||||
*
|
||||
* Graph ids rather than def ids because that is the identity the heritage edge
|
||||
* itself carries, and because same-file partial declarations share one node: a
|
||||
* base listed on any part is the base of the whole type. Absent for every
|
||||
* non-generic base, for every language whose captures do not record arguments,
|
||||
* and for heritage that never passes through the inheritance pre-pass (Ruby's
|
||||
* `include`, Go's structural implements) — all of which read as "unknown".
|
||||
*/
|
||||
export type HeritageTypeArguments = ReadonlyMap<string, readonly string[]>;
|
||||
|
||||
/**
|
||||
* Records one heritage edge's instantiation, from whichever pass emitted that
|
||||
* edge — the generic inheritance pre-pass, or a language's own
|
||||
* `ScopeResolver.emitHeritageEdges` for heritage the pre-pass cannot express
|
||||
* (Rust `impl Trait for S`, Dart's `implements` markers).
|
||||
*
|
||||
* The ids MUST be the same pair the emitted edge carries, because the dispatch
|
||||
* walk looks the instantiation up by the edge it is crossing. Recording nothing
|
||||
* is always safe: absence reads as "unknown" and keeps every target.
|
||||
*/
|
||||
export type HeritageTypeArgumentSink = (
|
||||
subtypeGraphId: string,
|
||||
supertypeGraphId: string,
|
||||
typeArguments: readonly string[],
|
||||
) => void;
|
||||
|
||||
/** Key for {@link HeritageTypeArguments}. NUL-separated because a graph id
|
||||
* embeds a file path, and a path may legally contain every other separator a
|
||||
* reader would reach for first — `:`, `|`, even a space. */
|
||||
export function heritageTypeArgumentsKey(subtypeGraphId: string, supertypeGraphId: string): string {
|
||||
return `${subtypeGraphId}\u0000${supertypeGraphId}`;
|
||||
}
|
||||
|
||||
/** One hop of the subtype closure, expressed as a substitution problem. */
|
||||
export interface HeritageInstantiationStep {
|
||||
/**
|
||||
* Arguments the SUPERTYPE is currently known to be instantiated with, in
|
||||
* declaration order — `['string']` for a receiver typed `IValidator<string>`.
|
||||
* `undefined` when the instantiation is unknown, which keeps every subtype.
|
||||
*/
|
||||
readonly supertypeArguments: readonly string[] | undefined;
|
||||
/**
|
||||
* Arguments the SUBTYPE wrote on the supertype in its own heritage clause —
|
||||
* `['string']` for `: IValidator<string>`, `['T']` for `: IValidator<T>`.
|
||||
* `undefined` when the subtype named the supertype without arguments, or when
|
||||
* the language's captures did not record them.
|
||||
*/
|
||||
readonly heritageArguments: readonly string[] | undefined;
|
||||
/** The SUBTYPE's own declared type parameters, in declaration order. */
|
||||
readonly subtypeParameters: readonly TypeParameter[] | undefined;
|
||||
/**
|
||||
* Does an EMPTY `subtypeParameters` mean "this declaration is not generic"?
|
||||
*
|
||||
* The distinction decides whether an unresolvable argument may be pruned on.
|
||||
* `SymbolDefinition.typeParameters` is absent both for a plain `class C :
|
||||
* IValidator<string>` and for every declaration in a language whose captures
|
||||
* record no parameters at all — and the two demand opposite answers, because
|
||||
* in the second case the `T` of `class Box<T> : IValidator<T>` is also absent
|
||||
* and would be read as a concrete type named "T".
|
||||
*
|
||||
* True when the caller has evidence the parameters ARE recorded: this
|
||||
* declaration itself lists some, or some declaration in the same language run
|
||||
* does. False leaves an unresolvable argument unusable as evidence, which is
|
||||
* the pre-#2912 fan-out for that language.
|
||||
*/
|
||||
readonly subtypeParametersComplete: boolean;
|
||||
/** Ground a supertype argument — resolved from the RECEIVER's scope. */
|
||||
readonly resolveSupertypeArgument: TypeArgumentResolver;
|
||||
/** Ground a heritage argument — resolved from where the HERITAGE was written,
|
||||
* a different scope from the call site and usually a different file. */
|
||||
readonly resolveHeritageArgument: TypeArgumentResolver;
|
||||
/** Optional language normalization applied to both sides before they are
|
||||
* compared, for aliases that denote one type (C# `string` / `String`). */
|
||||
readonly normalize?: (name: string) => string;
|
||||
}
|
||||
|
||||
interface HeritageInstantiationResult {
|
||||
/** False ONLY when the two instantiations are provably different types. */
|
||||
readonly compatible: boolean;
|
||||
/**
|
||||
* What the SUBTYPE is instantiated with, for the next hop of the walk:
|
||||
* its own type parameters resolved through this step's bindings. `undefined`
|
||||
* whenever any parameter stayed unbound — a partially known list would have to
|
||||
* be tracked per slot, and the whole-list unknown is the fail-open reading.
|
||||
*/
|
||||
readonly subtypeArguments: readonly string[] | undefined;
|
||||
}
|
||||
|
||||
const UNKNOWN: HeritageInstantiationResult = { compatible: true, subtypeArguments: undefined };
|
||||
|
||||
/** Stand-in for a language that declares no `normalizeTypeArgument`. Module
|
||||
* level so the 15 that do not are not charged a closure per hop. */
|
||||
const identity = (name: string): string => name;
|
||||
|
||||
/** A resolved declaration, or a name the language calls built in. Anything else
|
||||
* might be a type variable nobody captured. */
|
||||
function grounded(type: GroundedTypeArgument): boolean {
|
||||
return type.definitionId !== undefined || type.builtIn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this spelling name a SET of types rather than one — a Java wildcard
|
||||
* (`?`, `? extends User`, `? super User`), a Kotlin star projection (`*`) or
|
||||
* use-site variance (`out User`, `in User`)?
|
||||
*
|
||||
* Nullable decoration (`User?`, `string?`) matches the `?` test too. Keeping it
|
||||
* in is deliberate: an argument that may or may not be null is still the same
|
||||
* type for dispatch purposes, so the only cost is declining to prune a position
|
||||
* that could have been pruned — the direction every other uncertainty here
|
||||
* takes.
|
||||
*/
|
||||
function isWildcard(name: string): boolean {
|
||||
return WILDCARD_MARK.test(name) || USE_SITE_VARIANCE.test(name);
|
||||
}
|
||||
|
||||
const WILDCARD_MARK = /[?*]/;
|
||||
/** Leading whitespace is matched rather than trimmed off, so a spelling that
|
||||
* carries none — the overwhelming majority — costs no allocation. */
|
||||
const USE_SITE_VARIANCE = /^\s*(?:out|in)\s/;
|
||||
|
||||
/** Drop insignificant whitespace so two spellings of one instantiation compare
|
||||
* equal: `Map<string, User>` and `Map<string,User>` are the same type, and
|
||||
* which one a capture produced depends on how the source was written. */
|
||||
function compact(name: string): string {
|
||||
return name.replace(INSIGNIFICANT_WHITESPACE, '');
|
||||
}
|
||||
|
||||
const INSIGNIFICANT_WHITESPACE = /\s+/g;
|
||||
|
||||
/** Last segment of a qualified spelling: `java.lang.String` → `String`,
|
||||
* `System::Text::Encoding` → `Encoding`. Used only when a name did not
|
||||
* resolve, so the qualifier is exactly the part nothing can check. */
|
||||
function simpleName(name: string): string {
|
||||
const cut = Math.max(name.lastIndexOf('.'), name.lastIndexOf(':'));
|
||||
return cut === -1 ? name : name.slice(cut + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unify one heritage hop and carry the substitution to the subtype.
|
||||
*
|
||||
* Pure and total: no lookups of its own, no throwing, and every branch it cannot
|
||||
* decide answers {@link UNKNOWN} — compatible, with an unknown instantiation.
|
||||
*/
|
||||
export function stepHeritageInstantiation(
|
||||
step: HeritageInstantiationStep,
|
||||
): HeritageInstantiationResult {
|
||||
const { supertypeArguments, heritageArguments, subtypeParameters } = step;
|
||||
if (supertypeArguments === undefined || heritageArguments === undefined) return UNKNOWN;
|
||||
// An arity that does not line up means one of the two lists is not what this
|
||||
// code thinks it is (a spelling the argument splitter read differently, a
|
||||
// partial specialization, a variadic parameter pack). Nothing positive can be
|
||||
// concluded from a mismatched pairing, so nothing is.
|
||||
if (supertypeArguments.length !== heritageArguments.length) return UNKNOWN;
|
||||
|
||||
const normalize = step.normalize ?? identity;
|
||||
const bindings = new Map<string, string>();
|
||||
for (let i = 0; i < heritageArguments.length; i++) {
|
||||
const written = heritageArguments[i] as string;
|
||||
const actual = supertypeArguments[i] as string;
|
||||
// A type VARIABLE of the subtype binds rather than compares: `Wrapper<T> :
|
||||
// IValidator<T>` under an `IValidator<string>` receiver means T = string.
|
||||
if (subtypeParameters?.some((p) => p.name === written) === true) {
|
||||
const previous = bindings.get(written);
|
||||
if (previous !== undefined) {
|
||||
// The SAME variable in a second position must receive the same type:
|
||||
// `class C<T> : Pair<T, T>` cannot be a `Pair<string, int>`, and
|
||||
// overwriting the first binding would both accept that and hand the
|
||||
// next hop a substitution the subtype never had. Unify instead — but
|
||||
// prune only on the evidence the concrete path below demands, since two
|
||||
// spellings that differ are not yet two types.
|
||||
const first = step.resolveSupertypeArgument(previous);
|
||||
const second = step.resolveSupertypeArgument(actual);
|
||||
if (
|
||||
isWildcard(previous) ||
|
||||
isWildcard(actual) ||
|
||||
first.typeVariable === true ||
|
||||
second.typeVariable === true
|
||||
) {
|
||||
return UNKNOWN;
|
||||
}
|
||||
if (compact(normalize(previous)) === compact(normalize(actual))) continue;
|
||||
if (first.definitionId !== undefined && second.definitionId !== undefined) {
|
||||
if (first.definitionId === second.definitionId) continue;
|
||||
return { compatible: false, subtypeArguments: undefined };
|
||||
}
|
||||
if (grounded(first) && grounded(second)) {
|
||||
return { compatible: false, subtypeArguments: undefined };
|
||||
}
|
||||
// One side names something this pipeline cannot see. The position is
|
||||
// undecided, and so is the binding it would have carried onward.
|
||||
return UNKNOWN;
|
||||
}
|
||||
bindings.set(written, actual);
|
||||
continue;
|
||||
}
|
||||
// A WILDCARD names a set of types, not one: `Repo<? extends User>` holds a
|
||||
// `Repo<User>` perfectly well, and Kotlin's `Repo<*>` or `Repo<out User>`
|
||||
// say the same thing in their own spelling. Comparing one against a
|
||||
// concrete argument answers a question neither spelling asked, so the
|
||||
// position is simply unknown. Nullable decoration (`User?`, `string?`) trips
|
||||
// the same test, which costs a little precision in the safe direction.
|
||||
if (isWildcard(written) || isWildcard(actual)) continue;
|
||||
// Normalized once and reused by the simple-name compare below, so both
|
||||
// comparisons are visibly made on the same normalization.
|
||||
const writtenKey = compact(normalize(written));
|
||||
const actualKey = compact(normalize(actual));
|
||||
if (writtenKey === actualKey) continue;
|
||||
// Differing spellings, which is not yet a difference of TYPE. Resolve both
|
||||
// where each was written and compare what they bound to: an imported `User`
|
||||
// and a `Models.User` are one declaration, and a declaration is what the
|
||||
// instantiation is actually about.
|
||||
const heritageType = step.resolveHeritageArgument(written);
|
||||
const supertypeType = step.resolveSupertypeArgument(actual);
|
||||
// A type PARAMETER in scope where it was written stands for a different type
|
||||
// at every instantiation, so it is not comparable with anything — and
|
||||
// `subtypeParametersComplete` says nothing about it, because that flag is
|
||||
// evidence about the SUBTYPE's parameter list while this `T` belongs to the
|
||||
// enclosing generic method or class at the other end. Without this branch a
|
||||
// call written `void Run<T>(IValidator<T> v) { v.Check(x); }` prunes every
|
||||
// implementor: `T` is unbounded, so it grounds to nothing, and a bounded one
|
||||
// grounds to its BOUND and compares unequal to the concrete argument.
|
||||
if (heritageType.typeVariable === true || supertypeType.typeVariable === true) return UNKNOWN;
|
||||
if (heritageType.definitionId !== undefined && supertypeType.definitionId !== undefined) {
|
||||
if (heritageType.definitionId === supertypeType.definitionId) continue;
|
||||
return { compatible: false, subtypeArguments: undefined };
|
||||
}
|
||||
// At least one side names something outside this workspace — `String`,
|
||||
// `HttpClient`, a generated type. That is the COMMON case for a generic
|
||||
// argument, so refusing to decide here would make the whole filter inert;
|
||||
// what is compared instead is the simple name, which cannot tell
|
||||
// `a.User` from `b.User` (kept, the over-approximating direction) but does
|
||||
// tell `String` from `Integer`.
|
||||
if (simpleName(writtenKey) === simpleName(actualKey)) continue;
|
||||
// The one thing a spelling difference must not be read as: a TYPE VARIABLE
|
||||
// this pipeline never captured. Where the subtype's parameter list is not
|
||||
// known to be complete, only a pair of grounded names — resolved or built
|
||||
// in — is safe to prune on. A variable that IS captured never reaches here:
|
||||
// the subtype's own bind above, and any other declaration's through the
|
||||
// `typeVariable` test, which is why that test has to be reliable — see the
|
||||
// type-parameter captures on generic METHODS.
|
||||
if (!step.subtypeParametersComplete && !(grounded(heritageType) && grounded(supertypeType))) {
|
||||
return UNKNOWN;
|
||||
}
|
||||
return { compatible: false, subtypeArguments: undefined };
|
||||
}
|
||||
|
||||
if (subtypeParameters === undefined || subtypeParameters.length === 0) return UNKNOWN;
|
||||
const subtypeArguments: string[] = [];
|
||||
for (const parameter of subtypeParameters) {
|
||||
const bound = bindings.get(parameter.name);
|
||||
if (bound === undefined) return UNKNOWN;
|
||||
subtypeArguments.push(bound);
|
||||
}
|
||||
return { compatible: true, subtypeArguments };
|
||||
}
|
||||
|
|
@ -1205,6 +1205,17 @@ export const JAVA_QUERIES = `
|
|||
(record_declaration name: (identifier) @name) @definition.record
|
||||
(annotation_type_declaration name: (identifier) @name) @definition.annotation
|
||||
|
||||
; Canonical record-component accessors are implicit public zero-argument methods.
|
||||
(record_declaration
|
||||
parameters: (formal_parameters
|
||||
(formal_parameter
|
||||
name: (identifier) @name) @definition.method))
|
||||
(record_declaration
|
||||
parameters: (formal_parameters
|
||||
(spread_parameter
|
||||
(variable_declarator
|
||||
name: (identifier) @name)) @definition.method))
|
||||
|
||||
; Anonymous class bodies: new Runnable() { ... } — no @name capture; the
|
||||
; class extractor synthesizes the javac-style Worker$N name (#2550)
|
||||
(object_creation_expression (class_body)) @definition.class
|
||||
|
|
|
|||
|
|
@ -17,11 +17,31 @@ export function arityForIdFromInfo(info: MethodInfo): number | undefined {
|
|||
}
|
||||
|
||||
/**
|
||||
* Compute a type-based discriminator suffix for same-arity overloads.
|
||||
* Returns `~type1,type2` when the current method collides with another method
|
||||
* in the same class that has the same name and arity but different parameter types.
|
||||
* Returns `''` when there is no collision or types are unavailable.
|
||||
* Key for the per-class method map built by `getMethodInfo` (parse-worker).
|
||||
*
|
||||
* `name:line` is NOT unique. Two callables can start on the same line with the
|
||||
* same name whenever one of them is SYNTHESIZED at a position that is not its
|
||||
* own declaration: a Java record's implicit component accessor is minted at the
|
||||
* component (`record P(int x, int y) { int x(int s) {…} }`), and a C# 12 primary
|
||||
* constructor at the owner's `parameter_list`
|
||||
* (`class Point(int x, int y) { public Point(int x) : this(x, 0) {} }`). Both are
|
||||
* appended LAST by their extractor, so under a `name:line` key the synthesized
|
||||
* entry silently destroyed the source-written method's MethodInfo and the two
|
||||
* ids collapsed onto one node (#2936).
|
||||
*
|
||||
* `line` is 1-based and `column` is 0-based — deliberately, because this is a
|
||||
* join key rather than a displayed location and both sides derive it from the
|
||||
* same node. Do not "normalize" one half; the join is the only contract.
|
||||
*
|
||||
* The KEY is never parsed by any consumer — `buildCollisionGroups`,
|
||||
* `typeTagForId` and `constTagForId` all iterate `.values()`. Keep it that way,
|
||||
* and never insert one MethodInfo under two keys: those consumers would then
|
||||
* count it twice and turn every singleton into a false collision group.
|
||||
*/
|
||||
export function methodInfoKey(name: string, line: number, column: number): string {
|
||||
return `${name}:${line}:${column}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build collision groups from a method map — groups methods by `name#arity`.
|
||||
* Call once per class, then pass to typeTagForId/constTagForId to avoid O(N²) scans.
|
||||
|
|
@ -43,6 +63,12 @@ export function buildCollisionGroups(
|
|||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a type-based discriminator suffix for same-arity overloads.
|
||||
* Returns `~type1,type2` when the current method collides with another method
|
||||
* in the same class that has the same name and arity but different parameter types.
|
||||
* Returns `''` when there is no collision or types are unavailable.
|
||||
*/
|
||||
export function typeTagForId(
|
||||
methodMap: Map<string, MethodInfo>,
|
||||
methodName: string,
|
||||
|
|
|
|||
|
|
@ -47,6 +47,129 @@ export function extractTemplateArguments(text: string): string[] | undefined {
|
|||
return args.length > 0 ? args : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The type ARGUMENTS a reference applies to its base, read from the reference's
|
||||
* own source spelling: `IValidator<string>` → `['string']`, `Base[User]` →
|
||||
* `['User']`, `Repository` → `undefined`.
|
||||
*
|
||||
* The inverse direction of {@link erasedTypeApplication}, which rebuilds the
|
||||
* `Base<Args>` SPELLING so a lookup can stay grounded; this returns the
|
||||
* ARGUMENTS so a consumer that has already resolved the base can ask which
|
||||
* instantiation it was (#2912).
|
||||
*
|
||||
* Both bracket families count, because both spell type application in a
|
||||
* heritage position — `class C : IValidator<string>` and Go's `struct { Base[int] }`
|
||||
* / Python's `class C(Base[User])`. What is NOT accepted is anything that fails
|
||||
* to be exactly one balanced, non-empty list closing at the very end:
|
||||
*
|
||||
* - `Base(args)` — a C# primary-constructor base, not an application.
|
||||
* - `Foo[]` — an empty list is an array spelling, not arguments.
|
||||
* - `(Int) -> Unit` — a Kotlin function type, whose `>` closes nothing.
|
||||
*
|
||||
* Declining is the safe outcome for all of them: absence reads as "unknown"
|
||||
* and every consumer of this fails open on it.
|
||||
*/
|
||||
export function typeApplicationArguments(spelling: string): string[] | undefined {
|
||||
const text = spelling.trim();
|
||||
const inner = balancedTailList(text, text.search(OPENING_BRACKET));
|
||||
if (inner === undefined) return undefined;
|
||||
const args = splitTopLevelArguments(inner);
|
||||
return args.length > 0 ? args : undefined;
|
||||
}
|
||||
|
||||
const OPENING_BRACKET = /[<[]/;
|
||||
|
||||
/**
|
||||
* The contents of the ONE balanced bracket list that opens at `start` and closes
|
||||
* on the LAST character of `text` — `Repo<User>` from index 4 yields `User`.
|
||||
*
|
||||
* `undefined` for everything else, which is what both callers need: a list that
|
||||
* closes early (`User[][]`, `Repo<User>?`), one that never closes
|
||||
* (`Map<String, (Int) -> Unit>`), an empty one (`User[]`), one whose brackets
|
||||
* cross families (`Foo<Bar]>`), or no bracket at all (`start === -1`). Shared
|
||||
* because the rule is one rule — `erasedTypeApplication` rebuilds the spelling
|
||||
* from it and `typeApplicationArguments` splits it, and two copies of a scan
|
||||
* this fiddly would be free to disagree about `User[][]`.
|
||||
*/
|
||||
function balancedTailList(text: string, start: number): string | undefined {
|
||||
const opener = text[start];
|
||||
if (opener !== '<' && opener !== '[') return undefined;
|
||||
// A STACK of expected closers rather than one counter for one family: a
|
||||
// counter scanning `Foo<Bar]>` never sees the `]`, reaches the final `>` at
|
||||
// depth zero and reports `Bar]` as a balanced argument list. Every closer must
|
||||
// now match the opener it actually closes, so a crossed pair declines — which
|
||||
// is what the contract above says and what both callers read as "unknown".
|
||||
const expected: string[] = [];
|
||||
for (let i = start; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (ch === '<' || ch === '[') {
|
||||
expected.push(ch === '<' ? '>' : ']');
|
||||
continue;
|
||||
}
|
||||
if (ch !== '>' && ch !== ']') continue;
|
||||
if (expected.pop() !== ch) return undefined;
|
||||
if (expected.length === 0) {
|
||||
return i === text.length - 1 && i > start + 1 ? text.slice(start + 1, i) : undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Split `string, Map<int, bool>` on the commas that are not inside a nested
|
||||
* list. Tracks BOTH bracket families so a mixed spelling (`List<Dict[a, b]>`)
|
||||
* does not split inside the inner one. */
|
||||
function splitTopLevelArguments(inner: string): string[] {
|
||||
const args: string[] = [];
|
||||
let depth = 0;
|
||||
let tokenStart = 0;
|
||||
const push = (end: number): void => {
|
||||
const token = inner.slice(tokenStart, end).trim();
|
||||
if (token.length > 0) args.push(token);
|
||||
};
|
||||
for (let i = 0; i < inner.length; i++) {
|
||||
const ch = inner[i];
|
||||
if (ch === '<' || ch === '[') depth++;
|
||||
else if (ch === '>' || ch === ']') depth--;
|
||||
else if (ch === ',' && depth === 0) {
|
||||
push(i);
|
||||
tokenStart = i + 1;
|
||||
}
|
||||
}
|
||||
push(inner.length);
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of the `(` that matches the trailing `)` of `text`, or -1 when the text
|
||||
* does not end in a balanced call suffix.
|
||||
*
|
||||
* Shared for the same reason as {@link balancedTailList}: this scan is fiddly
|
||||
* enough that two copies would be free to disagree, and it has two unrelated
|
||||
* readers — splitting a receiver chain at its call, and stripping a base's
|
||||
* constructor invocation off a heritage spelling.
|
||||
*/
|
||||
export function matchingOpenParen(text: string): number {
|
||||
if (!text.endsWith(')')) return -1;
|
||||
let depth = 0;
|
||||
for (let i = text.length - 1; i >= 0; i--) {
|
||||
const ch = text[i];
|
||||
if (ch === ')') depth++;
|
||||
else if (ch === '(') {
|
||||
depth--;
|
||||
if (depth === 0) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Drop a balanced `(...)` that ENDS the text — the argument list of a base's
|
||||
* constructor invocation, as in `record R : Base<int>(x)` or Kotlin
|
||||
* `class C : Bar<Int>()`. Anything else is returned unchanged. */
|
||||
export function stripTrailingCallSuffix(text: string): string {
|
||||
const open = matchingOpenParen(text);
|
||||
return open === -1 ? text : text.slice(0, open).trimEnd();
|
||||
}
|
||||
|
||||
export function stripTemplateArguments(text: string): string {
|
||||
const start = text.indexOf('<');
|
||||
if (start === -1) return text;
|
||||
|
|
@ -151,21 +274,8 @@ export function erasedTypeApplication(typeRef: TypeRef): string | undefined {
|
|||
if (spelling === undefined) return undefined;
|
||||
const base = typeRef.rawName.trim();
|
||||
if (base.length === 0 || !spelling.startsWith(base)) return undefined;
|
||||
const rest = spelling.slice(base.length).trimStart();
|
||||
const opener = rest[0];
|
||||
if (opener !== '<' && opener !== '[') return undefined;
|
||||
const closer = opener === '<' ? '>' : ']';
|
||||
let depth = 0;
|
||||
for (let i = 0; i < rest.length; i++) {
|
||||
if (rest[i] === opener) depth++;
|
||||
else if (rest[i] === closer) {
|
||||
depth--;
|
||||
// The list the spelling opened must close on the LAST character, and must
|
||||
// have held something: `Repo[User]` yes, `User[]` no, `User[][]` no.
|
||||
if (depth === 0) {
|
||||
return i === rest.length - 1 && i > 1 ? `${base}<${rest.slice(1, i)}>` : undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
// The list must open immediately after the base and close on the LAST
|
||||
// character, holding something: `Repo[User]` yes, `User[]` no, `User[][]` no.
|
||||
const inner = balancedTailList(spelling.slice(base.length).trimStart(), 0);
|
||||
return inner === undefined ? undefined : `${base}<${inner}>`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ import {
|
|||
constTagForId,
|
||||
buildCollisionGroups,
|
||||
parameterShapeIdTag,
|
||||
methodInfoKey,
|
||||
} from '../utils/method-props.js';
|
||||
import {
|
||||
extractTemplateArguments,
|
||||
|
|
@ -739,9 +740,12 @@ const methodInfoCache = new Map<number, Map<string, MethodInfo>>();
|
|||
|
||||
/**
|
||||
* Get (or extract and cache) method info for a class node.
|
||||
* Returns a "name:line" → MethodInfo map, or undefined if the provider has no method extractor
|
||||
* or the class yielded no methods.
|
||||
* Keyed by name:line (not name alone) to support overloaded methods in Java/Kotlin.
|
||||
* Returns a "name:line:column" → MethodInfo map, or undefined if the provider has no method
|
||||
* extractor or the class yielded no methods.
|
||||
* Keyed by name:line:column (not name, and not name:line) to support overloaded methods in
|
||||
* Java/Kotlin AND to keep a callable SYNTHESIZED at another node's position from evicting the
|
||||
* source-written one that starts on the same line (#2936). Every lookup site MUST pass the
|
||||
* column of the SAME node it takes the line from — see `methodInfoKey`.
|
||||
*/
|
||||
function getMethodInfo(
|
||||
classNode: SyntaxNode,
|
||||
|
|
@ -759,7 +763,7 @@ function getMethodInfo(
|
|||
|
||||
cached = new Map<string, MethodInfo>();
|
||||
for (const method of result.methods) {
|
||||
cached.set(`${method.name}:${method.line}`, method);
|
||||
cached.set(methodInfoKey(method.name, method.line, method.column), method);
|
||||
}
|
||||
methodInfoCache.set(cacheKey, cached);
|
||||
return cached;
|
||||
|
|
@ -996,7 +1000,9 @@ const findEnclosingFunctionId = (
|
|||
language: encLang,
|
||||
});
|
||||
const defLine = current.startPosition.row + 1;
|
||||
const info = methodMap?.get(`${funcName}:${defLine}`);
|
||||
const info = methodMap?.get(
|
||||
methodInfoKey(funcName, defLine, current.startPosition.column),
|
||||
);
|
||||
if (info) {
|
||||
arity = info.parameters.some((p) => p.isVariadic)
|
||||
? undefined
|
||||
|
|
@ -1062,7 +1068,9 @@ const findEnclosingFunctionId = (
|
|||
language: encLang2,
|
||||
});
|
||||
const defLine2 = sigNode.startPosition.row + 1;
|
||||
const info2 = methodMap2?.get(`${customResult.funcName}:${defLine2}`);
|
||||
const info2 = methodMap2?.get(
|
||||
methodInfoKey(customResult.funcName, defLine2, sigNode.startPosition.column),
|
||||
);
|
||||
if (info2) {
|
||||
arity2 = info2.parameters.some((p) => p.isVariadic)
|
||||
? undefined
|
||||
|
|
@ -2112,6 +2120,7 @@ const processFileGroup = (
|
|||
const definitionNode = getDefinitionNodeFromCaptures(captureMap);
|
||||
const defaultNodeLabel = getLabelFromCaptures(captureMap, provider);
|
||||
if (!defaultNodeLabel) continue;
|
||||
if (provider.shouldSkipDefinitionCapture?.(captureMap, defaultNodeLabel) === true) continue;
|
||||
|
||||
const nameNode = captureMap['name'];
|
||||
const extractedClassSymbol =
|
||||
|
|
@ -2562,7 +2571,9 @@ const processFileGroup = (
|
|||
language,
|
||||
});
|
||||
const defLine = definitionNode.startPosition.row + 1;
|
||||
const info = methodMap?.get(`${nodeName}:${defLine}`);
|
||||
const info = methodMap?.get(
|
||||
methodInfoKey(nodeName, defLine, definitionNode.startPosition.column),
|
||||
);
|
||||
if (info) {
|
||||
enrichedByMethodExtractor = true;
|
||||
arityForId = arityForIdFromInfo(info);
|
||||
|
|
|
|||
|
|
@ -174,6 +174,7 @@ import {
|
|||
} from './ingestion/frameworks/spring/analysis-features.js';
|
||||
import {
|
||||
JAVA_ENUM_INTERFACE_HERITAGE_FEATURE,
|
||||
JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE,
|
||||
SPRING_CONFIG_BINDINGS_FEATURE,
|
||||
} from './ingestion/languages/java/analysis-features.js';
|
||||
import {
|
||||
|
|
@ -229,6 +230,7 @@ const ANALYSIS_FEATURES = [
|
|||
SPRING_CONDITIONALS_FEATURE,
|
||||
SPRING_CONFIG_BINDINGS_FEATURE,
|
||||
JAVA_ENUM_INTERFACE_HERITAGE_FEATURE,
|
||||
JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE,
|
||||
] as const;
|
||||
|
||||
interface PersistedFrameworkAnnotationRow {
|
||||
|
|
|
|||
|
|
@ -507,10 +507,33 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
|
|||
// Warm v63 ParsedFiles lack those captures and must be re-extracted.
|
||||
// 64 -> 66 adds the synthetic-declaration sidecar used to keep anonymous class
|
||||
// implementations from evicting ordinary implementors at the dispatch cap.
|
||||
// This PR already published a v64 head, while #2936 uses 65 for its independent
|
||||
// record accessor shape, so 66 keeps all three cached shapes distinct.
|
||||
// That PR published a v64 head first, so 66 kept all the shapes in flight at the
|
||||
// time distinct. (It also named a v65 claim from this branch; that claim was
|
||||
// superseded before either landed — see the 66 -> 67 entry below. Nothing holds
|
||||
// 65 now.)
|
||||
//
|
||||
// 66 -> 67 for #2917's implicit Java record-component accessor definitions and
|
||||
// scope declarations. A warm cache would otherwise replay ParsedFiles without
|
||||
// the synthesized accessors. This branch staged 65 before #2918's 66 landed on
|
||||
// main; 67 is the next free value above every in-flight claim (main 66, #2939's
|
||||
// 64), which is the ledger rule above — re-check against the claims, not just
|
||||
// against main.
|
||||
//
|
||||
// 67 -> 68 for #2912's `ReferenceSite.typeArguments`: the generic arguments a
|
||||
// heritage reference was written with (`: IValidator<string>`), derived at
|
||||
// EXTRACTION time from the anchor's spelling. A warm cache replays `inherits`
|
||||
// sites with the field absent, absence is the fail-open "unknown", and
|
||||
// generic-instantiation filtering therefore degrades to the pre-fix fan-out on
|
||||
// exactly the unchanged files — silent, and passing every cold-run test.
|
||||
//
|
||||
// This branch staged 64 when main held 60 and #2935/#2936/#2934 claimed 61/62/63.
|
||||
// All three have since landed and cascaded main to 67, burying 64 inside main's
|
||||
// own ledger — the EIGHTH time the re-check moved a number, and the reason the
|
||||
// re-check is a merge step rather than a one-time choice. 68 is the next free
|
||||
// value above every in-flight claim at this merge (main 67, #2891's 59, #1616's
|
||||
// stale 2), which is the rule above: above every claim, not above origin/main.
|
||||
// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING.
|
||||
const SCHEMA_BUMP = 66;
|
||||
const SCHEMA_BUMP = 68;
|
||||
const GITNEXUS_PKG_VERSION = (() => {
|
||||
try {
|
||||
// package.json sits at gitnexus/package.json — two levels up from
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@
|
|||
},
|
||||
"csharp-assignment-chain/Program.cs": {
|
||||
"captureGroups": 32,
|
||||
"digest": "7698bdabe97661a2a2539a13cf9d886cd7efb2f4924b17a15597ce63cec5126a"
|
||||
"digest": "6b8eddb5525ef0358276dc18fba264ee0443a8adf5036a3300891507b99c36ab"
|
||||
},
|
||||
"csharp-async-binding/Order.cs": {
|
||||
"captureGroups": 9,
|
||||
|
|
@ -49,11 +49,11 @@
|
|||
},
|
||||
"csharp-async-binding/OrderService.cs": {
|
||||
"captureGroups": 14,
|
||||
"digest": "712fb5f3a791581ab56c37df58d8245a17a674a6d2f7bd25b2bc8d1632f751c3"
|
||||
"digest": "492b2ceffaeae03b6a9d673f961dc5386bf08496ef4a8ae70dae68a604801c2f"
|
||||
},
|
||||
"csharp-async-binding/Program.cs": {
|
||||
"captureGroups": 37,
|
||||
"digest": "73735c3910ed4db423302d9575cec86156d420e9961c592c34e0436301cac7ce"
|
||||
"digest": "fc6bb5b9887e5193c90c687248d890873f5eb40f6a8e5b597729311dd3cc9f0a"
|
||||
},
|
||||
"csharp-async-binding/User.cs": {
|
||||
"captureGroups": 9,
|
||||
|
|
@ -61,11 +61,11 @@
|
|||
},
|
||||
"csharp-async-binding/UserService.cs": {
|
||||
"captureGroups": 14,
|
||||
"digest": "a4e6b093fa23a86313bc468f8b6a89a96d5c90b1d16f166417745bd36dfbd10f"
|
||||
"digest": "e68423fbb601a61a100d01ef070e0d37f817be4a4c6555500b56fa2ed290adce"
|
||||
},
|
||||
"csharp-call-result-binding/App.cs": {
|
||||
"captureGroups": 27,
|
||||
"digest": "3d8c7dc0b7f5bd60c74d6a595bd4b49bdb13b62522fb7f0c1434495e10e1171b"
|
||||
"digest": "7439cd5fada77ae186fb76594404a8650e21b4892ff268dfbcad6c3ec741c478"
|
||||
},
|
||||
"csharp-calls/Services/UserService.cs": {
|
||||
"captureGroups": 11,
|
||||
|
|
@ -93,7 +93,7 @@
|
|||
},
|
||||
"csharp-chain-call/Services/UserService.cs": {
|
||||
"captureGroups": 11,
|
||||
"digest": "9833795eeb79a08ef9c8afb66a58b789ab314e48c1ae3193fe87fec279c55374"
|
||||
"digest": "36a822100dccd931041f95be155b426d87f8e3d1dd1e2268f152b6f8fbffc6d5"
|
||||
},
|
||||
"csharp-child-extends-parent/src/App.cs": {
|
||||
"captureGroups": 13,
|
||||
|
|
@ -125,11 +125,11 @@
|
|||
},
|
||||
"csharp-deep-field-chain/Service.cs": {
|
||||
"captureGroups": 14,
|
||||
"digest": "30a18501a48916294ef08b2694d297bd46c72ad1d16bb654968c4293b9c1cd14"
|
||||
"digest": "daeb42918323c3f79de9b72ffc72d2c61bb085a65ffb1f67ca3a0d9a78ec06e8"
|
||||
},
|
||||
"csharp-dictionary-keys-values/App.cs": {
|
||||
"captureGroups": 21,
|
||||
"digest": "ee8eb9c569b71d7050f292bc7fbf89b68cdbb60b4bcd557f2523c86831891543"
|
||||
"digest": "1f6dfccf8eef881d22795dc6aa80bd267f0ee6f12538c7cabf5cac71db6a7f57"
|
||||
},
|
||||
"csharp-dictionary-keys-values/Repo.cs": {
|
||||
"captureGroups": 7,
|
||||
|
|
@ -153,7 +153,7 @@
|
|||
},
|
||||
"csharp-field-types/Service.cs": {
|
||||
"captureGroups": 11,
|
||||
"digest": "c38e3db8241460f2c3c295536c760a2452c0f1bc0ee084f7c76e63979ad84b51"
|
||||
"digest": "4cb300e33d2dcc08f869b6752c4655a34b67aebeb4baa44f37411117486d5f1d"
|
||||
},
|
||||
"csharp-foreach/Models/Repo.cs": {
|
||||
"captureGroups": 8,
|
||||
|
|
@ -165,7 +165,7 @@
|
|||
},
|
||||
"csharp-foreach/Program.cs": {
|
||||
"captureGroups": 20,
|
||||
"digest": "810f0f65e956343cf6817918dc5b28ce7bc1f1f755f89e008a6e8b05864ff469"
|
||||
"digest": "0f71f4a62926fb335d32b456d5778812519c0eb2bf85528e2e9073db1b976749"
|
||||
},
|
||||
"csharp-frozen-binding-collision/App/Program.cs": {
|
||||
"captureGroups": 18,
|
||||
|
|
@ -189,11 +189,11 @@
|
|||
},
|
||||
"csharp-generic-type-refs/Program.cs": {
|
||||
"captureGroups": 25,
|
||||
"digest": "e0cd6ea7dc08f66b651027f964f7a36fd3c4efb7a4584df5f14935b93faace5a"
|
||||
"digest": "28246dd1c88ecfe07fcee84ba314dbd9e39ccd0c30f20b8fb4633ec71e48e375"
|
||||
},
|
||||
"csharp-grandparent-resolution/Models/A.cs": {
|
||||
"captureGroups": 10,
|
||||
"digest": "3cd545b2cbec5fee82e9e3d09f2d2ff7ff940e3bf4b597d7c9080fcd8b526675"
|
||||
"digest": "159a52b959e021b1a34cc0dfbf1ba1a0748a0f29c84634948e2e85afc75db003"
|
||||
},
|
||||
"csharp-grandparent-resolution/Models/B.cs": {
|
||||
"captureGroups": 6,
|
||||
|
|
@ -221,7 +221,7 @@
|
|||
},
|
||||
"csharp-inline-constructor-receiver/src/Svc.cs": {
|
||||
"captureGroups": 19,
|
||||
"digest": "c2ec7f452c244d7fda931e32c16e46cc7c59e15f404a4413d18f522ed6c492bb"
|
||||
"digest": "cfbc783ca38a1149913b71f5dead7fd7a7048ca313cfaa7806c68ac9f1867e1f"
|
||||
},
|
||||
"csharp-interface-default-method/App.cs": {
|
||||
"captureGroups": 12,
|
||||
|
|
@ -321,7 +321,7 @@
|
|||
},
|
||||
"csharp-method-chain-binding/App.cs": {
|
||||
"captureGroups": 60,
|
||||
"digest": "2b4761e1dfe2d48ac25cfda7ccce95175607926b47db43726f38ad2a16acc6f1"
|
||||
"digest": "4cdccde81efbe41cac6bc82e33b365ccd79481a440e65012f78cb543421c9873"
|
||||
},
|
||||
"csharp-method-enrichment/Animal.cs": {
|
||||
"captureGroups": 18,
|
||||
|
|
@ -393,7 +393,7 @@
|
|||
},
|
||||
"csharp-null-check-narrowing/Services/App.cs": {
|
||||
"captureGroups": 36,
|
||||
"digest": "5d840c524610b6a84a2f09981c15327e9f7ea5c2c4b11b09e959d880ac6b9bc9"
|
||||
"digest": "6c7fa1daf5d12a60403a63d5d29c1b42b99370d12f4be53c8e56bb31e5b09d8a"
|
||||
},
|
||||
"csharp-null-conditional/App.cs": {
|
||||
"captureGroups": 17,
|
||||
|
|
@ -425,7 +425,7 @@
|
|||
},
|
||||
"csharp-overload-interface/App/Caller.cs": {
|
||||
"captureGroups": 15,
|
||||
"digest": "f1ea2e564c46dab5fb93fb19e81ab3411f458b172820b5dc0bdc57f49ffa88e0"
|
||||
"digest": "5769b1eda360cd588192335e47035683dae751b9f67e0528cccc066b1e4ed887"
|
||||
},
|
||||
"csharp-overload-interface/App/Logger.cs": {
|
||||
"captureGroups": 14,
|
||||
|
|
@ -445,7 +445,7 @@
|
|||
},
|
||||
"csharp-overload-param-types/Models/UserService.cs": {
|
||||
"captureGroups": 30,
|
||||
"digest": "178e1a7dd5b07ba3361e1eb28ce73ca6a6075fa8ecb8f2ca40e8e87a410de552"
|
||||
"digest": "ba08bc90619c578582465cb9f640fd0bf0640a5a6a84fdfed0be987802086bb3"
|
||||
},
|
||||
"csharp-parent-resolution/src/Models/BaseModel.cs": {
|
||||
"captureGroups": 8,
|
||||
|
|
@ -465,7 +465,7 @@
|
|||
},
|
||||
"csharp-pattern-matching/Services/AnimalService.cs": {
|
||||
"captureGroups": 13,
|
||||
"digest": "2623dcd94520473dc3dd830cfc21675349b6981185b779db3f5a47200b2f44a3"
|
||||
"digest": "6f308b4411f9ad397e2789d7f85eaaaed78f7879dd16f4d7b8d8e7639335d302"
|
||||
},
|
||||
"csharp-primary-ctor-heritage/src/BaseEntity.cs": {
|
||||
"captureGroups": 6,
|
||||
|
|
@ -581,7 +581,7 @@
|
|||
},
|
||||
"csharp-return-type/Models/User.cs": {
|
||||
"captureGroups": 23,
|
||||
"digest": "6681e6830c71c25908e50273e4babb41611bc229395d1dbab70ac9f219b68ca8"
|
||||
"digest": "8ca5e28d14a29fb1f29ca6f19300b26fd99d20bc99bd9f537787a41cd9fe6196"
|
||||
},
|
||||
"csharp-return-type/Services/App.cs": {
|
||||
"captureGroups": 16,
|
||||
|
|
@ -621,7 +621,7 @@
|
|||
},
|
||||
"csharp-spurious-edges-no-csproj/Services/OrderService.cs": {
|
||||
"captureGroups": 15,
|
||||
"digest": "2574c61dc312d531301a6d08c828ac743b1198e32946980c0f44bc115ec9bcdc"
|
||||
"digest": "77a89bc40ea022b9e795adcc1bf5e8a1bc67d8d1c5061c2fe990ec3d72248de0"
|
||||
},
|
||||
"csharp-spurious-edges/Legacy/Tasks.cs": {
|
||||
"captureGroups": 8,
|
||||
|
|
@ -633,7 +633,7 @@
|
|||
},
|
||||
"csharp-spurious-edges/Services/OrderService.cs": {
|
||||
"captureGroups": 15,
|
||||
"digest": "2574c61dc312d531301a6d08c828ac743b1198e32946980c0f44bc115ec9bcdc"
|
||||
"digest": "77a89bc40ea022b9e795adcc1bf5e8a1bc67d8d1c5061c2fe990ec3d72248de0"
|
||||
},
|
||||
"csharp-struct-overloads/src/Calc.cs": {
|
||||
"captureGroups": 19,
|
||||
|
|
@ -689,7 +689,7 @@
|
|||
},
|
||||
"csharp-var-foreach/Program.cs": {
|
||||
"captureGroups": 32,
|
||||
"digest": "a9c7bf1f2425cece1ecb698abc0c6fa5e7a3bb24e3cc7d2dba50ef7d0651badc"
|
||||
"digest": "58052d12af8b6e4f34924d59bd965773ce6083cb557adebe28eb1d3edcd41b7a"
|
||||
},
|
||||
"csharp-variadic-resolution/Services/App.cs": {
|
||||
"captureGroups": 10,
|
||||
|
|
@ -705,7 +705,7 @@
|
|||
},
|
||||
"csharp-write-access/Service.cs": {
|
||||
"captureGroups": 11,
|
||||
"digest": "aa6d8a61ac39db413df10a6bc8b9bad3305327dcdce09e01cf01eff31f945537"
|
||||
"digest": "f97be4b109be6bdaa583e2e7b3268b2fb90ac1ce3cfaf0291a7873f09103a2f9"
|
||||
},
|
||||
"synthetic:dao-20": {
|
||||
"captureGroups": 263,
|
||||
|
|
|
|||
5
gitnexus/test/fixtures/lang-resolution/java-import-package-evidence/User.java
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/java-import-package-evidence/User.java
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package com.example;
|
||||
|
||||
public class User {
|
||||
public void save() {}
|
||||
}
|
||||
11
gitnexus/test/fixtures/lang-resolution/java-import-package-evidence/app/Main.java
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/java-import-package-evidence/app/Main.java
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package app;
|
||||
|
||||
import java.util.List;
|
||||
import com.example.User;
|
||||
|
||||
public class Main {
|
||||
public void run() {
|
||||
User user = new User();
|
||||
user.save();
|
||||
}
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/java-import-package-evidence/util/List.java
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/java-import-package-evidence/util/List.java
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package util;
|
||||
|
||||
public class List {
|
||||
public void localOnly() {}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"name": "@repo/web",
|
||||
"dependencies": { "@acme/telemetry": "0.5.0", "@repo/utils": "workspace:*" }
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { Button } from '@ui/Button';
|
||||
|
||||
export function render(): string {
|
||||
return Button();
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { format } from 'utils/format';
|
||||
|
||||
export function run(): string {
|
||||
return format(' x ');
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export function Button(): string {
|
||||
return 'button';
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { help } from 'shared/helper';
|
||||
|
||||
export function useHelp(): string {
|
||||
return help();
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { init } from '@acme/telemetry/nest';
|
||||
|
||||
export function boot(): void {
|
||||
init();
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { excludedThing } from '@repo/excluded';
|
||||
|
||||
export function callExcluded(): string {
|
||||
return excludedThing();
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/typescript-pnpm-workspace-imports/apps/web/src/use.ts
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/typescript-pnpm-workspace-imports/apps/web/src/use.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { doThing } from '@repo/utils';
|
||||
|
||||
export function run(): string {
|
||||
return doThing();
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export function format(value: string): string {
|
||||
return value.trim();
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src",
|
||||
"paths": {
|
||||
"@ui/*": ["components/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{ "name": "@repo/excluded", "main": "src/index.ts" }
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export function excludedThing(): string {
|
||||
return 'excluded';
|
||||
}
|
||||
1
gitnexus/test/fixtures/lang-resolution/typescript-pnpm-workspace-imports/package.json
vendored
Normal file
1
gitnexus/test/fixtures/lang-resolution/typescript-pnpm-workspace-imports/package.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "name": "workspace-root", "private": true }
|
||||
|
|
@ -0,0 +1 @@
|
|||
{ "name": "@repo/inner", "main": "src/index.ts" }
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export function nested(): string {
|
||||
return 'inner';
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export function help(): string {
|
||||
return 'helped';
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{ "name": "@repo/utils", "main": "src/index.ts" }
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export function doThing(): string {
|
||||
return 'done';
|
||||
}
|
||||
3
gitnexus/test/fixtures/lang-resolution/typescript-pnpm-workspace-imports/pnpm-workspace.yaml
vendored
Normal file
3
gitnexus/test/fixtures/lang-resolution/typescript-pnpm-workspace-imports/pnpm-workspace.yaml
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
|
|
@ -661,7 +661,7 @@
|
|||
},
|
||||
"rust-qualified-trait/src/widget.rs": {
|
||||
"captureGroups": 23,
|
||||
"digest": "ee34385539f7e9398123c056738c6a662a80dac41fc038db96fab0da5c84c8ac"
|
||||
"digest": "f131767bf717a065166a5d8b6bdd969e8ea31c8725eecbedeac694b2e2aaeea5"
|
||||
},
|
||||
"rust-receiver-resolution/src/main.rs": {
|
||||
"captureGroups": 25,
|
||||
|
|
|
|||
|
|
@ -1,130 +0,0 @@
|
|||
/**
|
||||
* Production-path regression guard for the Java import-resolution indexes
|
||||
* (#2908).
|
||||
*
|
||||
* `resolveJavaImportTarget` reads TWO per-file-set indexes, each memoized on
|
||||
* the `allFilePaths` Set identity via its own WeakMap: the shared
|
||||
* `getWorkspaceFileIndex` (`import-resolvers/workspace-file-index.ts`, which
|
||||
* answers the whole-path and segment-suffix legs) and `getJavaDirIndex`
|
||||
* (`languages/java/import-target.ts`, the package-directory index behind
|
||||
* `firstFileDirectlyInPkgDir`). Before the hoist every leg was a full
|
||||
* `allFilePaths` scan, and the progressive-stripping loop re-ran that scan once
|
||||
* per stripped segment — so a four-segment `import` that resolves to nothing,
|
||||
* which is what every JDK and third-party import does, cost four full passes.
|
||||
*
|
||||
* Resolution reaches both indexes through `javaScopeResolver.resolveImportTarget`
|
||||
* — the orchestrator adapter — not by calling `resolveJavaImportTarget` directly
|
||||
* the way the unit parity test does. The adapter must therefore pass the Set
|
||||
* THROUGH; a defensive copy (`new Set(allFilePaths)`) would hand a fresh WeakMap
|
||||
* key per call and rebuild BOTH indexes on every import, restoring the
|
||||
* O(imports × files) behaviour this replaced. Python hit exactly that (PR #1918
|
||||
* review P1), and `test/unit/scope-resolution/java-import-target-parity.test.ts`
|
||||
* cannot see it: it never crosses the adapter.
|
||||
*
|
||||
* The counting instrument has to be a real `Set` subclass: `narrowContext`
|
||||
* rejects a workspace context whose `allFilePaths` fails `instanceof Set`, and a
|
||||
* rejected context resolves nothing — every assertion would then pass on
|
||||
* `null === null`.
|
||||
*
|
||||
* The traversal-count assertions are the perf guard. They are paired with result
|
||||
* assertions on purpose: a count of 2 is equally true of an adapter that has
|
||||
* stopped resolving anything at all, so counting alone would stay green while
|
||||
* every Java IMPORTS edge disappeared.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { javaScopeResolver } from '../../src/core/ingestion/languages/java/scope-resolver.js';
|
||||
import { CountingSet, expectDistinctFileSetsGetOwnIndex } from '../helpers/counting-file-set.js';
|
||||
|
||||
const { resolveImportTarget } = javaScopeResolver;
|
||||
|
||||
const FROM_FILE = 'src/main/java/com/example/App.java';
|
||||
|
||||
/**
|
||||
* A synthetic Java source tree covering all four legs of the cascade:
|
||||
* `com/example/model/User.java` answers the whole-path lookup,
|
||||
* `src/main/java/com/example/service/` answers the package-directory lookup a
|
||||
* wildcard import lands on, `src/main/java/com/example/util/Strings.java`
|
||||
* answers the nested-suffix lookup, and `domain/Order.java` is reachable only
|
||||
* after progressive prefix stripping.
|
||||
*/
|
||||
function buildWorkspace(fileCount: number): CountingSet {
|
||||
const files: string[] = [];
|
||||
for (let i = 0; i < fileCount; i++) {
|
||||
files.push(`src/main/java/com/example/service/Service${String(i).padStart(5, '0')}.java`);
|
||||
}
|
||||
files.push('com/example/model/User.java');
|
||||
files.push('src/main/java/com/example/util/Strings.java');
|
||||
files.push('domain/Order.java');
|
||||
files.push(FROM_FILE);
|
||||
return new CountingSet(files);
|
||||
}
|
||||
|
||||
describe('Java import resolution — index reuse across imports (#2908)', () => {
|
||||
it('builds each index 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 whole-path hit, a nested-suffix hit, a package-directory hit via a
|
||||
// wildcard, and a miss that runs the full progressive-stripping cascade —
|
||||
// the case that used to re-scan the workspace once per stripped prefix.
|
||||
resolved.push(resolveImportTarget('com.example.model.User', FROM_FILE, files, undefined));
|
||||
resolved.push(resolveImportTarget('com.example.util.Strings', FROM_FILE, files, undefined));
|
||||
resolved.push(resolveImportTarget('com.example.service.*', FROM_FILE, files, undefined));
|
||||
resolved.push(
|
||||
resolveImportTarget(`vendor${i}.ghost.deep.Missing`, FROM_FILE, files, undefined),
|
||||
);
|
||||
}
|
||||
|
||||
// Two passes: the shared workspace/suffix index and the package-dir index.
|
||||
// Not one: they are separate WeakMaps and `buildPackageDirIndex` takes the
|
||||
// Set, so each iterates it once — the same accounting as C# (#2878).
|
||||
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('com/example/model/User.java');
|
||||
expect(resolved[1]).toBe('src/main/java/com/example/util/Strings.java');
|
||||
expect(resolved[2]).toBe('src/main/java/com/example/service/Service00000.java');
|
||||
expect(resolved[3]).toBeNull();
|
||||
});
|
||||
|
||||
it('a distinct file set gets its own indexes (no stale cross-run reuse)', () => {
|
||||
expectDistinctFileSetsGetOwnIndex({
|
||||
resolveImportTarget,
|
||||
buildWorkspace: () => buildWorkspace(20),
|
||||
targetRaw: 'com.example.model.User',
|
||||
fromFile: FROM_FILE,
|
||||
resolutionConfig: undefined,
|
||||
expected: 'com/example/model/User.java',
|
||||
// Two, not one: the shared workspace/suffix index and the package-dir
|
||||
// index are separate WeakMaps over the same Set.
|
||||
expectedScans: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('still resolves real imports correctly (the perf test is not vacuous)', () => {
|
||||
const files = buildWorkspace(5);
|
||||
|
||||
// Whole-path match on the package path.
|
||||
expect(resolveImportTarget('com.example.model.User', FROM_FILE, files, undefined)).toBe(
|
||||
'com/example/model/User.java',
|
||||
);
|
||||
// Nested suffix match under the source root.
|
||||
expect(resolveImportTarget('com.example.util.Strings', FROM_FILE, files, undefined)).toBe(
|
||||
'src/main/java/com/example/util/Strings.java',
|
||||
);
|
||||
// Wildcard: `.*` is stripped and the package directory answers with its
|
||||
// first `.java` child in file-set order.
|
||||
expect(resolveImportTarget('com.example.service.*', FROM_FILE, files, undefined)).toBe(
|
||||
'src/main/java/com/example/service/Service00000.java',
|
||||
);
|
||||
// Progressive prefix stripping: the repo has no `com/shop/` prefix.
|
||||
expect(resolveImportTarget('com.shop.domain.Order', FROM_FILE, files, undefined)).toBe(
|
||||
'domain/Order.java',
|
||||
);
|
||||
|
||||
// Unknown packages resolve to nothing.
|
||||
expect(resolveImportTarget('vendor.ghost.Missing', FROM_FILE, files, undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -1332,7 +1332,13 @@ class PyMultiSvc:
|
|||
rows: [
|
||||
{
|
||||
caller: 'runTsNested',
|
||||
targets: ['Method:a.ts:Repo.save#1', 'Method:a.ts:UserRepo.save#1'],
|
||||
// No `UserRepo.save`, and that is the #2912 filter doing its job rather
|
||||
// than the receiver failing to resolve: `UserRepo implements Repo<User>`
|
||||
// is an implementor of a DIFFERENT instantiation from this receiver's
|
||||
// `Repo<Repo<User>>`, so no dispatch through it can reach `UserRepo`.
|
||||
// The primary edge to the interface's own declaration is unaffected,
|
||||
// which is what still proves the receiver typed correctly here.
|
||||
targets: ['Method:a.ts:Repo.save#1'],
|
||||
note: 'DISCRIMINATING nested generic: TypeScript reaches the shared lookup, unlike the Java/Kotlin/Rust spelling rows above',
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,492 @@
|
|||
/**
|
||||
* Interface-dispatch fan-out is generic-instantiation aware (#2912).
|
||||
*
|
||||
* `IValidator<string>` and `IValidator<int>` are one DECLARATION and therefore
|
||||
* one subtype list, so an erased fan-out reaches implementors of instantiations
|
||||
* the receiver can never hold. Each language here declares two incompatible
|
||||
* instantiations of one interface with the SAME method name — the shape the
|
||||
* issue was filed with — plus the cases the filter must not break: a generic
|
||||
* pass-through implementor, a non-generic interface, and (C#) the predefined
|
||||
* alias spellings of one type.
|
||||
*
|
||||
* Both ways a receiver gets its type are covered, because they reach the
|
||||
* instantiation by different routes: a DECLARED receiver (`Validator<string> v`)
|
||||
* carries it on the type binding, while a FOLDED one (`this._validator`,
|
||||
* `this._holder.Validator`) is typed by the compound fold, which answers with a
|
||||
* class and reports the spelling separately.
|
||||
*
|
||||
* Every implementor lives in its own file so a dispatch target can be named by
|
||||
* `targetFilePath`: the two `Check` methods are otherwise indistinguishable by
|
||||
* node name alone.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import {
|
||||
getRelationships,
|
||||
runPipelineFromRepo,
|
||||
writeFixtureRepo,
|
||||
type PipelineResult,
|
||||
} from './helpers.js';
|
||||
|
||||
/** Files a dispatch edge out of `caller` landed in, deduped and sorted. */
|
||||
function dispatchTargetFiles(result: PipelineResult, caller: string, member: string): string[] {
|
||||
const files = getRelationships(result, 'CALLS')
|
||||
.filter(
|
||||
(edge) =>
|
||||
edge.source === caller &&
|
||||
edge.target === member &&
|
||||
edge.rel.reason === 'interface-dispatch',
|
||||
)
|
||||
.map((edge) => path.basename(edge.targetFilePath));
|
||||
return [...new Set(files)].sort();
|
||||
}
|
||||
|
||||
/** Files ANY resolved call out of `caller` landed in — primary edges included. */
|
||||
function calledFiles(result: PipelineResult, caller: string, member: string): string[] {
|
||||
const files = getRelationships(result, 'CALLS')
|
||||
.filter((edge) => edge.source === caller && edge.target === member)
|
||||
.map((edge) => path.basename(edge.targetFilePath));
|
||||
return [...new Set(files)].sort();
|
||||
}
|
||||
|
||||
describe('C# generic interface dispatch (#2912)', () => {
|
||||
let result: PipelineResult;
|
||||
let root: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-csharp-generic-dispatch-'));
|
||||
writeFixtureRepo(root, {
|
||||
'IValidator.cs': `namespace Probe;
|
||||
public interface IValidator<T> { bool Check(T item); }`,
|
||||
'UserValidator.cs': `namespace Probe;
|
||||
public record UserValidator : IValidator<string> { public bool Check(string item) => true; }`,
|
||||
'IntValidator.cs': `namespace Probe;
|
||||
public record IntValidator : IValidator<int> { public bool Check(int item) => true; }`,
|
||||
'AliasValidator.cs': `namespace Probe;
|
||||
public class AliasValidator : IValidator<String> { public bool Check(String item) => true; }`,
|
||||
'GlobalAliasValidator.cs': `namespace Probe;
|
||||
public class GlobalAliasValidator : IValidator<global::System.String> { public bool Check(String item) => true; }`,
|
||||
'Wrapper.cs': `namespace Probe;
|
||||
public class Wrapper<T> : IValidator<T> { public bool Check(T item) => true; }`,
|
||||
'Runner.cs': `namespace Probe;
|
||||
public class Runner {
|
||||
public bool Run(IValidator<string> v) => v.Check("x");
|
||||
public bool RunInt(IValidator<int> v) => v.Check(1);
|
||||
public bool RunAny<TItem>(IValidator<TItem> v, TItem item) => v.Check(item);
|
||||
}`,
|
||||
});
|
||||
result = await runPipelineFromRepo(root, () => {});
|
||||
}, 60000);
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('does not fan a string-instantiated receiver out to the int implementor', () => {
|
||||
expect(dispatchTargetFiles(result, 'Run', 'Check')).not.toContain('IntValidator.cs');
|
||||
});
|
||||
|
||||
it('still reaches the implementor of the matching instantiation', () => {
|
||||
expect(dispatchTargetFiles(result, 'Run', 'Check')).toContain('UserValidator.cs');
|
||||
});
|
||||
|
||||
it('mirrors the filter for the other instantiation', () => {
|
||||
const intTargets = dispatchTargetFiles(result, 'RunInt', 'Check');
|
||||
expect(intTargets).toContain('IntValidator.cs');
|
||||
expect(intTargets).not.toContain('UserValidator.cs');
|
||||
});
|
||||
|
||||
it('keeps a generic pass-through implementor for BOTH instantiations', () => {
|
||||
// `Wrapper<T> : IValidator<T>` is an implementor of every instantiation —
|
||||
// T binds to the receiver's argument rather than clashing with it.
|
||||
expect(dispatchTargetFiles(result, 'Run', 'Check')).toContain('Wrapper.cs');
|
||||
expect(dispatchTargetFiles(result, 'RunInt', 'Check')).toContain('Wrapper.cs');
|
||||
});
|
||||
|
||||
it('treats the predefined alias spelling as the same instantiation', () => {
|
||||
// `IValidator<String>` ≡ `IValidator<string>`: C# defines the keyword as an
|
||||
// alias, so pruning on the spelling would delete a real dispatch target.
|
||||
expect(dispatchTargetFiles(result, 'Run', 'Check')).toContain('AliasValidator.cs');
|
||||
expect(dispatchTargetFiles(result, 'RunInt', 'Check')).not.toContain('AliasValidator.cs');
|
||||
});
|
||||
|
||||
it('treats the `global::`-qualified spelling as that same instantiation', () => {
|
||||
expect(dispatchTargetFiles(result, 'Run', 'Check')).toContain('GlobalAliasValidator.cs');
|
||||
expect(dispatchTargetFiles(result, 'RunInt', 'Check')).not.toContain('GlobalAliasValidator.cs');
|
||||
});
|
||||
|
||||
it('keeps every implementor when the receiver is typed by a CALLER type variable', () => {
|
||||
// `RunAny<TItem>(IValidator<TItem> v)` knows no instantiation, so the filter
|
||||
// has nothing to prune on and must restore the unfiltered fan-out. `TItem`
|
||||
// is a type parameter of the calling METHOD, which the subtype's own
|
||||
// parameter-list evidence says nothing about.
|
||||
const targets = dispatchTargetFiles(result, 'RunAny', 'Check');
|
||||
expect(targets).toContain('UserValidator.cs');
|
||||
expect(targets).toContain('IntValidator.cs');
|
||||
});
|
||||
|
||||
it('still emits the primary edge to the interface declaration', () => {
|
||||
expect(calledFiles(result, 'Run', 'Check')).toContain('IValidator.cs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('C# generic dispatch through a FOLDED receiver (#2912)', () => {
|
||||
// The dependency-injection shape: the receiver is a field reached through a
|
||||
// dot, so it is typed by the compound fold rather than by a type binding.
|
||||
// The fold answers with a CLASS, which no longer carries the instantiation —
|
||||
// the spelling it typed the position from is what does.
|
||||
let result: PipelineResult;
|
||||
let root: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-csharp-folded-dispatch-'));
|
||||
writeFixtureRepo(root, {
|
||||
'IValidator.cs': `namespace Probe;
|
||||
public interface IValidator<T> { bool Check(T item); }`,
|
||||
'UserValidator.cs': `namespace Probe;
|
||||
public class UserValidator : IValidator<string> { public bool Check(string item) => true; }`,
|
||||
'IntValidator.cs': `namespace Probe;
|
||||
public class IntValidator : IValidator<int> { public bool Check(int item) => true; }`,
|
||||
'Service.cs': `namespace Probe;
|
||||
public class Service {
|
||||
private readonly IValidator<string> _validator;
|
||||
public Service(IValidator<string> validator) { _validator = validator; }
|
||||
public bool Run() => this._validator.Check("x");
|
||||
}`,
|
||||
'Holder.cs': `namespace Probe;
|
||||
public class Holder {
|
||||
public IValidator<int> Validator { get; set; }
|
||||
}`,
|
||||
'ChainRunner.cs': `namespace Probe;
|
||||
public class ChainRunner {
|
||||
private readonly Holder _holder;
|
||||
public ChainRunner(Holder holder) { _holder = holder; }
|
||||
public bool RunChain() => this._holder.Validator.Check(1);
|
||||
}`,
|
||||
});
|
||||
result = await runPipelineFromRepo(root, () => {});
|
||||
}, 60000);
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('filters a field-typed receiver by its own instantiation', () => {
|
||||
const targets = dispatchTargetFiles(result, 'Run', 'Check');
|
||||
expect(targets).toContain('UserValidator.cs');
|
||||
expect(targets).not.toContain('IntValidator.cs');
|
||||
});
|
||||
|
||||
it("filters a two-hop chain by the LAST hop's instantiation", () => {
|
||||
// `this._holder.Validator` — the fold walks two members, and it is the
|
||||
// second one's declared spelling that types the receiver.
|
||||
const targets = dispatchTargetFiles(result, 'RunChain', 'Check');
|
||||
expect(targets).toContain('IntValidator.cs');
|
||||
expect(targets).not.toContain('UserValidator.cs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('C# non-generic interface dispatch is unaffected (#2912)', () => {
|
||||
let result: PipelineResult;
|
||||
let root: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-csharp-plain-dispatch-'));
|
||||
writeFixtureRepo(root, {
|
||||
'IGreeter.cs': `namespace Probe;
|
||||
public interface IGreeter { string Greet(); }`,
|
||||
'Loud.cs': `namespace Probe;
|
||||
public class Loud : IGreeter { public string Greet() => "HI"; }`,
|
||||
'Quiet.cs': `namespace Probe;
|
||||
public class Quiet : IGreeter { public string Greet() => "hi"; }`,
|
||||
'Runner.cs': `namespace Probe;
|
||||
public class Runner { public string Run(IGreeter g) => g.Greet(); }`,
|
||||
});
|
||||
result = await runPipelineFromRepo(root, () => {});
|
||||
}, 60000);
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('fans out to every implementor when no generics are involved', () => {
|
||||
expect(dispatchTargetFiles(result, 'Run', 'Greet')).toEqual(['Loud.cs', 'Quiet.cs']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Java generic interface dispatch (#2912)', () => {
|
||||
let result: PipelineResult;
|
||||
let root: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-java-generic-dispatch-'));
|
||||
writeFixtureRepo(root, {
|
||||
'Validator.java': `package probe;
|
||||
public interface Validator<T> { boolean check(T item); }`,
|
||||
'StringValidator.java': `package probe;
|
||||
public class StringValidator implements Validator<String> {
|
||||
public boolean check(String item) { return true; }
|
||||
}`,
|
||||
'NumberValidator.java': `package probe;
|
||||
public class NumberValidator implements Validator<Integer> {
|
||||
public boolean check(Integer item) { return true; }
|
||||
}`,
|
||||
'Runner.java': `package probe;
|
||||
public class Runner {
|
||||
public boolean run(Validator<String> v) { return v.check("x"); }
|
||||
public <T> boolean runAny(Validator<T> v, T item) { return v.check(item); }
|
||||
}`,
|
||||
});
|
||||
result = await runPipelineFromRepo(root, () => {});
|
||||
}, 60000);
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reaches only the implementor of the receiver instantiation', () => {
|
||||
const targets = dispatchTargetFiles(result, 'run', 'check');
|
||||
expect(targets).toContain('StringValidator.java');
|
||||
expect(targets).not.toContain('NumberValidator.java');
|
||||
});
|
||||
|
||||
it('keeps every implementor when the receiver is typed by a CALLER type variable', () => {
|
||||
const targets = dispatchTargetFiles(result, 'runAny', 'check');
|
||||
expect(targets).toContain('StringValidator.java');
|
||||
expect(targets).toContain('NumberValidator.java');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Kotlin generic interface dispatch (#2912)', () => {
|
||||
let result: PipelineResult;
|
||||
let root: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-kotlin-generic-dispatch-'));
|
||||
writeFixtureRepo(root, {
|
||||
'Validator.kt': `package probe
|
||||
interface Validator<T> { fun check(item: T): Boolean }`,
|
||||
'StringValidator.kt': `package probe
|
||||
class StringValidator : Validator<String> { override fun check(item: String): Boolean = true }`,
|
||||
'IntValidator.kt': `package probe
|
||||
class IntValidator : Validator<Int> { override fun check(item: Int): Boolean = true }`,
|
||||
'Runner.kt': `package probe
|
||||
class Runner {
|
||||
fun run(v: Validator<String>): Boolean = v.check("x")
|
||||
fun <T> runAny(v: Validator<T>, item: T): Boolean = v.check(item)
|
||||
}`,
|
||||
});
|
||||
result = await runPipelineFromRepo(root, () => {});
|
||||
}, 60000);
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reaches only the implementor of the receiver instantiation', () => {
|
||||
const targets = dispatchTargetFiles(result, 'run', 'check');
|
||||
expect(targets).toContain('StringValidator.kt');
|
||||
expect(targets).not.toContain('IntValidator.kt');
|
||||
});
|
||||
|
||||
it('keeps every implementor when the receiver is typed by a CALLER type variable', () => {
|
||||
const targets = dispatchTargetFiles(result, 'runAny', 'check');
|
||||
expect(targets).toContain('StringValidator.kt');
|
||||
expect(targets).toContain('IntValidator.kt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TypeScript generic interface dispatch (#2912)', () => {
|
||||
let result: PipelineResult;
|
||||
let root: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-ts-generic-dispatch-'));
|
||||
writeFixtureRepo(root, {
|
||||
'validator.ts': `export interface Validator<T> { check(item: T): boolean; }`,
|
||||
'string-validator.ts': `import type { Validator } from './validator.js';
|
||||
export class StringValidator implements Validator<string> {
|
||||
check(item: string): boolean { return true; }
|
||||
}`,
|
||||
'number-validator.ts': `import type { Validator } from './validator.js';
|
||||
export class NumberValidator implements Validator<number> {
|
||||
check(item: number): boolean { return true; }
|
||||
}`,
|
||||
'runner.ts': `import type { Validator } from './validator.js';
|
||||
export function run(v: Validator<string>): boolean { return v.check('x'); }
|
||||
export function runAny<T>(v: Validator<T>, item: T): boolean { return v.check(item); }`,
|
||||
});
|
||||
result = await runPipelineFromRepo(root, () => {});
|
||||
}, 60000);
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reaches only the implementor of the receiver instantiation', () => {
|
||||
const targets = dispatchTargetFiles(result, 'run', 'check');
|
||||
expect(targets).toContain('string-validator.ts');
|
||||
expect(targets).not.toContain('number-validator.ts');
|
||||
});
|
||||
|
||||
it('keeps every implementor when the receiver is typed by a CALLER type variable', () => {
|
||||
const targets = dispatchTargetFiles(result, 'runAny', 'check');
|
||||
expect(targets).toContain('string-validator.ts');
|
||||
expect(targets).toContain('number-validator.ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Kotlin generic interface dispatch (#2912)', () => {
|
||||
// Kotlin needs no per-language wiring: it emits heritage through the shared
|
||||
// pre-pass, so the arguments are read off the clause's own spelling. The
|
||||
// `class C : Bar<Int>()` shape — a base with a constructor invocation — is
|
||||
// the one `stripTrailingCallSuffix` exists for, and is covered here by the
|
||||
// supertype being an interface (no call suffix) plus the unit tests on that
|
||||
// helper.
|
||||
let result: PipelineResult;
|
||||
let root: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-kotlin-generic-dispatch-'));
|
||||
writeFixtureRepo(root, {
|
||||
'Validator.kt': `package probe
|
||||
interface Validator<T> { fun check(item: T): Boolean }`,
|
||||
'StringValidator.kt': `package probe
|
||||
class StringValidator : Validator<String> {
|
||||
override fun check(item: String): Boolean = true
|
||||
}`,
|
||||
'NumberValidator.kt': `package probe
|
||||
class NumberValidator : Validator<Int> {
|
||||
override fun check(item: Int): Boolean = true
|
||||
}`,
|
||||
'Runner.kt': `package probe
|
||||
class Runner { fun run(v: Validator<String>): Boolean = v.check("x") }`,
|
||||
});
|
||||
result = await runPipelineFromRepo(root, () => {});
|
||||
}, 60000);
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reaches only the implementor of the receiver instantiation', () => {
|
||||
const targets = dispatchTargetFiles(result, 'run', 'check');
|
||||
expect(targets).toContain('StringValidator.kt');
|
||||
expect(targets).not.toContain('NumberValidator.kt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Kotlin non-generic interface dispatch is unaffected (#2912)', () => {
|
||||
// The CONTROL for the case above. Without it, the `not.toContain` there
|
||||
// passes just as well when Kotlin emits no dispatch edge at all — which is
|
||||
// exactly what Dart, Python and Rust turned out to do for this receiver
|
||||
// shape, and why they are not asserted on in this file.
|
||||
let result: PipelineResult;
|
||||
let root: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-kotlin-plain-dispatch-'));
|
||||
writeFixtureRepo(root, {
|
||||
'Greeter.kt': `package probe
|
||||
interface Greeter { fun greet(): String }`,
|
||||
'Loud.kt': `package probe
|
||||
class Loud : Greeter { override fun greet(): String = "HI" }`,
|
||||
'Quiet.kt': `package probe
|
||||
class Quiet : Greeter { override fun greet(): String = "hi" }`,
|
||||
'Runner.kt': `package probe
|
||||
class Runner { fun run(g: Greeter): String = g.greet() }`,
|
||||
});
|
||||
result = await runPipelineFromRepo(root, () => {});
|
||||
}, 60000);
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('fans out to every implementor when no generics are involved', () => {
|
||||
expect(dispatchTargetFiles(result, 'run', 'greet')).toEqual(['Loud.kt', 'Quiet.kt']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Go generic interface dispatch (#2912)', () => {
|
||||
// Go reaches the same filter by a different route: implementors are matched
|
||||
// STRUCTURALLY rather than by a heritage clause, and the receiver's own
|
||||
// `Validator[string]` spelling is what carries the instantiation.
|
||||
let result: PipelineResult;
|
||||
let root: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-go-generic-dispatch-'));
|
||||
writeFixtureRepo(root, {
|
||||
'validator.go': `package probe
|
||||
|
||||
type Validator[T any] interface {
|
||||
Check(item T) bool
|
||||
}`,
|
||||
'string_validator.go': `package probe
|
||||
|
||||
type StringValidator struct{}
|
||||
|
||||
func (s StringValidator) Check(item string) bool { return true }`,
|
||||
'number_validator.go': `package probe
|
||||
|
||||
type NumberValidator struct{}
|
||||
|
||||
func (n NumberValidator) Check(item int) bool { return true }`,
|
||||
'runner.go': `package probe
|
||||
|
||||
func Run(v Validator[string]) bool { return v.Check("x") }`,
|
||||
});
|
||||
result = await runPipelineFromRepo(root, () => {});
|
||||
}, 60000);
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reaches only the implementor of the receiver instantiation', () => {
|
||||
const targets = dispatchTargetFiles(result, 'Run', 'Check');
|
||||
expect(targets).toContain('string_validator.go');
|
||||
expect(targets).not.toContain('number_validator.go');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Go non-generic interface dispatch is unaffected (#2912)', () => {
|
||||
let result: PipelineResult;
|
||||
let root: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-go-plain-dispatch-'));
|
||||
writeFixtureRepo(root, {
|
||||
'greeter.go': `package probe
|
||||
|
||||
type Greeter interface {
|
||||
Greet() string
|
||||
}`,
|
||||
'loud.go': `package probe
|
||||
|
||||
type Loud struct{}
|
||||
|
||||
func (l Loud) Greet() string { return "HI" }`,
|
||||
'quiet.go': `package probe
|
||||
|
||||
type Quiet struct{}
|
||||
|
||||
func (q Quiet) Greet() string { return "hi" }`,
|
||||
'runner.go': `package probe
|
||||
|
||||
func Run(g Greeter) string { return g.Greet() }`,
|
||||
});
|
||||
result = await runPipelineFromRepo(root, () => {});
|
||||
}, 60000);
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('fans out to every implementor when no generics are involved', () => {
|
||||
expect(dispatchTargetFiles(result, 'Run', 'Greet')).toEqual(['loud.go', 'quiet.go']);
|
||||
});
|
||||
});
|
||||
|
|
@ -553,6 +553,49 @@ describe('Java named import disambiguation', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Declared-package resolution (#2910): external imports cannot suffix-match
|
||||
// local lookalikes, while file layout does not override package declarations.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Java declared-package import resolution (#2910)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'java-import-package-evidence'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it('does not emit an IMPORTS edge from java.util.List to local util/List.java', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
expect(
|
||||
imports.some(
|
||||
(edge) =>
|
||||
edge.sourceFilePath === 'app/Main.java' && edge.targetFilePath === 'util/List.java',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves a declared package even when the file path is flattened', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS');
|
||||
expect(
|
||||
imports.some(
|
||||
(edge) => edge.sourceFilePath === 'app/Main.java' && edge.targetFilePath === 'User.java',
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
expect(
|
||||
calls.some(
|
||||
(edge) =>
|
||||
edge.source === 'run' && edge.target === 'save' && edge.targetFilePath === 'User.java',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variadic resolution: String... doesn't get filtered by arity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -626,7 +669,7 @@ describe('Java variadic call resolution', () => {
|
|||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wildcard import: `import com.example.models.*` resolves to a package file
|
||||
// Wildcard import: `import com.example.models.*` resolves every package file
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Java wildcard import resolution', () => {
|
||||
|
|
@ -636,15 +679,14 @@ describe('Java wildcard import resolution', () => {
|
|||
result = await runPipelineFromRepo(path.join(FIXTURES, 'java-wildcard-import'), () => {});
|
||||
}, 60000);
|
||||
|
||||
it('parses wildcard import without errors and creates graph nodes', () => {
|
||||
// The wildcard import (`import com.example.models.*`) exercises the
|
||||
// directoryChild branch in resolveJavaImportTarget. Even if no IMPORTS
|
||||
// edge is created (nondeterministic file selection — documented flip
|
||||
// blocker), the graph must contain valid nodes for all classes.
|
||||
const classes = getNodesByLabel(result, 'Class');
|
||||
expect(classes).toContain('Main');
|
||||
expect(classes).toContain('User');
|
||||
expect(classes).toContain('Order');
|
||||
it('emits IMPORTS edges to every file declaring the wildcard package', () => {
|
||||
const imports = getRelationships(result, 'IMPORTS').filter(
|
||||
(edge) => edge.sourceFilePath === 'com/example/app/Main.java',
|
||||
);
|
||||
expect(imports.map((edge) => edge.targetFilePath).sort()).toEqual([
|
||||
'com/example/models/Order.java',
|
||||
'com/example/models/User.java',
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves user.save() call via wildcard-imported User', () => {
|
||||
|
|
@ -1247,6 +1289,31 @@ describe('Java record method resolution (#2564)', () => {
|
|||
expect(sumCall).toBeDefined();
|
||||
});
|
||||
|
||||
// #2936: the implicit accessor is minted at the COMPONENT's position, so on a
|
||||
// single line it shares (name, line) with an explicit overload. The worker's
|
||||
// per-class method map keyed on that pair, so the appended implicit entry
|
||||
// evicted the source-written method and both definitions collapsed onto
|
||||
// `Scaled.x#0` — the arity-1 call then bound to a zero-argument target.
|
||||
it('keeps a same-line explicit overload distinct from the implicit accessor (#2936)', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-java-record-sameline-'));
|
||||
try {
|
||||
writeFixtureRepo(root, {
|
||||
'Scaled.java':
|
||||
'package probe;\npublic record Scaled(int x, int y) { int x(int factor) { return x * factor; } }\n',
|
||||
});
|
||||
|
||||
const linked = await runPipelineFromRepo(root, () => {});
|
||||
const arities = getNodesByLabelFull(linked, 'Method')
|
||||
.filter((node) => node.name === 'x')
|
||||
.map((node) => Number(node.properties.parameterCount))
|
||||
.sort((left, right) => left - right);
|
||||
|
||||
expect(arities).toEqual([0, 1]);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
it('uses the Record node as a caller source and constructor-call target (#2801)', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-java-record-link-'));
|
||||
try {
|
||||
|
|
@ -1309,15 +1376,19 @@ describe('Java record method resolution (#2564)', () => {
|
|||
}
|
||||
}, 60000);
|
||||
|
||||
it('documents missing dispatch to an implicit Record component accessor (#2917)', async () => {
|
||||
it('materializes implicit accessors and dispatches them through a Record interface (#2917)', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-java-record-accessor-'));
|
||||
try {
|
||||
writeFixtureRepo(root, {
|
||||
'RecordAccessor.java': `interface Named { String name(); }
|
||||
record User(String name) implements Named {}
|
||||
class Reader {
|
||||
String read(Named value) { return value.name(); }
|
||||
}`,
|
||||
'Named.java': 'interface Named { String name(); }',
|
||||
'User.java': 'record User(String name, java.util.List<String> tags) implements Named {}',
|
||||
'Explicit.java': `record Explicit(String name) implements Named {
|
||||
public String name() { return name.toUpperCase(); }
|
||||
}`,
|
||||
'Reader.java': `class Reader {
|
||||
String read(Named value) { return value.name(); }
|
||||
java.util.List<String> directTags(User value) { return value.tags(); }
|
||||
}`,
|
||||
});
|
||||
|
||||
const linked = await runPipelineFromRepo(root, () => {});
|
||||
|
|
@ -1331,11 +1402,49 @@ describe('Java record method resolution (#2564)', () => {
|
|||
edge.rel.reason === 'interface-dispatch',
|
||||
);
|
||||
|
||||
const methods = getNodesByLabelFull(linked, 'Method');
|
||||
const userName = methods.find(
|
||||
(method) => method.name === 'name' && method.properties.filePath.endsWith('User.java'),
|
||||
);
|
||||
const userTags = methods.find(
|
||||
(method) => method.name === 'tags' && method.properties.filePath.endsWith('User.java'),
|
||||
);
|
||||
const explicitNames = methods.filter(
|
||||
(method) => method.name === 'name' && method.properties.filePath.endsWith('Explicit.java'),
|
||||
);
|
||||
const userHasMethod = getRelationships(linked, 'HAS_METHOD').filter(
|
||||
(edge) => edge.source === 'User' && (edge.target === 'name' || edge.target === 'tags'),
|
||||
);
|
||||
const methodImplements = getRelationships(linked, 'METHOD_IMPLEMENTS').filter(
|
||||
(edge) => edge.source === 'name' && edge.target === 'name',
|
||||
);
|
||||
const directTags = getRelationships(linked, 'CALLS').find(
|
||||
(edge) => edge.source === 'directTags' && edge.target === 'tags',
|
||||
);
|
||||
|
||||
expect(implementsEdge?.sourceLabel).toBe('Record');
|
||||
expect(implementsEdge?.targetLabel).toBe('Interface');
|
||||
// TODO(#2917): implicit component accessors are not Method nodes yet.
|
||||
// Replace this characterization with the expected User.name target.
|
||||
expect(fanout).toEqual([]);
|
||||
expect(userName?.properties).toMatchObject({
|
||||
parameterCount: 0,
|
||||
returnType: 'String',
|
||||
visibility: 'public',
|
||||
});
|
||||
expect(userTags?.properties).toMatchObject({
|
||||
parameterCount: 0,
|
||||
returnType: 'java.util.List<String>',
|
||||
visibility: 'public',
|
||||
});
|
||||
expect(explicitNames).toHaveLength(1);
|
||||
expect(userHasMethod.map((edge) => edge.target).sort()).toEqual(['name', 'tags']);
|
||||
expect(methodImplements.map((edge) => edge.sourceFilePath).sort()).toEqual([
|
||||
expect.stringContaining('Explicit.java'),
|
||||
expect.stringContaining('User.java'),
|
||||
]);
|
||||
expect(fanout.map((edge) => edge.targetFilePath).sort()).toEqual([
|
||||
expect.stringContaining('Explicit.java'),
|
||||
expect.stringContaining('User.java'),
|
||||
]);
|
||||
expect(directTags?.targetFilePath).toContain('User.java');
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from '../../src/core/ingestion/frameworks/spring/analysis-features.js';
|
||||
import {
|
||||
JAVA_ENUM_INTERFACE_HERITAGE_FEATURE,
|
||||
JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE,
|
||||
SPRING_CONFIG_BINDINGS_FEATURE,
|
||||
} from '../../src/core/ingestion/languages/java/analysis-features.js';
|
||||
|
||||
|
|
@ -22,6 +23,7 @@ const FEATURES = [
|
|||
SPRING_CONDITIONALS_FEATURE,
|
||||
SPRING_CONFIG_BINDINGS_FEATURE,
|
||||
JAVA_ENUM_INTERFACE_HERITAGE_FEATURE,
|
||||
JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE,
|
||||
] as const;
|
||||
|
||||
describe('analysis feature versions', () => {
|
||||
|
|
@ -32,6 +34,7 @@ describe('analysis feature versions', () => {
|
|||
expect(resolveAnalysisFeatureVersions(FEATURES, ['src/App.java'])).toEqual({
|
||||
'graph.class-framework-annotations': 1,
|
||||
'java.heritage-captures': 1,
|
||||
'java.record-component-accessors': 1,
|
||||
'spring.aop-advice': 1,
|
||||
'spring.bean-inventory': 2,
|
||||
'spring.conditionals-auto-configuration': 1,
|
||||
|
|
|
|||
|
|
@ -207,22 +207,28 @@ describe('PARSE_CACHE_VERSION', () => {
|
|||
// Main subsequently advanced through 63. Values above it must remain distinct
|
||||
// from both published branch heads and every active in-flight claim.
|
||||
// Moved 63 -> 64 for Java enum and annotated heritage captures (#2918),
|
||||
// then 64 -> 66 for the synthetic-declaration sidecar. #2936 uses 65 for
|
||||
// its independent record-component accessor cache shape.
|
||||
it('pins SCHEMA_BUMP to 66 so concurrent bumps cannot silently collide (#2766)', () => {
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(66);
|
||||
// then 64 -> 66 for the synthetic-declaration sidecar, both now on main.
|
||||
// Moved 66 -> 67 for #2917's implicit Java record-component accessor
|
||||
// definitions and scope declarations. This branch staged 65 before #2918's 66
|
||||
// landed; 67 is the next free value above every in-flight claim (main 66,
|
||||
// #2939's 64), re-checked against the claims rather than against main alone.
|
||||
// Moved 67 -> 68 for #2912's `ReferenceSite.typeArguments` — heritage generic
|
||||
// arguments derived at extraction time, so a warm cache replays `inherits`
|
||||
// sites without them and instantiation-aware dispatch degrades silently to
|
||||
// the pre-fix fan-out. This branch staged 64 above the claims live at the
|
||||
// time (61, 62, 63); all three landed and cascaded main to 67, so 68 is the
|
||||
// next free value above every claim at merge — the rule, re-applied.
|
||||
it('pins SCHEMA_BUMP to 68 so concurrent bumps cannot silently collide (#2766)', () => {
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(68);
|
||||
// The PREVIOUS version must fail the reuse gate, not merely differ from the
|
||||
// current one — a hardcoded number outside the conflict hunk rebases cleanly
|
||||
// while being wrong, which is exactly how the 37/38 exact clashes landed.
|
||||
// Every nearby historical value is rejected: origin/main advanced through
|
||||
// 63, while this branch already published 64 and #2936 uses 65. Pinning 66
|
||||
// and rejecting all prior values makes an accidental conflict resolution loud.
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(60);
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(61);
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(62);
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(63);
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(64);
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(65);
|
||||
// 67, and this branch previously published 64. Pinning 68 and rejecting all
|
||||
// prior values makes an accidental conflict resolution loud.
|
||||
for (const taken of [60, 61, 62, 63, 64, 65, 66, 67]) {
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken);
|
||||
}
|
||||
});
|
||||
|
||||
it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => {
|
||||
|
|
|
|||
177
gitnexus/test/unit/java-module-resolution.test.ts
Normal file
177
gitnexus/test/unit/java-module-resolution.test.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
/**
|
||||
* Java import resolution against declared packages (#2953).
|
||||
*
|
||||
* The property: an import names a type in a package, and what puts a file in a
|
||||
* package is that file's own `package` declaration. Every arm below is a case
|
||||
* where the answer differs from what the file's PATH suggests — which is the
|
||||
* point, because path shape is what the previous resolver used and why it could
|
||||
* not tell a JDK import from a local one.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildJavaPackageIndex,
|
||||
resolveJavaModule,
|
||||
type JavaPackageIndex,
|
||||
} from '../../src/core/ingestion/languages/java/module-resolution.js';
|
||||
import { interpretJavaImport } from '../../src/core/ingestion/languages/java/interpret.js';
|
||||
import type { JvmPackageFact } from '../../src/core/ingestion/languages/jvm/package-facts.js';
|
||||
import type { Capture, CaptureMatch, ParsedFile } from 'gitnexus-shared';
|
||||
|
||||
/** Build an index from `filePath -> declared package`. */
|
||||
function indexOf(declarations: Readonly<Record<string, string | null>>): JavaPackageIndex {
|
||||
const parsedFiles = Object.keys(declarations).map(
|
||||
(filePath) => ({ filePath }) as unknown as ParsedFile,
|
||||
);
|
||||
const packageOf = (filePath: string): JvmPackageFact | undefined => {
|
||||
const declared = declarations[filePath];
|
||||
if (declared === undefined) return undefined;
|
||||
// `null` models a malformed package header, which `extractJvmPackageFact`
|
||||
// reports as `unknown` — the file is in no package anyone can name.
|
||||
return declared === null ? { status: 'unknown' } : { status: 'known', packageName: declared };
|
||||
};
|
||||
return buildJavaPackageIndex(parsedFiles, packageOf);
|
||||
}
|
||||
|
||||
function capture(text: string): Capture {
|
||||
return { text } as Capture;
|
||||
}
|
||||
|
||||
const WORKSPACE = indexOf({
|
||||
'src/main/java/com/example/App.java': 'com.example',
|
||||
'src/main/java/com/example/Utils.java': 'com.example',
|
||||
'src/main/java/com/example/model/User.java': 'com.example.model',
|
||||
'src/main/java/com/example/model/Order.java': 'com.example.model',
|
||||
// Path and package deliberately disagree: this is legal Java, and the
|
||||
// declaration is what counts.
|
||||
'weird/place/Detached.java': 'com.example.detached',
|
||||
// A file whose PATH looks like the JDK's but which declares its own package.
|
||||
'vendor/java/util/List.java': 'vendor.java.util',
|
||||
});
|
||||
|
||||
describe('type imports', () => {
|
||||
it('resolves a type to the file declaring its package', () => {
|
||||
expect(resolveJavaModule('com.example.model.User', WORKSPACE)).toBe(
|
||||
'src/main/java/com/example/model/User.java',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves a file whose path does not match its package', () => {
|
||||
// Nothing about `weird/place/Detached.java` suggests `com.example.detached`
|
||||
// except the declaration, which is exactly what a path matcher cannot read.
|
||||
expect(resolveJavaModule('com.example.detached.Detached', WORKSPACE)).toBe(
|
||||
'weird/place/Detached.java',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves a static member import to the type that owns it', () => {
|
||||
// `com.example.Utils` is the type; `format` is a member inside it. The
|
||||
// longest DECLARED package prefix is `com.example`, so the split lands
|
||||
// there rather than treating `Utils` as a package.
|
||||
expect(resolveJavaModule('com.example.Utils.format', WORKSPACE)).toBe(
|
||||
'src/main/java/com/example/Utils.java',
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers the longest declared package prefix', () => {
|
||||
// `com.example` also declares types, so a shorter split would have to be
|
||||
// rejected on the type name alone; the longest package wins first.
|
||||
expect(resolveJavaModule('com.example.model.Order', WORKSPACE)).toBe(
|
||||
'src/main/java/com/example/model/Order.java',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('imports of things outside the repository (#2953)', () => {
|
||||
it('does not resolve a JDK import onto a same-named local file', () => {
|
||||
// The reported defect. `vendor/java/util/List.java` ends in `java/util/List`
|
||||
// and the old resolver bound `java.util.List` straight to it. No file
|
||||
// declares package `java.util`, so there is nothing here to name.
|
||||
expect(resolveJavaModule('java.util.List', WORKSPACE)).toBeNull();
|
||||
});
|
||||
|
||||
it('still resolves the local file under the package it DOES declare', () => {
|
||||
// The paired positive: the same file is reachable, just not by the JDK's
|
||||
// name for it. Without this arm the one above could pass by resolving
|
||||
// nothing at all.
|
||||
expect(resolveJavaModule('vendor.java.util.List', WORKSPACE)).toBe(
|
||||
'vendor/java/util/List.java',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not resolve a third-party import', () => {
|
||||
expect(resolveJavaModule('org.springframework.stereotype.Service', WORKSPACE)).toBeNull();
|
||||
expect(resolveJavaModule('com.google.common.collect.Lists', WORKSPACE)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not resolve a declared package with an undeclared type', () => {
|
||||
// `com.example` is real; `Missing` is not. A partial match is not a match.
|
||||
expect(resolveJavaModule('com.example.Missing', WORKSPACE)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not resolve a bare single-segment name', () => {
|
||||
// Java forbids importing from the default package, so there is no spelling
|
||||
// of a one-segment import that names an in-repo type.
|
||||
expect(resolveJavaModule('App', WORKSPACE)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('wildcard imports', () => {
|
||||
it('answers with EVERY file in the package, not one', () => {
|
||||
// The old resolver returned the first `.java` in the package directory in
|
||||
// file-set iteration order — an answer decided by the file list rather than
|
||||
// by the import.
|
||||
expect(resolveJavaModule('com.example.model.*', WORKSPACE)).toEqual([
|
||||
'src/main/java/com/example/model/User.java',
|
||||
'src/main/java/com/example/model/Order.java',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a static wildcard on a type distinct from a same-named package', () => {
|
||||
const index = indexOf({
|
||||
'src/main/java/com/example/Utils.java': 'com.example',
|
||||
'src/main/java/com/example/Utils/Nested.java': 'com.example.Utils',
|
||||
});
|
||||
const parsed = interpretJavaImport({
|
||||
'@import.kind': capture('static-wildcard'),
|
||||
'@import.source': capture('com.example.Utils'),
|
||||
'@import.name': capture('*'),
|
||||
} as CaptureMatch);
|
||||
|
||||
expect(parsed).toEqual({ kind: 'wildcard', targetRaw: 'com.example.Utils' });
|
||||
if (parsed === null || parsed.targetRaw === null) throw new Error('import was not interpreted');
|
||||
expect(resolveJavaModule(parsed.targetRaw, index)).toBe('src/main/java/com/example/Utils.java');
|
||||
expect(resolveJavaModule('com.example.Utils.*', index)).toEqual([
|
||||
'src/main/java/com/example/Utils/Nested.java',
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolves nothing for a wildcard on an undeclared package', () => {
|
||||
expect(resolveJavaModule('java.util.*', WORKSPACE)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the index itself', () => {
|
||||
it('counts files whose package header could not be read', () => {
|
||||
const index = indexOf({ 'src/Broken.java': null, 'src/Fine.java': 'ok.pkg' });
|
||||
|
||||
expect(index.unreadablePackageFiles).toBe(1);
|
||||
// The unreadable file is in no package, so nothing can import it — but the
|
||||
// readable one beside it is unaffected.
|
||||
expect(resolveJavaModule('ok.pkg.Fine', index)).toBe('src/Fine.java');
|
||||
});
|
||||
|
||||
it('indexes the default package but refuses imports from it', () => {
|
||||
const index = indexOf({ 'Root.java': '' });
|
||||
|
||||
// Same-package visibility still needs the file bucketed…
|
||||
expect(index.filesByPackage.get('')).toEqual(['Root.java']);
|
||||
// …but `import Root;` is not legal Java and resolves to nothing.
|
||||
expect(resolveJavaModule('Root', index)).toBeNull();
|
||||
});
|
||||
|
||||
it('is empty for an empty workspace, and resolves nothing', () => {
|
||||
const empty = buildJavaPackageIndex([], () => undefined);
|
||||
|
||||
expect(resolveJavaModule('com.example.model.User', empty)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { createMethodExtractor } from '../../src/core/ingestion/method-extractors/generic.js';
|
||||
import { javaRecordMethodExtractor } from '../../src/core/ingestion/languages/java/record-components.js';
|
||||
import {
|
||||
javaMethodConfig,
|
||||
kotlinMethodConfig,
|
||||
|
|
@ -18,6 +19,7 @@ import { phpMethodConfig } from '../../src/core/ingestion/method-extractors/conf
|
|||
import { swiftMethodConfig } from '../../src/core/ingestion/method-extractors/configs/swift.js';
|
||||
import { goMethodConfig } from '../../src/core/ingestion/method-extractors/configs/go.js';
|
||||
import type { MethodExtractorContext } from '../../src/core/ingestion/method-types.js';
|
||||
import { methodInfoKey } from '../../src/core/ingestion/utils/method-props.js';
|
||||
import Parser from 'tree-sitter';
|
||||
import Java from 'tree-sitter-java';
|
||||
import Go from 'tree-sitter-go';
|
||||
|
|
@ -98,7 +100,7 @@ const csharpCtx: MethodExtractorContext = {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Java MethodExtractor', () => {
|
||||
const extractor = createMethodExtractor(javaMethodConfig);
|
||||
const extractor = javaRecordMethodExtractor;
|
||||
|
||||
describe('isTypeDeclaration', () => {
|
||||
it('recognizes class_declaration', () => {
|
||||
|
|
@ -404,6 +406,124 @@ describe('Java MethodExtractor', () => {
|
|||
expect(ctor!.parameters[0].name).toBe('x');
|
||||
expect(ctor!.parameters[1].name).toBe('y');
|
||||
});
|
||||
|
||||
it('synthesizes public zero-argument accessors with full component return types', () => {
|
||||
const tree = parseJava('public record User(String name, java.util.List<String> tags) {}');
|
||||
const result = extractor.extract(tree.rootNode.child(0)!, javaCtx);
|
||||
|
||||
expect(result!.methods).toHaveLength(2);
|
||||
expect(result!.methods).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'name',
|
||||
returnType: 'String',
|
||||
parameters: [],
|
||||
visibility: 'public',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'tags',
|
||||
returnType: 'java.util.List<String>',
|
||||
parameters: [],
|
||||
visibility: 'public',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('exposes a varargs component through its array-typed accessor', () => {
|
||||
const tree = parseJava('public record Samples(String... values) {}');
|
||||
const result = extractor.extract(tree.rootNode.child(0)!, javaCtx);
|
||||
|
||||
expect(result!.methods).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: 'values',
|
||||
returnType: 'String[]',
|
||||
parameters: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps an explicit canonical accessor as the single definition', () => {
|
||||
const tree = parseJava(`
|
||||
public record User(String name) {
|
||||
public String name(/* canonical accessor */) { return name.toUpperCase(); }
|
||||
}
|
||||
`);
|
||||
const result = extractor.extract(tree.rootNode.child(0)!, javaCtx);
|
||||
const accessors = result!.methods.filter((method) => method.name === 'name');
|
||||
|
||||
expect(accessors).toHaveLength(1);
|
||||
expect(accessors[0].line).toBe(3);
|
||||
});
|
||||
|
||||
it('does not count an explicit accessor receiver parameter toward arity', () => {
|
||||
const tree = parseJava(`
|
||||
public record User(String name) {
|
||||
public String name(User this) { return name.toUpperCase(); }
|
||||
}
|
||||
`);
|
||||
const result = extractor.extract(tree.rootNode.child(0)!, javaCtx);
|
||||
const accessors = result!.methods.filter((method) => method.name === 'name');
|
||||
|
||||
expect(accessors).toHaveLength(1);
|
||||
expect(accessors[0].parameters).toEqual([]);
|
||||
expect(accessors[0].line).toBe(3);
|
||||
});
|
||||
|
||||
it('retains an explicit overload alongside the implicit accessor', () => {
|
||||
const tree = parseJava(`
|
||||
public record User(String name) {
|
||||
public String name(int repeat) { return name.repeat(repeat); }
|
||||
}
|
||||
`);
|
||||
const result = extractor.extract(tree.rootNode.child(0)!, javaCtx);
|
||||
const accessors = result!.methods.filter((method) => method.name === 'name');
|
||||
|
||||
expect(accessors).toHaveLength(2);
|
||||
expect(accessors.map((method) => method.parameters.length).sort()).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
// #2936: the accessor is minted at the COMPONENT's position, so on a single
|
||||
// line it shares (name, line) with an explicit overload. The worker's
|
||||
// per-class map keys on that pair, so before `column` the appended implicit
|
||||
// entry evicted the source-written method and both ids collapsed to `x#0`.
|
||||
it('gives a same-line implicit accessor and explicit overload distinct map keys', () => {
|
||||
const tree = parseJava(
|
||||
'public record User(String name) { public String name(int repeat) { return name.repeat(repeat); } }',
|
||||
);
|
||||
const result = extractor.extract(tree.rootNode.child(0)!, javaCtx);
|
||||
const keys = result!.methods
|
||||
.filter((method) => method.name === 'name')
|
||||
.map((method) => methodInfoKey(method.name, method.line, method.column))
|
||||
.sort();
|
||||
|
||||
expect(keys).toEqual(['name:1:19', 'name:1:34']);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a dropped component type', 'record M(int x, y) {}', ['x']],
|
||||
['a nameless varargs component', 'record W(int... ) {}', []],
|
||||
['an underscore component', 'record R(int _) {}', []],
|
||||
['an underscore varargs component', 'record S(int... _) {}', []],
|
||||
])('synthesizes no accessor for %s', (_label, source, expected) => {
|
||||
const tree = parseJava(source);
|
||||
const result = extractor.extract(tree.rootNode.child(0)!, javaCtx);
|
||||
|
||||
expect(result!.methods.map((method) => method.name)).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a marker annotation', 'record U(@Marker String name) {}', ['@Marker']],
|
||||
['an annotation with arguments', 'record U(@Marker("x") String name) {}', ['@Marker']],
|
||||
['several annotations', 'record U(@A @B String name) {}', ['@A', '@B']],
|
||||
['an annotated varargs component', 'record U(@A String... xs) {}', ['@A']],
|
||||
['no annotation', 'record U(String name) {}', []],
|
||||
])('propagates %s to the implicit accessor', (_label, source, expected) => {
|
||||
const tree = parseJava(source);
|
||||
const result = extractor.extract(tree.rootNode.child(0)!, javaCtx);
|
||||
|
||||
expect(result!.methods[0]!.annotations).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extract primitive varargs', () => {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ function makeMethodInfo(
|
|||
annotations: [],
|
||||
sourceFile: 'test.java',
|
||||
line: 1,
|
||||
column: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
341
gitnexus/test/unit/node-workspace-packages.test.ts
Normal file
341
gitnexus/test/unit/node-workspace-packages.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
172
gitnexus/test/unit/node-workspace-scope.test.ts
Normal file
172
gitnexus/test/unit/node-workspace-scope.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,443 @@
|
|||
/**
|
||||
* 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 { ParsedFile, 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';
|
||||
import {
|
||||
clearJavaPackageFacts,
|
||||
setJavaPackageFact,
|
||||
} from '../../../src/core/ingestion/languages/java/package-facts.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;
|
||||
/**
|
||||
* Declarations the resolver reads from the parsed workspace rather than from
|
||||
* path shape. Java resolves entirely this way since #2953 — a specifier names
|
||||
* a type in a DECLARED package — so a case that supplied only paths would
|
||||
* measure a resolver with no workspace at all, and every arm would pass for
|
||||
* the wrong reason.
|
||||
*/
|
||||
readonly declare?: () => void;
|
||||
}
|
||||
|
||||
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,
|
||||
// `vendor/util/List.java` declares package `vendor.util`, so
|
||||
// `vendor.util.List` is a real import of it and `java.util.List` is not.
|
||||
// The two differ ONLY by what the workspace declares — both specifiers
|
||||
// end in `util.List`, and both would match the same path suffix.
|
||||
declare: () => {
|
||||
clearJavaPackageFacts();
|
||||
setJavaPackageFact('vendor/util/List.java', {
|
||||
status: 'known',
|
||||
packageName: 'vendor.util',
|
||||
});
|
||||
setJavaPackageFact('com/example/model/User.java', {
|
||||
status: 'known',
|
||||
packageName: 'com.example.model',
|
||||
});
|
||||
setJavaPackageFact('src/main/java/com/example/App.java', {
|
||||
status: 'known',
|
||||
packageName: 'com.example',
|
||||
});
|
||||
},
|
||||
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.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);
|
||||
testCase.declare?.();
|
||||
// The parsed workspace, supplied only to a case that declares one. A minimal
|
||||
// `{ filePath }` stand-in is exactly right for a resolver that reads the file
|
||||
// list plus its own fact store (Java), and WRONG for one that reads other
|
||||
// ParsedFile fields (PHP's `filesByDirectory`), which would silently resolve
|
||||
// differently against stubs than against real parsed files.
|
||||
const parsedFiles =
|
||||
testCase.declare === undefined
|
||||
? []
|
||||
: testCase.files.map((filePath) => ({ filePath }) as unknown as ParsedFile);
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,367 @@
|
|||
/**
|
||||
* Unit tests for the generic-instantiation matcher behind interface-dispatch
|
||||
* fan-out (#2912) and for the spelling reader that feeds it.
|
||||
*
|
||||
* The integration suite proves the filter reaches real graphs; these pin the
|
||||
* decisions the filter is MADE of, and above all the fail-open ones — an
|
||||
* unknown that starts pruning is a silently missing edge, which is the failure
|
||||
* mode this design is built to avoid.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
heritageTypeArgumentsKey,
|
||||
stepHeritageInstantiation,
|
||||
type HeritageInstantiationStep,
|
||||
} from '../../../src/core/ingestion/scope-resolution/utils/generic-instantiation.js';
|
||||
import { typeApplicationArguments } from '../../../src/core/ingestion/utils/template-arguments.js';
|
||||
import { csharpScopeResolver } from '../../../src/core/ingestion/languages/csharp/scope-resolver.js';
|
||||
|
||||
/** A step with everything unresolvable and no parameters — the pessimistic
|
||||
* baseline each test overrides only what it is about. */
|
||||
function step(overrides: Partial<HeritageInstantiationStep>): HeritageInstantiationStep {
|
||||
return {
|
||||
supertypeArguments: undefined,
|
||||
heritageArguments: undefined,
|
||||
subtypeParameters: undefined,
|
||||
subtypeParametersComplete: true,
|
||||
resolveSupertypeArgument: () => ({ builtIn: false }),
|
||||
resolveHeritageArgument: () => ({ builtIn: false }),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('stepHeritageInstantiation — pruning on positive evidence', () => {
|
||||
it('prunes an implementor of a different instantiation', () => {
|
||||
const result = stepHeritageInstantiation(
|
||||
step({ supertypeArguments: ['string'], heritageArguments: ['int'] }),
|
||||
);
|
||||
expect(result.compatible).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps an implementor of the same instantiation', () => {
|
||||
const result = stepHeritageInstantiation(
|
||||
step({ supertypeArguments: ['string'], heritageArguments: ['string'] }),
|
||||
);
|
||||
expect(result.compatible).toBe(true);
|
||||
});
|
||||
|
||||
it('prunes on a difference in any position, not just the first', () => {
|
||||
const result = stepHeritageInstantiation(
|
||||
step({ supertypeArguments: ['string', 'User'], heritageArguments: ['string', 'Admin'] }),
|
||||
);
|
||||
expect(result.compatible).toBe(false);
|
||||
});
|
||||
|
||||
it('compares what the names RESOLVED to, so a qualifier is not a difference', () => {
|
||||
const result = stepHeritageInstantiation(
|
||||
step({
|
||||
supertypeArguments: ['User'],
|
||||
heritageArguments: ['Models.User'],
|
||||
resolveSupertypeArgument: () => ({ definitionId: 'def:User', builtIn: false }),
|
||||
resolveHeritageArgument: () => ({ definitionId: 'def:User', builtIn: false }),
|
||||
}),
|
||||
);
|
||||
expect(result.compatible).toBe(true);
|
||||
});
|
||||
|
||||
it('prunes two names that resolved to different declarations', () => {
|
||||
const result = stepHeritageInstantiation(
|
||||
step({
|
||||
supertypeArguments: ['User'],
|
||||
heritageArguments: ['Admin'],
|
||||
resolveSupertypeArgument: () => ({ definitionId: 'def:User', builtIn: false }),
|
||||
resolveHeritageArgument: () => ({ definitionId: 'def:Admin', builtIn: false }),
|
||||
}),
|
||||
);
|
||||
expect(result.compatible).toBe(false);
|
||||
});
|
||||
|
||||
it('applies the language normalizer to both sides before comparing', () => {
|
||||
const result = stepHeritageInstantiation(
|
||||
step({
|
||||
supertypeArguments: ['string'],
|
||||
heritageArguments: ['String'],
|
||||
normalize: (name) => (name === 'string' ? 'String' : name),
|
||||
}),
|
||||
);
|
||||
expect(result.compatible).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps an unresolved qualified spelling of the same simple name', () => {
|
||||
const result = stepHeritageInstantiation(
|
||||
step({ supertypeArguments: ['String'], heritageArguments: ['java.lang.String'] }),
|
||||
);
|
||||
expect(result.compatible).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stepHeritageInstantiation — substitution', () => {
|
||||
it('binds a type variable instead of comparing it', () => {
|
||||
const result = stepHeritageInstantiation(
|
||||
step({
|
||||
supertypeArguments: ['string'],
|
||||
heritageArguments: ['T'],
|
||||
subtypeParameters: [{ name: 'T' }],
|
||||
}),
|
||||
);
|
||||
expect(result.compatible).toBe(true);
|
||||
expect(result.subtypeArguments).toEqual(['string']);
|
||||
});
|
||||
|
||||
it('carries the binding in the subtype’s own parameter order', () => {
|
||||
const result = stepHeritageInstantiation(
|
||||
step({
|
||||
supertypeArguments: ['string', 'int'],
|
||||
heritageArguments: ['V', 'K'],
|
||||
subtypeParameters: [{ name: 'K' }, { name: 'V' }],
|
||||
}),
|
||||
);
|
||||
expect(result.subtypeArguments).toEqual(['int', 'string']);
|
||||
});
|
||||
|
||||
it('reports an unknown instantiation when a parameter stayed unbound', () => {
|
||||
const result = stepHeritageInstantiation(
|
||||
step({
|
||||
supertypeArguments: ['string'],
|
||||
heritageArguments: ['T'],
|
||||
subtypeParameters: [{ name: 'T' }, { name: 'U' }],
|
||||
}),
|
||||
);
|
||||
expect(result.compatible).toBe(true);
|
||||
expect(result.subtypeArguments).toBeUndefined();
|
||||
});
|
||||
|
||||
it('prunes a repeated variable the two positions disagree about', () => {
|
||||
// `class C<T> : Pair<T, T>` is not a `Pair<string, int>` at any
|
||||
// instantiation; the second position must not overwrite the first.
|
||||
const result = stepHeritageInstantiation(
|
||||
step({
|
||||
supertypeArguments: ['string', 'int'],
|
||||
heritageArguments: ['T', 'T'],
|
||||
subtypeParameters: [{ name: 'T' }],
|
||||
resolveSupertypeArgument: () => ({ builtIn: true }),
|
||||
}),
|
||||
);
|
||||
expect(result.compatible).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a repeated variable both positions agree about', () => {
|
||||
const result = stepHeritageInstantiation(
|
||||
step({
|
||||
supertypeArguments: ['string', 'string'],
|
||||
heritageArguments: ['T', 'T'],
|
||||
subtypeParameters: [{ name: 'T' }],
|
||||
}),
|
||||
);
|
||||
expect(result.compatible).toBe(true);
|
||||
expect(result.subtypeArguments).toEqual(['string']);
|
||||
});
|
||||
|
||||
it('keeps, without a binding, when a repeated variable cannot be decided', () => {
|
||||
// `ExternalA` and `ExternalB` are both unresolvable, so the disagreement is
|
||||
// not proven — and the binding the next hop would inherit is not either.
|
||||
const result = stepHeritageInstantiation(
|
||||
step({
|
||||
supertypeArguments: ['ExternalA', 'ExternalB'],
|
||||
heritageArguments: ['T', 'T'],
|
||||
subtypeParameters: [{ name: 'T' }],
|
||||
}),
|
||||
);
|
||||
expect(result.compatible).toBe(true);
|
||||
expect(result.subtypeArguments).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stepHeritageInstantiation — every uncertainty keeps the target', () => {
|
||||
it('keeps when the receiver instantiation is unknown', () => {
|
||||
const result = stepHeritageInstantiation(step({ heritageArguments: ['int'] }));
|
||||
expect(result.compatible).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps when the heritage clause recorded no arguments', () => {
|
||||
const result = stepHeritageInstantiation(step({ supertypeArguments: ['string'] }));
|
||||
expect(result.compatible).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps when the two argument lists have different lengths', () => {
|
||||
const result = stepHeritageInstantiation(
|
||||
step({ supertypeArguments: ['string'], heritageArguments: ['string', 'int'] }),
|
||||
);
|
||||
expect(result.compatible).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a wildcard receiver argument, which names a SET of types', () => {
|
||||
// `Repo<? extends User>` genuinely holds a `Repo<User>`; so do Kotlin's
|
||||
// `Repo<*>` and `Repo<out User>`.
|
||||
for (const wildcard of ['? extends User', '?', '* ', 'out User', 'in User']) {
|
||||
const result = stepHeritageInstantiation(
|
||||
step({
|
||||
supertypeArguments: [wildcard],
|
||||
heritageArguments: ['User'],
|
||||
resolveSupertypeArgument: () => ({ builtIn: true }),
|
||||
resolveHeritageArgument: () => ({ definitionId: 'def:User', builtIn: false }),
|
||||
}),
|
||||
);
|
||||
expect(result.compatible).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a nullable spelling of the same argument', () => {
|
||||
const result = stepHeritageInstantiation(
|
||||
step({ supertypeArguments: ['User?'], heritageArguments: ['User'] }),
|
||||
);
|
||||
expect(result.compatible).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores whitespace when comparing nested spellings', () => {
|
||||
const result = stepHeritageInstantiation(
|
||||
step({
|
||||
supertypeArguments: ['Map<string, User>'],
|
||||
heritageArguments: ['Map<string,User>'],
|
||||
}),
|
||||
);
|
||||
expect(result.compatible).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps every implementor for a receiver typed with a CALLER type variable', () => {
|
||||
// `void Run<T>(IValidator<T> v) { v.Check(x); }`. `T` belongs to the calling
|
||||
// method, not to the subtype, so `subtypeParametersComplete` — which is
|
||||
// evidence about the SUBTYPE's list — says nothing about it. An unbounded
|
||||
// `T` grounds to nothing and a bounded one grounds to its BOUND; both would
|
||||
// otherwise compare unequal to the implementor's concrete argument.
|
||||
for (const receiverType of [
|
||||
{ builtIn: false, typeVariable: true },
|
||||
{ definitionId: 'def:User', builtIn: false, typeVariable: true },
|
||||
]) {
|
||||
const result = stepHeritageInstantiation(
|
||||
step({
|
||||
supertypeArguments: ['T'],
|
||||
heritageArguments: ['Admin'],
|
||||
subtypeParametersComplete: true,
|
||||
resolveSupertypeArgument: () => receiverType,
|
||||
resolveHeritageArgument: () => ({ definitionId: 'def:Admin', builtIn: false }),
|
||||
}),
|
||||
);
|
||||
expect(result.compatible).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a heritage argument that is a type variable of an ENCLOSING declaration', () => {
|
||||
const result = stepHeritageInstantiation(
|
||||
step({
|
||||
supertypeArguments: ['string'],
|
||||
heritageArguments: ['T'],
|
||||
subtypeParametersComplete: true,
|
||||
resolveSupertypeArgument: () => ({ builtIn: true }),
|
||||
resolveHeritageArgument: () => ({ builtIn: false, typeVariable: true }),
|
||||
}),
|
||||
);
|
||||
expect(result.compatible).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps an unresolvable argument when the parameter list may be incomplete', () => {
|
||||
// The `T` of `class Box<T> : IValidator<T>` in a language that captures no
|
||||
// type parameters: indistinguishable from a concrete type named T, so it
|
||||
// must not be pruned on.
|
||||
const result = stepHeritageInstantiation(
|
||||
step({
|
||||
supertypeArguments: ['string'],
|
||||
heritageArguments: ['T'],
|
||||
subtypeParametersComplete: false,
|
||||
}),
|
||||
);
|
||||
expect(result.compatible).toBe(true);
|
||||
});
|
||||
|
||||
it('prunes the same pair once BOTH names are grounded', () => {
|
||||
const result = stepHeritageInstantiation(
|
||||
step({
|
||||
supertypeArguments: ['string'],
|
||||
heritageArguments: ['int'],
|
||||
subtypeParametersComplete: false,
|
||||
resolveSupertypeArgument: () => ({ builtIn: true }),
|
||||
resolveHeritageArgument: () => ({ builtIn: true }),
|
||||
}),
|
||||
);
|
||||
expect(result.compatible).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('heritageTypeArgumentsKey', () => {
|
||||
it('keeps a pair distinct from the same ids in the other order', () => {
|
||||
expect(heritageTypeArgumentsKey('a', 'b')).not.toBe(heritageTypeArgumentsKey('b', 'a'));
|
||||
});
|
||||
|
||||
it('separates on a character a file path cannot contain', () => {
|
||||
// `Class:a b.cs:A` + `Class:c.cs:C` must not be spellable two ways.
|
||||
expect(heritageTypeArgumentsKey('Class:a b.cs:A', 'Class:c.cs:C')).not.toBe(
|
||||
heritageTypeArgumentsKey('Class:a', 'b.cs:A Class:c.cs:C'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('typeApplicationArguments', () => {
|
||||
it('reads angle-bracket arguments', () => {
|
||||
expect(typeApplicationArguments('IValidator<string>')).toEqual(['string']);
|
||||
});
|
||||
|
||||
it('reads bracket arguments (Go embedding, Python bases)', () => {
|
||||
expect(typeApplicationArguments('Base[User]')).toEqual(['User']);
|
||||
});
|
||||
|
||||
it('splits only at top level', () => {
|
||||
expect(typeApplicationArguments('Map<string, List<int>>')).toEqual(['string', 'List<int>']);
|
||||
expect(typeApplicationArguments('Cache<Dict[str, int], bool>')).toEqual([
|
||||
'Dict[str, int]',
|
||||
'bool',
|
||||
]);
|
||||
});
|
||||
|
||||
it('declines a plain name, an array spelling, and a constructor call', () => {
|
||||
expect(typeApplicationArguments('Repository')).toBeUndefined();
|
||||
expect(typeApplicationArguments('User[]')).toBeUndefined();
|
||||
expect(typeApplicationArguments('Base(args)')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('declines a list that does not close at the end', () => {
|
||||
expect(typeApplicationArguments('Repo<User> by delegate')).toBeUndefined();
|
||||
expect(typeApplicationArguments('(Int) -> Unit')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('declines brackets that cross families', () => {
|
||||
// A one-family counter never sees the `]`, reaches the final `>` at depth
|
||||
// zero and reports `Bar]` as a balanced argument list.
|
||||
expect(typeApplicationArguments('Foo<Bar]>')).toBeUndefined();
|
||||
expect(typeApplicationArguments('Foo[Bar>]')).toBeUndefined();
|
||||
expect(typeApplicationArguments('Map<Dict[a, b>]')).toBeUndefined();
|
||||
// The well-formed mixed nesting it must NOT start declining.
|
||||
expect(typeApplicationArguments('List<Dict[a, b]>')).toEqual(['Dict[a, b]']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('C# normalizeTypeArgument', () => {
|
||||
const normalize = csharpScopeResolver.normalizeTypeArgument as (name: string) => string;
|
||||
|
||||
it('makes every spelling of a predefined type one name', () => {
|
||||
// Including the `global::` alias qualifier, which this repository already
|
||||
// unwraps when decomposing imports.
|
||||
for (const spelling of ['string', 'String', 'System.String', 'global::System.String']) {
|
||||
expect(normalize(spelling)).toBe('String');
|
||||
}
|
||||
expect(normalize('int')).toBe('Int32');
|
||||
});
|
||||
|
||||
it('leaves an unrelated qualified name as written', () => {
|
||||
expect(normalize('Foo.String')).toBe('Foo.String');
|
||||
expect(normalize('Models.User')).toBe('Models.User');
|
||||
});
|
||||
|
||||
it('keeps the qualifier on an ordinary type that merely lives in System', () => {
|
||||
// Stripping `System.` unconditionally would answer `Custom` here, equating
|
||||
// this with an unrelated `Custom` elsewhere in the workspace. Only a
|
||||
// spelling that reduces to a PREDEFINED type earns the strip.
|
||||
expect(normalize('System.Custom')).toBe('System.Custom');
|
||||
expect(normalize('global::System.Custom')).toBe('global::System.Custom');
|
||||
expect(normalize('System.Collections.Generic.List')).toBe('System.Collections.Generic.List');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
/**
|
||||
* Heritage generic ARGUMENTS reach resolution, across languages (#2912).
|
||||
*
|
||||
* Three routes exist, and every language uses exactly one of them:
|
||||
*
|
||||
* 1. The `@reference.inherits` ANCHOR already spans the whole base, so the
|
||||
* spelling is read straight off it and no query changed (C#, Java,
|
||||
* TypeScript, Kotlin, Go, Python, Swift).
|
||||
* 2. The anchor is the bare NAME node — widening it would move the site's
|
||||
* range, which is part of every inheritance edge's id — so the arguments
|
||||
* arrive through the `@reference.type-arguments` sub-tag (Rust, Dart
|
||||
* `extends`).
|
||||
* 3. The clause never becomes a reference site at all, and rides a heritage
|
||||
* MARKER payload instead (Dart `implements` / `with`).
|
||||
*
|
||||
* Each is pinned here because instantiation filtering degrades SILENTLY to the
|
||||
* pre-#2912 fan-out when a capture stops arriving: no error, no failing edge
|
||||
* count, just an interface reaching implementors of the wrong instantiation
|
||||
* again.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
import { extractParsedFile } from '../../../src/core/ingestion/scope-extractor-bridge.js';
|
||||
import type { LanguageProvider } from '../../../src/core/ingestion/language-provider.js';
|
||||
import { csharpProvider } from '../../../src/core/ingestion/languages/csharp.js';
|
||||
import { javaProvider } from '../../../src/core/ingestion/languages/java.js';
|
||||
import { typescriptProvider } from '../../../src/core/ingestion/languages/typescript.js';
|
||||
import { kotlinProvider } from '../../../src/core/ingestion/languages/kotlin.js';
|
||||
import { goProvider } from '../../../src/core/ingestion/languages/go.js';
|
||||
import { pythonProvider } from '../../../src/core/ingestion/languages/python.js';
|
||||
import { swiftProvider } from '../../../src/core/ingestion/languages/swift.js';
|
||||
import { rustProvider } from '../../../src/core/ingestion/languages/rust.js';
|
||||
import { dartProvider } from '../../../src/core/ingestion/languages/dart.js';
|
||||
import { decodeMarker } from '../../../src/core/ingestion/utils/heritage-marker.js';
|
||||
|
||||
function inheritsSites(
|
||||
provider: LanguageProvider,
|
||||
source: string,
|
||||
filePath: string,
|
||||
): Array<{ name: string; typeArguments?: readonly string[] }> {
|
||||
const parsed: ParsedFile | undefined = extractParsedFile(provider, source, filePath);
|
||||
return (parsed?.referenceSites ?? [])
|
||||
.filter((site) => site.kind === 'inherits')
|
||||
.map((site) => ({ name: site.name, typeArguments: site.typeArguments }));
|
||||
}
|
||||
|
||||
describe('heritage type arguments are captured', () => {
|
||||
it('C# base list', () => {
|
||||
expect(
|
||||
inheritsSites(
|
||||
csharpProvider,
|
||||
'namespace P;\npublic record V : IValidator<string> { }',
|
||||
'V.cs',
|
||||
),
|
||||
).toEqual([{ name: 'IValidator', typeArguments: ['string'] }]);
|
||||
});
|
||||
|
||||
it('C# record with a primary-constructor base', () => {
|
||||
// `Base<int>(x)` writes a CALL in the heritage position; the call is not
|
||||
// part of the type and must not stop the arguments being read.
|
||||
expect(
|
||||
inheritsSites(
|
||||
csharpProvider,
|
||||
'namespace P;\npublic record R(int x) : Base<int>(x) { }',
|
||||
'R.cs',
|
||||
),
|
||||
).toEqual([{ name: 'Base', typeArguments: ['int'] }]);
|
||||
});
|
||||
|
||||
it('Java implements clause', () => {
|
||||
expect(
|
||||
inheritsSites(
|
||||
javaProvider,
|
||||
'package p;\npublic class V implements Validator<String> { }',
|
||||
'V.java',
|
||||
),
|
||||
).toEqual([{ name: 'Validator', typeArguments: ['String'] }]);
|
||||
});
|
||||
|
||||
it('TypeScript implements clause', () => {
|
||||
expect(
|
||||
inheritsSites(typescriptProvider, 'export class V implements Validator<string> { }', 'v.ts'),
|
||||
).toEqual([{ name: 'Validator', typeArguments: ['string'] }]);
|
||||
});
|
||||
|
||||
it('Kotlin delegation specifier, with and without a constructor call', () => {
|
||||
expect(inheritsSites(kotlinProvider, 'class V : Validator<String>() { }', 'v.kt')).toEqual([
|
||||
{ name: 'Validator', typeArguments: ['String'] },
|
||||
]);
|
||||
expect(inheritsSites(kotlinProvider, 'class V : Validator<String> { }', 'v2.kt')).toEqual([
|
||||
{ name: 'Validator', typeArguments: ['String'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('Go generic struct embedding (bracket application)', () => {
|
||||
expect(inheritsSites(goProvider, 'package p\ntype S struct { Base[int] }', 's.go')).toEqual([
|
||||
{ name: 'Base', typeArguments: ['int'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('Python subscripted base (bracket application)', () => {
|
||||
expect(inheritsSites(pythonProvider, 'class Repo(Base[User]):\n pass\n', 'r.py')).toEqual([
|
||||
{ name: 'Base', typeArguments: ['User'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('Swift inheritance clause', () => {
|
||||
expect(inheritsSites(swiftProvider, 'class Repo: Base<User> { }', 'r.swift')).toEqual([
|
||||
{ name: 'Base', typeArguments: ['User'] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitters whose anchor is the bare name use the explicit sub-tag', () => {
|
||||
it('Rust trait impl', () => {
|
||||
// The anchor is the trait NAME node inside a `generic_type`, and its range
|
||||
// is part of the inheritance edge's id — so the arguments arrive through
|
||||
// `@reference.type-arguments` rather than by widening the anchor.
|
||||
expect(inheritsSites(rustProvider, 'impl Validator<String> for V { }', 'v.rs')).toEqual([
|
||||
{ name: 'Validator', typeArguments: ['String'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('Rust trait impl without arguments records none', () => {
|
||||
expect(inheritsSites(rustProvider, 'impl Validator for V { }', 'v2.rs')).toEqual([
|
||||
{ name: 'Validator', typeArguments: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it('Dart extends clause', () => {
|
||||
expect(inheritsSites(dartProvider, 'class Repo extends Base<User> { }', 'r.dart')).toEqual([
|
||||
{ name: 'Base', typeArguments: ['User'] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('heritage that never becomes a reference site', () => {
|
||||
// Dart's `implements` / `with` travel as heritage MARKERS on parsed imports,
|
||||
// not as `inherits` sites: `emitDartHeritageEdges` reads the marker and emits
|
||||
// the edge, so the instantiation has to ride the payload to reach the same
|
||||
// sink the generic pre-pass writes to (#2912).
|
||||
function heritageMarkers(source: string, filePath: string): Array<string[]> {
|
||||
const parsed = extractParsedFile(dartProvider, source, filePath);
|
||||
return (parsed?.parsedImports ?? [])
|
||||
.map((imported) => decodeMarker(String(imported.targetRaw)))
|
||||
.filter(
|
||||
(marker): marker is { kind: 'heritage'; fields: string[] } => marker?.kind === 'heritage',
|
||||
)
|
||||
.map((marker) => marker.fields);
|
||||
}
|
||||
|
||||
it('carries the arguments of a Dart `implements` clause', () => {
|
||||
expect(heritageMarkers('class V implements Validator<String> { }', 'v.dart')).toEqual([
|
||||
['implements', 'Validator', 'V', '<String>'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('carries the arguments of a Dart `with` clause', () => {
|
||||
expect(heritageMarkers('class V extends Base with M<int> { }', 'v2.dart')).toEqual([
|
||||
['with', 'M', 'V', '<int>'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits the field for a non-generic clause, so old payloads stay readable', () => {
|
||||
expect(heritageMarkers('class V implements Validator { }', 'v3.dart')).toEqual([
|
||||
['implements', 'Validator', 'V'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-generic heritage stays byte-identical', () => {
|
||||
it('records no arguments for a plain base', () => {
|
||||
expect(
|
||||
inheritsSites(csharpProvider, 'namespace P;\npublic class C : Base { }', 'C.cs'),
|
||||
).toEqual([{ name: 'Base', typeArguments: undefined }]);
|
||||
});
|
||||
});
|
||||
|
|
@ -118,6 +118,10 @@ import type { ParsedImport } from 'gitnexus-shared';
|
|||
|
||||
import { SCOPE_RESOLVERS } from '../../../src/core/ingestion/scope-resolution/pipeline/registry.js';
|
||||
import type { ScopeResolver } from '../../../src/core/ingestion/scope-resolution/contract/scope-resolver.js';
|
||||
import {
|
||||
clearJavaPackageFacts,
|
||||
setJavaPackageFact,
|
||||
} from '../../../src/core/ingestion/languages/java/package-facts.js';
|
||||
import type { ComposerConfig } from '../../../src/core/ingestion/language-config.js';
|
||||
import {
|
||||
CountingSet,
|
||||
|
|
@ -201,6 +205,13 @@ interface ImportTargetFixture {
|
|||
* how every arm of this file passed while measuring nothing on that channel.
|
||||
*/
|
||||
readonly minimumParsedFileReads: number;
|
||||
/**
|
||||
* Seed whatever per-language fact store the resolver reads alongside
|
||||
* `parsedFiles`. Java resolves an import against DECLARED packages (#2953),
|
||||
* so without this its `hitTarget` resolves to nothing and every count below
|
||||
* would be the count of a resolver doing nothing.
|
||||
*/
|
||||
readonly declare?: () => void;
|
||||
}
|
||||
|
||||
/** The `composer.json` PSR-4 map `loadPhpComposerConfig` would have produced. */
|
||||
|
|
@ -275,19 +286,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,
|
||||
{
|
||||
|
|
@ -309,13 +307,28 @@ const FIXTURES: ReadonlyMap<SupportedLanguages, ImportTargetFixture> = new Map<
|
|||
files: ['com/example/model/User.java', 'src/main/java/com/example/App.java'],
|
||||
fromFile: 'src/main/java/com/example/App.java',
|
||||
resolutionConfig: undefined,
|
||||
// Four segments and no hit: the progressive-stripping loop runs to the
|
||||
// end, which is what every JDK and third-party import does.
|
||||
// A four-segment miss, which is what every JDK and third-party import is.
|
||||
// Since #2953 that is a lookup miss rather than a scan: no package named
|
||||
// `vendor<i>.ghost.deep` is declared, so there is nothing to search.
|
||||
missTarget: (i) => `vendor${i}.ghost.deep.Missing`,
|
||||
hitTarget: 'com.example.model.User',
|
||||
parsedImport: IGNORES_CONTEXT,
|
||||
minimumScans: 1,
|
||||
minimumParsedFileReads: 0,
|
||||
declare: () => {
|
||||
clearJavaPackageFacts();
|
||||
setJavaPackageFact('com/example/model/User.java', {
|
||||
status: 'known',
|
||||
packageName: 'com.example.model',
|
||||
});
|
||||
setJavaPackageFact('src/main/java/com/example/App.java', {
|
||||
status: 'known',
|
||||
packageName: 'com.example',
|
||||
});
|
||||
},
|
||||
// Java reads the DECLARATIONS, not the file set: the package index is
|
||||
// built from `context.parsedFiles`, so the file-set counter stays at zero
|
||||
// and the parsed-file counter is where this property is now measured.
|
||||
minimumScans: 0,
|
||||
minimumParsedFileReads: 1,
|
||||
},
|
||||
],
|
||||
[
|
||||
|
|
@ -388,19 +401,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 +477,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;
|
||||
|
|
@ -559,6 +581,7 @@ function driveImports(
|
|||
fixture: ImportTargetFixture,
|
||||
importCount: number,
|
||||
): ImportRun {
|
||||
fixture.declare?.();
|
||||
const files = new CountingSet(fixture.files);
|
||||
const workspace = countedParsedFiles(fixture.files);
|
||||
const contextFor = (targetRaw: string) => ({
|
||||
|
|
@ -640,11 +663,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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,718 +0,0 @@
|
|||
/**
|
||||
* Differential harness for the Java import-target index hoist (#2908).
|
||||
*
|
||||
* `resolveJavaImportTarget` answered its three-tier cascade with a full
|
||||
* `allFilePaths` scan, and ran that scan AGAIN inside the progressive
|
||||
* prefix-stripping loop — once per stripped segment. Replacing those scans with
|
||||
* the per-file-set indexes (`getWorkspaceFileIndex` +
|
||||
* `buildPackageDirIndex`/`firstFileDirectlyInPkgDir`) is a pure performance
|
||||
* change ONLY if every implicit tie-break survives, and those tie-breaks are
|
||||
* expressed through Set-iteration order and `indexOf` positions rather than
|
||||
* through anything the type system or the existing Java tests can see:
|
||||
*
|
||||
* - the first pass `break`s on an exact whole-path hit, so an exact match wins
|
||||
* over a suffix OR directory-child match found EARLIER in iteration order;
|
||||
* - the stripping loop instead returns mid-scan at the first hit of
|
||||
* `f === tailFile || f.endsWith('/' + tailFile)` — no exact-wins rule there
|
||||
* — while its directory child is collected and returned only after the scan
|
||||
* completes, so file/suffix beats directory child within one `skip` level
|
||||
* regardless of order;
|
||||
* - the directory-child leg answers when the file's PARENT directory ends
|
||||
* with `pathLike`, so `com/example/com/example/Deep.java` DOES answer
|
||||
* `com.example`. It took the FIRST `'/' + pathLike + '/'` occurrence until
|
||||
* #2881, which made a package whose name repeats higher in its own path
|
||||
* unresolvable;
|
||||
* - a wildcard import drops its trailing `.*` before any of that runs;
|
||||
* - paths are compared normalized (`\` → `/`) but returned RAW.
|
||||
*
|
||||
* So this file keeps a copy of the pre-change implementation — the
|
||||
* `resolveJavaImportTarget` that shipped before #2908, scans and all, minus the
|
||||
* one rule #2881 deliberately removed (see tie-break 3) — and
|
||||
* asserts the new one agrees with it, both on hand-built corpora built to force
|
||||
* exactly those cases and on a generated corpus replayed under three insertion
|
||||
* orders — order being the only channel most of these tie-breaks travel on.
|
||||
* The copy is the specification; if a future change makes an arm here fail, the
|
||||
* resolver's OUTPUT moved and Java's IMPORTS edges move with it.
|
||||
*
|
||||
* The hand-built arm additionally pins ABSOLUTE expectations. A pure
|
||||
* differential goes green when old and new agree on `null` everywhere, which is
|
||||
* also what a corpus that has quietly stopped matching anything looks like.
|
||||
*
|
||||
* The last arm counts how often the file Set is iterated, as the deterministic
|
||||
* guard against a scan reintroduced BESIDE the reused index. It is not the
|
||||
* guard for a defensive `new Set(allFilePaths)` copy in the orchestrator
|
||||
* ADAPTER — that lives one layer above every call here, and is guarded by
|
||||
* `test/integration/java-import-index-reuse.test.ts`.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
|
||||
|
||||
import { resolveJavaImportTarget } from '../../../src/core/ingestion/languages/java/import-target.js';
|
||||
import { CountingSet } from '../../helpers/counting-file-set.js';
|
||||
|
||||
// ─── pre-change implementation, minus the rule #2881 removed ─────────────────
|
||||
|
||||
interface LegacyJavaResolveContext {
|
||||
readonly fromFile: string;
|
||||
readonly allFilePaths: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
function legacyResolveJavaImportTarget(
|
||||
parsedImport: ParsedImport,
|
||||
workspaceIndex: WorkspaceIndex,
|
||||
): string | null {
|
||||
const ctx = workspaceIndex as LegacyJavaResolveContext | undefined;
|
||||
if (
|
||||
ctx === undefined ||
|
||||
typeof (ctx as { fromFile?: unknown }).fromFile !== 'string' ||
|
||||
!((ctx as { allFilePaths?: unknown }).allFilePaths instanceof Set)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (parsedImport.kind === 'dynamic-unresolved') return null;
|
||||
if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null;
|
||||
|
||||
// Strip trailing `.*` for wildcard imports: `com.example.*` → `com.example`
|
||||
let target = parsedImport.targetRaw;
|
||||
if (target.endsWith('.*')) {
|
||||
target = target.slice(0, -2);
|
||||
}
|
||||
|
||||
// Package path: `com.example.User` → `com/example/User`
|
||||
const pathLike = target.replace(/\./g, '/');
|
||||
const suffix = `/${pathLike}`;
|
||||
|
||||
let exactFile: string | null = null;
|
||||
let suffixFile: string | null = null;
|
||||
let directoryChild: string | null = null;
|
||||
const suffixDirPrefix = `/${pathLike}/`;
|
||||
|
||||
for (const raw of ctx.allFilePaths) {
|
||||
const f = raw.replace(/\\/g, '/');
|
||||
if (!f.endsWith('.java')) continue;
|
||||
if (f === `${pathLike}.java`) {
|
||||
exactFile = raw;
|
||||
break;
|
||||
}
|
||||
if (suffixFile === null && f.endsWith(`${suffix}.java`)) {
|
||||
suffixFile = raw;
|
||||
}
|
||||
if (directoryChild === null) {
|
||||
// Since #2881: "the file's parent directory ends with `pathLike`". The
|
||||
// `atRoot`/`indexOf` pair this replaces said that AND "…and that is the
|
||||
// first occurrence". `>= 0`, not `> 0` — a repo-root file has `lastSlash`
|
||||
// 0 for `/Top.java` shapes and the bare-wildcard case depends on it.
|
||||
const lastSlash = f.lastIndexOf('/');
|
||||
if (lastSlash >= 0 && `/${f.slice(0, lastSlash)}/`.endsWith(suffixDirPrefix)) {
|
||||
directoryChild = raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (exactFile !== null) return exactFile;
|
||||
if (suffixFile !== null) return suffixFile;
|
||||
if (directoryChild !== null) return directoryChild;
|
||||
|
||||
// Progressive prefix stripping — handles `import com.example.User;`
|
||||
// in a repo laid out `User.java` (no `com/example/` prefix).
|
||||
const segments = pathLike.split('/').filter(Boolean);
|
||||
for (let skip = 1; skip < segments.length; skip++) {
|
||||
const tail = segments.slice(skip).join('/');
|
||||
if (tail === '') continue;
|
||||
const tailFile = `${tail}.java`;
|
||||
const tailSuffix = `/${tailFile}`;
|
||||
const tailSuffixDir = `/${tail}/`;
|
||||
let tailDirectChild: string | null = null;
|
||||
for (const raw of ctx.allFilePaths) {
|
||||
const f = raw.replace(/\\/g, '/');
|
||||
if (!f.endsWith('.java')) continue;
|
||||
if (f === tailFile) return raw;
|
||||
if (f.endsWith(tailSuffix)) return raw;
|
||||
if (tailDirectChild === null) {
|
||||
const lastSlash = f.lastIndexOf('/');
|
||||
if (lastSlash >= 0 && `/${f.slice(0, lastSlash)}/`.endsWith(tailSuffixDir)) {
|
||||
tailDirectChild = raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tailDirectChild !== null) return tailDirectChild;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── harness ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const FROM_FILE = 'src/main/java/com/example/App.java';
|
||||
|
||||
function javaImport(targetRaw: string): ParsedImport {
|
||||
return { kind: 'named', localName: '_', importedName: '_', targetRaw };
|
||||
}
|
||||
|
||||
/** A file layout plus the import spelling resolved against it. */
|
||||
interface Case {
|
||||
readonly label: string;
|
||||
readonly files: readonly string[];
|
||||
readonly target: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `label => result`, so a divergence names the case instead of printing an
|
||||
* index into two long arrays. `null` is spelled, not dropped: "resolved
|
||||
* nothing" is a real answer and must be diffed like any other.
|
||||
*/
|
||||
function runAll(
|
||||
cases: readonly Case[],
|
||||
resolve: (i: ParsedImport, w: WorkspaceIndex) => string | null,
|
||||
): string[] {
|
||||
return cases.map((c) => {
|
||||
const ws = { fromFile: FROM_FILE, allFilePaths: new Set(c.files) };
|
||||
return `${c.label} => ${resolve(javaImport(c.target), ws) ?? 'null'}`;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── hand-built tie-break corpora ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One corpus per tie-break, each as small as the rule it pins. The expectation
|
||||
* strings are the pre-change behaviour, derived by hand from the scan and
|
||||
* confirmed against the verbatim copy by the first `it` below.
|
||||
*/
|
||||
const HAND_CASES: readonly Case[] = [
|
||||
{
|
||||
// Tier 1: the scan `break`s on the exact hit, so it wins over the suffix
|
||||
// match already found at position 0. `index.get` alone returns position 0.
|
||||
label: 'exact-beats-earlier-suffix',
|
||||
files: ['src/main/java/com/example/model/User.java', 'com/example/model/User.java'],
|
||||
target: 'com.example.model.User',
|
||||
},
|
||||
{
|
||||
// Same rule against the directory-child leg.
|
||||
label: 'exact-beats-earlier-directory-child',
|
||||
files: ['com/example/util/Helper/Inner.java', 'com/example/util/Helper.java'],
|
||||
target: 'com.example.util.Helper',
|
||||
},
|
||||
{
|
||||
label: 'suffix-beats-earlier-directory-child',
|
||||
files: ['com/example/util/Helper/Inner.java', 'src/com/example/util/Helper.java'],
|
||||
target: 'com.example.util.Helper',
|
||||
},
|
||||
{
|
||||
label: 'directory-child-is-first-in-set-order',
|
||||
files: ['com/example/service/Beta.java', 'com/example/service/Alpha.java'],
|
||||
target: 'com.example.service',
|
||||
},
|
||||
{
|
||||
label: 'directory-child-order-follows-insertion',
|
||||
files: ['com/example/service/Alpha.java', 'com/example/service/Beta.java'],
|
||||
target: 'com.example.service',
|
||||
},
|
||||
{
|
||||
// Tie-break 3: `.*` is stripped, so this is the package-directory query.
|
||||
label: 'wildcard-resolves-as-package-directory',
|
||||
files: ['com/example/service/Beta.java', 'com/example/service/Alpha.java'],
|
||||
target: 'com.example.service.*',
|
||||
},
|
||||
{
|
||||
// A file named like the package beats that package's directory child.
|
||||
label: 'wildcard-exact-file-beats-directory',
|
||||
files: ['com/example/service/Alpha.java', 'com/example/service.java'],
|
||||
target: 'com.example.service.*',
|
||||
},
|
||||
{
|
||||
// Tie-break 5: the parent directory `com/example/com/example` ends with
|
||||
// `com/example`, so it answers. Until #2881 the leg took the FIRST
|
||||
// `/com/example/` occurrence, which leaves `com/example/Deep.java` after it
|
||||
// — still containing a slash — and the import resolved to null.
|
||||
label: 'self-nested-directory-answers-the-outer-package',
|
||||
files: ['com/example/com/example/Deep.java'],
|
||||
target: 'com.example',
|
||||
},
|
||||
{
|
||||
// Kept beside `self-nested-directory-answers-the-outer-package` even though
|
||||
// both now resolve to the same file: this one addresses the FULL path and
|
||||
// hits the exact-file/suffix tier, that one addresses the outer package and
|
||||
// hits the directory-child tier. Before #2881 the pair separated a hit from
|
||||
// a null; it now separates two tiers, which is why it is still two cases.
|
||||
label: 'self-nested-directory-matches-full-path',
|
||||
files: ['com/example/com/example/Deep.java'],
|
||||
target: 'com.example.com.example',
|
||||
},
|
||||
{
|
||||
// Tie-break 2: the directory child is seen first, the suffix hit second,
|
||||
// and the suffix hit still wins because the scan returns mid-loop.
|
||||
label: 'stripping-suffix-beats-earlier-directory-child',
|
||||
files: ['x/models/Order/Part.java', 'y/models/Order.java'],
|
||||
target: 'com.shop.models.Order',
|
||||
},
|
||||
{
|
||||
label: 'stripping-reaches-root-file',
|
||||
files: ['Order.java'],
|
||||
target: 'com.shop.Order',
|
||||
},
|
||||
{
|
||||
// The mirror of tie-break 1: inside the stripping loop the scan returns at
|
||||
// the first hit of `f === tailFile || f.endsWith('/' + tailFile)`, so the
|
||||
// suffix hit at position 0 beats the whole-path file behind it. Applying
|
||||
// tier 1's exact-wins rule here would answer `Order.java`.
|
||||
label: 'stripping-takes-the-first-hit-not-the-whole-path-one',
|
||||
files: ['a/Order.java', 'Order.java'],
|
||||
target: 'com.shop.Order',
|
||||
},
|
||||
{
|
||||
label: 'stripping-reaches-directory-child',
|
||||
files: ['proj/models/Thing.java'],
|
||||
target: 'com.shop.models',
|
||||
},
|
||||
{
|
||||
// Tie-break 4: matched on the normalized path, returned RAW.
|
||||
label: 'backslash-paths-normalize-and-return-raw',
|
||||
files: ['win\\src\\com\\example\\win\\Windows.java'],
|
||||
target: 'com.example.win.Windows',
|
||||
},
|
||||
{
|
||||
label: 'duplicate-normalized-path-keeps-first-raw-spelling',
|
||||
files: ['a/b/Dup.java', 'a\\b\\Dup.java'],
|
||||
target: 'a.b.Dup',
|
||||
},
|
||||
{
|
||||
label: 'duplicate-normalized-path-keeps-first-raw-spelling-reversed',
|
||||
files: ['a\\b\\Dup.java', 'a/b/Dup.java'],
|
||||
target: 'a.b.Dup',
|
||||
},
|
||||
{
|
||||
// Tie-break 4: the `.java` filter, on both the file and the directory legs.
|
||||
label: 'non-java-sibling-is-skipped',
|
||||
files: ['com/example/model/User.kt', 'com/example/model/User.java'],
|
||||
target: 'com.example.model.User',
|
||||
},
|
||||
{
|
||||
label: 'directory-of-non-java-files-is-not-a-package-directory',
|
||||
files: ['com/example/onlytext/notes.txt', 'com/example/onlytext/README.md'],
|
||||
target: 'com.example.onlytext',
|
||||
},
|
||||
{
|
||||
label: 'several-directories-share-a-last-segment',
|
||||
files: ['svc-b/shared/BShared.java', 'svc-a/shared/AShared.java'],
|
||||
target: 'shared',
|
||||
},
|
||||
{
|
||||
label: 'several-directories-share-a-last-segment-reversed',
|
||||
files: ['svc-a/shared/AShared.java', 'svc-b/shared/BShared.java'],
|
||||
target: 'shared',
|
||||
},
|
||||
{
|
||||
label: 'root-file-exact-match',
|
||||
files: ['src/Loose.java', 'Loose.java'],
|
||||
target: 'Loose',
|
||||
},
|
||||
{
|
||||
label: 'single-segment-target-has-no-stripping-pass',
|
||||
files: ['deep/pkg/Loose.java'],
|
||||
target: 'Loose',
|
||||
},
|
||||
{
|
||||
// `.*` alone strips to the empty package path — the degenerate query the
|
||||
// directory index answers through its empty-last-segment bucket.
|
||||
label: 'bare-wildcard-over-absolute-paths',
|
||||
files: ['/abs/Root.java', '/Top.java'],
|
||||
target: '.*',
|
||||
},
|
||||
{
|
||||
label: 'bare-wildcard-over-relative-paths',
|
||||
files: ['pkg/Root.java', 'Top.java'],
|
||||
target: '.*',
|
||||
},
|
||||
{
|
||||
label: 'empty-segments-are-not-collapsed-in-the-first-pass',
|
||||
files: ['com/example/User.java'],
|
||||
target: 'com..example.User',
|
||||
},
|
||||
{
|
||||
// Every tier is case-sensitive, so the package path never matches — but the
|
||||
// stripping loop reaches the bare basename, which does.
|
||||
label: 'case-mismatch-falls-through-to-basename-stripping',
|
||||
files: ['com/Example/Model/Cased.java'],
|
||||
target: 'com.example.model.Cased',
|
||||
},
|
||||
{
|
||||
label: 'directory-named-like-a-java-file',
|
||||
files: ['com/example/weird.java/Inside.java'],
|
||||
target: 'com.example.weird',
|
||||
},
|
||||
{
|
||||
// Java has no in-repo-namespace gate (C#'s #1881), so a JDK import whose
|
||||
// tail happens to exist locally resolves to it. Pinned, not endorsed.
|
||||
label: 'jdk-import-strips-into-a-local-lookalike',
|
||||
files: ['com/example/model/User.java', 'src/main/java/util/List.java'],
|
||||
target: 'java.util.List',
|
||||
},
|
||||
{
|
||||
label: 'jdk-import-with-no-lookalike-resolves-to-nothing',
|
||||
files: ['com/example/model/User.java'],
|
||||
target: 'java.util.List',
|
||||
},
|
||||
// ── negative control for the widening (#2881), matching Kotlin's ──────────
|
||||
// Everything above pins the widened leg from the POSITIVE side: a file whose
|
||||
// package directory repeats higher in its own path now answers. Nothing here
|
||||
// pinned the other half — that dropping the first-occurrence rule did not
|
||||
// widen "the parent directory ends with `pathLike`" into "`pathLike` appears
|
||||
// somewhere in the path". These three are the same trio
|
||||
// `kotlin-import-target-parity.test.ts` carries: the two positions the old
|
||||
// `atRoot`/`indexOf` pair distinguished, plus the positive control that keeps
|
||||
// the pair from passing by refusing everything.
|
||||
{
|
||||
label: 'not-the-parent-directory-stays-out-leading',
|
||||
files: ['com/example/sub/Repo.java'],
|
||||
target: 'com.example',
|
||||
},
|
||||
{
|
||||
label: 'not-the-parent-directory-stays-out-mid-path',
|
||||
files: ['top/com/example/mid/Repo.java'],
|
||||
target: 'com.example',
|
||||
},
|
||||
{
|
||||
label: 'the-parent-directory-itself-does-answer',
|
||||
files: ['top/com/example/Repo.java'],
|
||||
target: 'com.example',
|
||||
},
|
||||
];
|
||||
|
||||
/** Absolute pre-change behaviour, so the differential cannot pass vacuously. */
|
||||
const HAND_EXPECTED: readonly string[] = [
|
||||
'exact-beats-earlier-suffix => com/example/model/User.java',
|
||||
'exact-beats-earlier-directory-child => com/example/util/Helper.java',
|
||||
'suffix-beats-earlier-directory-child => src/com/example/util/Helper.java',
|
||||
'directory-child-is-first-in-set-order => com/example/service/Beta.java',
|
||||
'directory-child-order-follows-insertion => com/example/service/Alpha.java',
|
||||
'wildcard-resolves-as-package-directory => com/example/service/Beta.java',
|
||||
'wildcard-exact-file-beats-directory => com/example/service.java',
|
||||
'self-nested-directory-answers-the-outer-package => com/example/com/example/Deep.java',
|
||||
'self-nested-directory-matches-full-path => com/example/com/example/Deep.java',
|
||||
'stripping-suffix-beats-earlier-directory-child => y/models/Order.java',
|
||||
'stripping-reaches-root-file => Order.java',
|
||||
'stripping-takes-the-first-hit-not-the-whole-path-one => a/Order.java',
|
||||
'stripping-reaches-directory-child => proj/models/Thing.java',
|
||||
'backslash-paths-normalize-and-return-raw => win\\src\\com\\example\\win\\Windows.java',
|
||||
'duplicate-normalized-path-keeps-first-raw-spelling => a/b/Dup.java',
|
||||
'duplicate-normalized-path-keeps-first-raw-spelling-reversed => a\\b\\Dup.java',
|
||||
'non-java-sibling-is-skipped => com/example/model/User.java',
|
||||
'directory-of-non-java-files-is-not-a-package-directory => null',
|
||||
'several-directories-share-a-last-segment => svc-b/shared/BShared.java',
|
||||
'several-directories-share-a-last-segment-reversed => svc-a/shared/AShared.java',
|
||||
'root-file-exact-match => Loose.java',
|
||||
'single-segment-target-has-no-stripping-pass => deep/pkg/Loose.java',
|
||||
'bare-wildcard-over-absolute-paths => /Top.java',
|
||||
// A relative root file has no leading slash, so the empty package path finds
|
||||
// nothing — unlike the absolute case above.
|
||||
'bare-wildcard-over-relative-paths => null',
|
||||
// `filter(Boolean)` drops the empty segment, so stripping recovers the file
|
||||
// the first pass could not see.
|
||||
'empty-segments-are-not-collapsed-in-the-first-pass => com/example/User.java',
|
||||
'case-mismatch-falls-through-to-basename-stripping => com/Example/Model/Cased.java',
|
||||
'directory-named-like-a-java-file => null',
|
||||
'jdk-import-strips-into-a-local-lookalike => src/main/java/util/List.java',
|
||||
'jdk-import-with-no-lookalike-resolves-to-nothing => null',
|
||||
// `com/example/sub` ends with `example/sub`, not with `com/example`, and the
|
||||
// stripping loop's `example` level does not reach it either — so the file is
|
||||
// in no bucket the query can name.
|
||||
'not-the-parent-directory-stays-out-leading => null',
|
||||
'not-the-parent-directory-stays-out-mid-path => null',
|
||||
'the-parent-directory-itself-does-answer => top/com/example/Repo.java',
|
||||
];
|
||||
|
||||
// ─── generated corpus ────────────────────────────────────────────────────────
|
||||
|
||||
const SOURCE_ROOTS = ['src/main/java', 'src/test/java', '', 'legacy', 'modules/core/src/main/java'];
|
||||
const PACKAGE_DIRS = [
|
||||
'com/example/model',
|
||||
'com/example/service',
|
||||
'com/example/util',
|
||||
'org/acme/api',
|
||||
'io/gn/core',
|
||||
];
|
||||
const TYPE_NAMES = ['User', 'Order', 'Helper', 'Client', 'Registry'];
|
||||
|
||||
/**
|
||||
* A layout where the same package path exists under several source roots AND
|
||||
* root-relative, so most lookups have a whole-path candidate and one or more
|
||||
* earlier suffix candidates — the collision tier 1 turns on. The tail adds the
|
||||
* shapes a regular layout never produces: self-nested packages, directories
|
||||
* sharing a last segment, non-`.java` neighbours, root files, backslash paths,
|
||||
* and the stripping-only targets.
|
||||
*/
|
||||
function generatedFiles(): string[] {
|
||||
const files: string[] = [];
|
||||
for (const pkg of PACKAGE_DIRS) {
|
||||
for (const type of TYPE_NAMES) {
|
||||
for (const root of SOURCE_ROOTS) {
|
||||
files.push(root === '' ? `${pkg}/${type}.java` : `${root}/${pkg}/${type}.java`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A file whose whole path IS a package directory used elsewhere.
|
||||
files.push('com/example/service.java');
|
||||
files.push('src/main/java/com/example/model.java');
|
||||
// Packages nested inside themselves.
|
||||
files.push('com/example/model/com/example/model/Nested.java');
|
||||
files.push('legacy/io/gn/core/io/gn/core/Legacy.java');
|
||||
// Directories sharing a last segment across trees.
|
||||
for (let i = 0; i < 4; i++) {
|
||||
files.push(`svc${i}/shared/Shared${i}.java`);
|
||||
files.push(`svc${i}/shared/internal/Deep${i}.java`);
|
||||
}
|
||||
// Non-`.java` neighbours, including a directory with none of them accepted.
|
||||
files.push('com/example/model/User.kt');
|
||||
files.push('com/example/model/package-info.txt');
|
||||
files.push('com/example/resources/application.yaml');
|
||||
files.push('com/example/weird.java/Inside.java');
|
||||
// Root files and a deep chain.
|
||||
files.push('Loose.java');
|
||||
files.push('Order.java');
|
||||
files.push('a/b/c/d/e/f/Deep6.java');
|
||||
// Backslash spellings, one of them a duplicate of a forward-slash entry.
|
||||
files.push('win\\src\\main\\java\\com\\example\\win\\WinUser.java');
|
||||
files.push('a/b/Dup.java');
|
||||
files.push('a\\b\\Dup.java');
|
||||
// Reachable only after progressive prefix stripping, with a directory child
|
||||
// planted ahead of the suffix hit at the same `skip` level.
|
||||
files.push('x/models/Order/Part.java');
|
||||
files.push('bare/models/Order.java');
|
||||
files.push('bare/models/Invoice.java');
|
||||
return files;
|
||||
}
|
||||
|
||||
function generatedTargets(): string[] {
|
||||
const targets: string[] = [];
|
||||
for (const pkg of PACKAGE_DIRS) {
|
||||
const dotted = pkg.replace(/\//g, '.');
|
||||
targets.push(dotted);
|
||||
targets.push(`${dotted}.*`);
|
||||
for (const type of TYPE_NAMES) targets.push(`${dotted}.${type}`);
|
||||
}
|
||||
targets.push(
|
||||
// Package prefixes: partial paths that are directories but not packages.
|
||||
'com',
|
||||
'com.example',
|
||||
'com.*',
|
||||
'org',
|
||||
'org.acme',
|
||||
'io',
|
||||
'io.gn',
|
||||
'src.main.java.com.example.model.User',
|
||||
'legacy.com.example.util.Helper',
|
||||
'modules.core.src.main.java.io.gn.core.Client',
|
||||
// Self-nesting.
|
||||
'com.example.model.com.example.model',
|
||||
'com.example.model.com.example.model.Nested',
|
||||
'io.gn.core.io.gn.core.Legacy',
|
||||
// Shared last segments.
|
||||
'shared',
|
||||
'shared.*',
|
||||
'svc0.shared',
|
||||
'svc2.shared.internal',
|
||||
'internal',
|
||||
// Stripping-only.
|
||||
'com.shop.models.Order',
|
||||
'com.shop.models',
|
||||
'com.shop.models.*',
|
||||
'whatever.bare.models.Invoice',
|
||||
'nowhere.Loose',
|
||||
'nowhere.deeply.nested.Order',
|
||||
// Non-`.java` and odd shapes.
|
||||
'com.example.resources',
|
||||
'com.example.weird',
|
||||
'com.example.model.User.kt',
|
||||
'a.b.Dup',
|
||||
'a.b.c.d.e.f.Deep6',
|
||||
'com.example.win.WinUser',
|
||||
'.*',
|
||||
'*',
|
||||
'com..example.User',
|
||||
'Loose',
|
||||
'Order',
|
||||
// Unresolvable: JDK and third-party, the majority case in real source.
|
||||
'java.util.List',
|
||||
'java.util.*',
|
||||
'java.io.File',
|
||||
'javax.annotation.Nullable',
|
||||
'org.junit.jupiter.api.Test',
|
||||
'org.springframework.boot.SpringApplication',
|
||||
'com.google.common.collect.ImmutableList',
|
||||
'com.example.missing.Absent',
|
||||
);
|
||||
return targets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Three insertion orders over the same paths. Set-iteration order IS the
|
||||
* tie-break channel for every "first match wins" rule here, so replaying the
|
||||
* same targets under a reversal and a rotation exercises each collision from
|
||||
* both sides — the as-built order alone would leave half of them one-sided.
|
||||
*/
|
||||
function orderedCorpora(): ReadonlyMap<string, readonly string[]> {
|
||||
const base = generatedFiles();
|
||||
const reversed = [...base].reverse();
|
||||
const rotation = 7;
|
||||
const rotated = [...base.slice(rotation), ...base.slice(0, rotation)];
|
||||
return new Map([
|
||||
['as-built', base],
|
||||
['reversed', reversed],
|
||||
['rotated', rotated],
|
||||
]);
|
||||
}
|
||||
|
||||
function generatedCases(): Case[] {
|
||||
const cases: Case[] = [];
|
||||
for (const [order, files] of orderedCorpora()) {
|
||||
for (const target of generatedTargets()) {
|
||||
cases.push({ label: `${order}|${target}`, files, target });
|
||||
}
|
||||
}
|
||||
return cases;
|
||||
}
|
||||
|
||||
const GENERATED_CASES = generatedCases();
|
||||
|
||||
// ─── arms ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Java import target — index hoist parity (#2908)', () => {
|
||||
it('reproduces the pre-change results on the hand-built tie-break corpora', () => {
|
||||
expect(runAll(HAND_CASES, legacyResolveJavaImportTarget)).toEqual(HAND_EXPECTED);
|
||||
expect(runAll(HAND_CASES, resolveJavaImportTarget)).toEqual(HAND_EXPECTED);
|
||||
});
|
||||
|
||||
it('reproduces the pre-change results across three insertion orders', () => {
|
||||
expect(runAll(GENERATED_CASES, resolveJavaImportTarget)).toEqual(
|
||||
runAll(GENERATED_CASES, legacyResolveJavaImportTarget),
|
||||
);
|
||||
});
|
||||
|
||||
it('the generated corpus resolves a broad set of distinct targets', () => {
|
||||
const results = runAll(GENERATED_CASES, resolveJavaImportTarget);
|
||||
const resolvedFiles = new Set(
|
||||
results.map((r) => r.split(' => ')[1]).filter((r) => r !== 'null'),
|
||||
);
|
||||
|
||||
// Non-vacuity: a differential is worthless if both sides answer `null`.
|
||||
// Sized just under the current values so ordinary corpus edits do not trip
|
||||
// it, while a corpus that stops matching does.
|
||||
expect(results.filter((r) => !r.endsWith('=> null')).length).toBeGreaterThan(150);
|
||||
expect(resolvedFiles.size).toBeGreaterThan(40);
|
||||
// ...and it must keep exercising the unresolvable majority, which is the
|
||||
// only case that runs the whole cascade.
|
||||
expect(results.filter((r) => r.endsWith('=> null')).length).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it('matches the pre-change guards for unusable inputs', () => {
|
||||
const files = new Set(['com/example/model/User.java']);
|
||||
const good = { fromFile: FROM_FILE, allFilePaths: files };
|
||||
const inputs: readonly (readonly [string, ParsedImport, WorkspaceIndex])[] = [
|
||||
['undefined context', javaImport('com.example.model.User'), undefined],
|
||||
['missing fromFile', javaImport('com.example.model.User'), { allFilePaths: files }],
|
||||
[
|
||||
'allFilePaths is not a Set',
|
||||
javaImport('com.example.model.User'),
|
||||
{ fromFile: FROM_FILE, allFilePaths: ['com/example/model/User.java'] },
|
||||
],
|
||||
[
|
||||
'dynamic-unresolved import',
|
||||
{ kind: 'dynamic-unresolved', localName: '', targetRaw: 'com.example.model.User' },
|
||||
good,
|
||||
],
|
||||
// `targetRaw: null` is reachable only on `dynamic-unresolved`, which the
|
||||
// kind check above already refuses, so the resolver's null branch has no
|
||||
// typeable input of its own.
|
||||
['empty target', javaImport(''), good],
|
||||
['wildcard kind', { kind: 'wildcard', targetRaw: 'com.example.model.*' }, good],
|
||||
];
|
||||
|
||||
const legacy = inputs.map(([label, imp, ws]) => {
|
||||
return `${label} => ${legacyResolveJavaImportTarget(imp, ws) ?? 'null'}`;
|
||||
});
|
||||
const current = inputs.map(([label, imp, ws]) => {
|
||||
return `${label} => ${resolveJavaImportTarget(imp, ws) ?? 'null'}`;
|
||||
});
|
||||
|
||||
expect(current).toEqual(legacy);
|
||||
// Every one of them refuses, except the last — a well-formed call, so the
|
||||
// arm cannot pass by refusing everything.
|
||||
expect(current).toEqual([
|
||||
'undefined context => null',
|
||||
'missing fromFile => null',
|
||||
'allFilePaths is not a Set => null',
|
||||
'dynamic-unresolved import => null',
|
||||
'empty target => null',
|
||||
'wildcard kind => com/example/model/User.java',
|
||||
]);
|
||||
});
|
||||
|
||||
it('pins WHICH of two competing package directories the first-child leg takes', () => {
|
||||
// `firstFileDirectlyInPkgDir` commits to ONE file with no downstream
|
||||
// filter, and #2881 widened the set it chooses from. Every widened-shape
|
||||
// case in HAND_CASES uses a ONE-FILE corpus, so the widened bucket has a
|
||||
// single member and the choice is not exercised anywhere — yet a different
|
||||
// first child is the largest class of movement the change produced.
|
||||
//
|
||||
// Both files below are legitimate members of the widened `com/example`
|
||||
// set: `com/example/legacy/com/example` ends with `com/example` (it is the
|
||||
// self-nested shape #2881 admitted) and `src/main/java/com/example` ends
|
||||
// with it too. Nothing in the resolver prefers one over the other. The
|
||||
// tie-break is FILE-SET ITERATION ORDER — the insertion order of the Set
|
||||
// the caller passes — so reversing the corpus reverses the answer. That is
|
||||
// the property these assertions pin, and the one nothing else watches:
|
||||
// membership is already pinned above, the WINNER was not.
|
||||
const set = (files: readonly string[]): WorkspaceIndex =>
|
||||
({ fromFile: FROM_FILE, allFilePaths: new Set(files) }) as WorkspaceIndex;
|
||||
const nestedFirst = [
|
||||
'com/example/legacy/com/example/Old.java',
|
||||
'src/main/java/com/example/App.java',
|
||||
];
|
||||
const conventionalFirst = [...nestedFirst].reverse();
|
||||
|
||||
expect(resolveJavaImportTarget(javaImport('com.example'), set(nestedFirst))).toBe(
|
||||
'com/example/legacy/com/example/Old.java',
|
||||
);
|
||||
expect(resolveJavaImportTarget(javaImport('com.example'), set(conventionalFirst))).toBe(
|
||||
'src/main/java/com/example/App.java',
|
||||
);
|
||||
// A wildcard drops its `.*` before any of that, so it lands on the same leg
|
||||
// and moves with it. Spelled out because `com.example.*` is how real Java
|
||||
// source reaches this tier.
|
||||
expect(resolveJavaImportTarget(javaImport('com.example.*'), set(nestedFirst))).toBe(
|
||||
'com/example/legacy/com/example/Old.java',
|
||||
);
|
||||
expect(resolveJavaImportTarget(javaImport('com.example.*'), set(conventionalFirst))).toBe(
|
||||
'src/main/java/com/example/App.java',
|
||||
);
|
||||
// The pre-change copy agrees on both orders, which places the movement in
|
||||
// #2881's removal of the first-occurrence rule rather than in #2908's
|
||||
// index hoist: the hoist did not touch the tie-break, it inherited it.
|
||||
expect(legacyResolveJavaImportTarget(javaImport('com.example'), set(nestedFirst))).toBe(
|
||||
'com/example/legacy/com/example/Old.java',
|
||||
);
|
||||
expect(legacyResolveJavaImportTarget(javaImport('com.example'), set(conventionalFirst))).toBe(
|
||||
'src/main/java/com/example/App.java',
|
||||
);
|
||||
});
|
||||
|
||||
it('builds each index once per file set rather than once per import', () => {
|
||||
const files = new CountingSet(generatedFiles());
|
||||
const ws = { fromFile: FROM_FILE, allFilePaths: files };
|
||||
const targets = generatedTargets();
|
||||
|
||||
const results = targets.map((t) => resolveJavaImportTarget(javaImport(t), ws));
|
||||
|
||||
// Two traversals for the whole run: the shared workspace/suffix index and
|
||||
// the package-directory index, each memoized on this Set's identity. The
|
||||
// pre-change resolver traversed once per import PLUS once per stripped
|
||||
// segment.
|
||||
expect(files.scans).toBe(2);
|
||||
// Paired result assertion — a count of 2 is equally true of a resolver that
|
||||
// has stopped resolving anything at all.
|
||||
expect(results.filter((r) => r !== null).length).toBeGreaterThan(20);
|
||||
expect(resolveJavaImportTarget(javaImport('com.example.model.User'), ws)).toBe(
|
||||
'com/example/model/User.java',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -37,6 +37,17 @@ function inheritanceRefs(src: string): string[] {
|
|||
.sort();
|
||||
}
|
||||
|
||||
function recordAccessorDeclarations(src: string) {
|
||||
return emitJavaScopeCaptures(src, 'C.java')
|
||||
.filter((m) => m['@declaration.method'] !== undefined)
|
||||
.map((m) => ({
|
||||
name: m['@declaration.name']?.text,
|
||||
arity: m['@declaration.parameter-count']?.text,
|
||||
requiredArity: m['@declaration.required-parameter-count']?.text,
|
||||
returnType: m['@declaration.return-type']?.text,
|
||||
}));
|
||||
}
|
||||
|
||||
describe('emitJavaScopeCaptures — constructor reference names (F35 #1928)', () => {
|
||||
it('binds the simple name for an unqualified `new User()`', () => {
|
||||
const refs = ctorRefs(wrapExpr('new User()'));
|
||||
|
|
@ -137,6 +148,84 @@ describe('emitJavaScopeCaptures — record and enum interface heritage (#2900, #
|
|||
});
|
||||
});
|
||||
|
||||
describe('emitJavaScopeCaptures — record component accessors (#2917)', () => {
|
||||
it('synthesizes zero-argument declarations with full generic return types', () => {
|
||||
expect(
|
||||
recordAccessorDeclarations('record User(String name, java.util.List<String> tags) {}'),
|
||||
).toEqual([
|
||||
{ name: 'name', arity: '0', requiredArity: '0', returnType: 'String' },
|
||||
{
|
||||
name: 'tags',
|
||||
arity: '0',
|
||||
requiredArity: '0',
|
||||
returnType: 'java.util.List<String>',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the array return type for a varargs component accessor', () => {
|
||||
expect(recordAccessorDeclarations('record Samples(String... values) {}')).toEqual([
|
||||
{ name: 'values', arity: '0', requiredArity: '0', returnType: 'String[]' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not duplicate an explicit canonical accessor', () => {
|
||||
const declarations = recordAccessorDeclarations(
|
||||
'record User(String name) { public String name(/* canonical */) { return name; } }',
|
||||
);
|
||||
|
||||
expect(declarations.filter((declaration) => declaration.name === 'name')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not count an explicit accessor receiver parameter toward arity', () => {
|
||||
const declarations = recordAccessorDeclarations(
|
||||
'record User(String name) { public String name(User this) { return name; } }',
|
||||
);
|
||||
|
||||
expect(declarations.filter((declaration) => declaration.name === 'name')).toEqual([
|
||||
expect.objectContaining({ arity: '0', requiredArity: '0' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps an overload alongside the implicit zero-argument accessor', () => {
|
||||
const declarations = recordAccessorDeclarations(
|
||||
'record User(String name) { public String name(int repeat) { return name; } }',
|
||||
).filter((declaration) => declaration.name === 'name');
|
||||
|
||||
expect(declarations.map((declaration) => declaration.arity).sort()).toEqual(['0', '1']);
|
||||
});
|
||||
|
||||
// A component is named by a real `identifier` and nothing else. tree-sitter's
|
||||
// zero-width MISSING recovery token satisfies `name: (identifier)`, and the
|
||||
// grammar admits `underscore_pattern` in the same field, so both would mint an
|
||||
// accessor for source that does not compile.
|
||||
it.each([
|
||||
['a dropped component type', 'record M(int x, y) {}', ['x']],
|
||||
['a nameless varargs component', 'record W(int... ) {}', []],
|
||||
['an underscore component', 'record R(int _) {}', []],
|
||||
['an underscore varargs component', 'record S(int... _) {}', []],
|
||||
])('emits no accessor declaration for %s', (_label, source, expected) => {
|
||||
expect(recordAccessorDeclarations(source).map((declaration) => declaration.name)).toEqual(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
|
||||
it('emits no accessor scope for a component with no usable name', () => {
|
||||
const scopes = emitJavaScopeCaptures('record M(int x, y) {}', 'C.java')
|
||||
.filter((m) => m['@scope.function'] !== undefined)
|
||||
.map((m) => m['@scope.function']?.text);
|
||||
|
||||
expect(scopes).toEqual(['int x']);
|
||||
});
|
||||
|
||||
it('is unaffected for a valid record', () => {
|
||||
expect(recordAccessorDeclarations('record P(int x, String... ys) {}')).toEqual([
|
||||
{ name: 'x', arity: '0', requiredArity: '0', returnType: 'int' },
|
||||
{ name: 'ys', arity: '0', requiredArity: '0', returnType: 'String[]' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitJavaScopeCaptures — explicit constructor invocations (F38 #1928)', () => {
|
||||
it('captures `super(...)` as a constructor ref to the superclass simple name', () => {
|
||||
const src = 'class C extends pkg.Base { C() { super(1, 2); } }';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
222
gitnexus/test/unit/tsconfig-index.test.ts
Normal file
222
gitnexus/test/unit/tsconfig-index.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue