mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-09 03:17:54 +00:00
Merge origin/main into fix/nest-decorator-routes
Three conflicts, all in the parse-cache invalidation ledger's blast radius: - languages/typescript.ts: adjacent import lines (this branch's Nest route extractor vs main's Convex endpoint-metadata extractor). Both kept. - storage/parse-cache.ts + test/unit/incremental-parse-cache.test.ts: SCHEMA_BUMP. This branch claimed 71 when main was 70. While the PR was open main cascaded to 74 (#2980 took 71; the Convex change took 74, skipping 73 because open PR #3046 claims it). Keeping 71 would put this branch BELOW main, so the version would move backwards on merge and the reuse gate would never fire for the NestJS payload. Re-applied the ledger's own rule at merge time rather than at authoring time: the next free value above origin/main AND above every in-flight claim. Every open PR touching parse-cache.ts was scanned — #3046 (73) and #1616 (a stale 2) — so 75. The pin test's rejected-versions list gains 74 for the same reason it already lists 59..73: a hardcoded number outside the conflict hunk merges cleanly while being wrong.
This commit is contained in:
commit
44e97798be
86 changed files with 14726 additions and 493 deletions
12
.gitattributes
vendored
12
.gitattributes
vendored
|
|
@ -15,3 +15,15 @@
|
|||
*.so binary
|
||||
*.dll binary
|
||||
*.dylib binary
|
||||
|
||||
# TypeScript sources are always text for diff purposes. Git's binary
|
||||
# heuristic fires when EITHER blob in a pair carries a NUL, so a source
|
||||
# file that carried one on a base commit still renders as "Binary files
|
||||
# differ" — with no hunks and no inline comments — long after the byte
|
||||
# itself is gone from the working tree. A head-side guard cannot see
|
||||
# that, by construction. This does not mark the files binary or change
|
||||
# how they are stored; it only stops the heuristic from hiding a diff.
|
||||
*.ts diff
|
||||
*.tsx diff
|
||||
*.mts diff
|
||||
*.cts diff
|
||||
|
|
|
|||
|
|
@ -567,7 +567,7 @@ jobs:
|
|||
NODE
|
||||
|
||||
- name: Attest build provenance (SLSA)
|
||||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
||||
with:
|
||||
subject-path: 'gitnexus/vendor/tree-sitter-*/prebuilds/**/*.node'
|
||||
|
||||
|
|
|
|||
4
.github/workflows/docker.yml
vendored
4
.github/workflows/docker.yml
vendored
|
|
@ -256,7 +256,7 @@ jobs:
|
|||
# pulling from either GHCR or Docker Hub see the same provenance.
|
||||
- name: Generate build provenance attestation (GHCR)
|
||||
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
|
||||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
||||
with:
|
||||
subject-name: ghcr.io/${{ github.repository_owner }}/${{ matrix.image.slug }}
|
||||
subject-digest: ${{ steps.build.outputs.digest }}
|
||||
|
|
@ -264,7 +264,7 @@ jobs:
|
|||
|
||||
- name: Generate build provenance attestation (Docker Hub)
|
||||
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
|
||||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
||||
with:
|
||||
subject-name: docker.io/akonlabs/${{ matrix.image.slug }}
|
||||
subject-digest: ${{ steps.build.outputs.digest }}
|
||||
|
|
|
|||
|
|
@ -403,6 +403,7 @@ Each language implements `LanguageProvider` (`language-provider.ts`). Key fields
|
|||
| `typeConfig` | Type annotation extraction rules |
|
||||
| `mroStrategy` | `first-wins` / `c3` / `none` |
|
||||
| `descriptionExtractor` | Optional hook returning a symbol's doc-comment text as its `description`; feeds the embedding metadata header so doc-only terms are semantically searchable (issue #2270). Most languages register `createLeadingDocDescriptionExtractor` (shared, language-neutral; per-language comment/wrapper config passed at the call site) |
|
||||
| `definitionPropertiesExtractor` | Optional language-owned hook for structured, clone-safe definition metadata. Shared ingestion persists these properties opaquely; the owning provider supplies the extraction semantics. |
|
||||
|
||||
16 providers in `languages/index.ts` via `satisfies Record<SupportedLanguages, LanguageProvider>` — missing a language is a compile error.
|
||||
|
||||
|
|
|
|||
26
gitnexus-web/package-lock.json
generated
26
gitnexus-web/package-lock.json
generated
|
|
@ -8,8 +8,8 @@
|
|||
"name": "gitnexus-web",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@langchain/anthropic": "^1.5.1",
|
||||
"@langchain/core": "^1.2.3",
|
||||
"@langchain/anthropic": "^1.5.8",
|
||||
"@langchain/core": "^1.2.8",
|
||||
"@langchain/google-genai": "^2.2.0",
|
||||
"@langchain/langgraph": "^1.4.9",
|
||||
"@langchain/ollama": "^1.3.0",
|
||||
|
|
@ -98,9 +98,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.103.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.103.0.tgz",
|
||||
"integrity": "sha512-1uG7RNgoHTUxzOXqSCODKt0UTVlxWiHk/2Tt2/uQJiPW7XzBeKVuJyd3Aw6T3LPyvZV/jDTnPLX7SaM70WLLjA==",
|
||||
"version": "0.115.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.115.0.tgz",
|
||||
"integrity": "sha512-BJrFIVyjNuU8lfDyIJTvlRYzgQg+zEl78BxE7fq8esULsGz9IRQvGtW5spq3tydmtjQb/GFdooKGdGsetpx+lQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
|
|
@ -1123,25 +1123,25 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@langchain/anthropic": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.5.1.tgz",
|
||||
"integrity": "sha512-j92zCCd5BFH3rHMRzc2wBmSKDoVpinof1oh8aFiAz9TWbSOc4tGU4n6bqwy/wP0GH1uO96zZHLGCHBMPgrxTNw==",
|
||||
"version": "1.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.5.8.tgz",
|
||||
"integrity": "sha512-KZWgIf+04M9XZHhgH1rVJkqw/C26DM4a4jKk4Qc4HaSbRawN2Dw5nDffna+IoaU/50ohTdyB3HOz9g8XFQYF2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.103.0",
|
||||
"@anthropic-ai/sdk": "^0.115.0",
|
||||
"zod": "^3.25.76 || ^4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": "^1.2.1"
|
||||
"@langchain/core": "^1.2.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/core": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.3.tgz",
|
||||
"integrity": "sha512-F+L5SsciykwDl7eDxacnhDTcWe1IF6jetzfkvI5PPfq6ogWHO7xcjU90SGh/3lqbbS0tgun+qF01KIqxawrCsA==",
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.9.tgz",
|
||||
"integrity": "sha512-conzSEj9Zu1AyXJLXsSbgrtxtxinmI1yGqQ5CIJZSoV5rvv+yvQE/vgBnoySpBQ/bl3YPgj2FL/gbDjWykLSfg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cfworker/json-schema": "^4.0.2",
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@
|
|||
"test:e2e:report": "playwright show-report"
|
||||
},
|
||||
"dependencies": {
|
||||
"@langchain/anthropic": "^1.5.1",
|
||||
"@langchain/core": "^1.2.3",
|
||||
"@langchain/anthropic": "^1.5.8",
|
||||
"@langchain/core": "^1.2.8",
|
||||
"@langchain/google-genai": "^2.2.0",
|
||||
"@langchain/langgraph": "^1.4.9",
|
||||
"@langchain/ollama": "^1.3.0",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
{
|
||||
"fingerprint": "4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5",
|
||||
"fingerprint": "c4d799c5336d616955b3530ba051b7dca300d1a0e412a66741cf2f27e04c533e",
|
||||
"scaling_budget": 1.8,
|
||||
"max_ms_large": 1000,
|
||||
"_rebaselined_2856_property_is_detail": "Third and last of the bench guards this branch left red. The Property node table gained an `isDetail` BOOLEAN column (see PROPERTY_SCHEMA in src/core/lbug/schema.ts), so `streamAllCSVsToDisk` writes one more header field and one more cell per Property row — csv-generator.ts `propertyHeader` and the `node.label === 'Property'` tail. Verified to be header-only drift rather than a change in what is emitted: dumping every CSV this bench produces on `origin/main` and on this branch and diffing per-file (filename, byte length, sha256) shows the file SET is identical at 35 CSVs on both sides, 34 of the 35 are byte-identical, and the sole difference is `property.csv` growing 68 -> 77 bytes, `id,name,filePath,startLine,endLine,content,description,declaredType` -> `...,declaredType,isDetail`. The synthetic graph has no Property nodes, so no ROW moved at all. That is the check that matters here: a row routed to the wrong pair file, or a within-file reordering, is what this fingerprint exists to catch, and neither happened. Prior 69e9182ae205183ade24c3d8ad5d7292aea677144b1cbe443dd631bc25b0cafe -> 4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5. Both timing gates passed unchanged while this was red (scaling_ratio 0.783 vs budget 1.8, elapsed_ms_large 229ms vs the 1000ms backstop), so no throughput claim is being rebaselined away.",
|
||||
"_rebaselined_3040_convex_endpoint_factory": "Const and Function gained a trailing convexEndpointFactory column. A deterministic 2,400-entity emit produced the same 35 CSV files and fingerprint c4d799c5336d616955b3530ba051b7dca300d1a0e412a66741cf2f27e04c533e. Removing the new Const and Function header fields plus the new trailing empty Function cell from each of 4,800 Function rows restored the exact prior fingerprint 4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5. No file or row moved or reordered. The measured scaling ratio remained 0.826 against the 1.8 budget and elapsed_ms_large was 307.75ms against the 1000ms backstop.",
|
||||
"_note": "fingerprint = sha256 over per-file digests (filename + sha256(file bytes)), entry list sorted — binds each emitted line to its file so a row routed to the WRONG pair file changes the hash, AND catches within-file row reordering (file bytes hashed as-written). Byte-identity gate for #2203 U2/U3. NOTE: a future change that legitimately reorders emit (without changing the node/edge SET) will trip --check; regenerate then, and record WHY in a `_rebaselined_<reason>` key alongside — bench/scope-capture/baselines.json sets that convention and it is what makes a regenerated hash reviewable. scaling_budget bounds (t_large/t_small)/(LARGE/SMALL): observed ~0.95-1.05 (linear); 1.8 tolerates disk-I/O timing noise on CI while still catching an O(n^2) re-regression (~4x). max_ms_large=1000ms is a coarse absolute backstop (observed ~200ms) that catches a gross uniform slowdown the ratio gate misses; generous so CI host noise won't flake it. Regenerate via `node --import tsx bench/emit-persistence/measure.mjs`."
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -399,12 +399,9 @@
|
|||
* per parsed file, O(files) with no depth term, and the count gate in
|
||||
* import-target-index-reuse.contract.test.ts is what holds it to one build
|
||||
* per pass;
|
||||
* - PHP's leg is measured with NO composer.json — `resolutionConfig` is
|
||||
* undefined here, as it always has been — so `namespaceDirectories` only
|
||||
* ever returns the directory of an already-resolved file and the PSR-4
|
||||
* mapping branch stays unreached, exactly as `csharp` cannot reach the
|
||||
* csproj leg. Closing that is a second PHP arm on the `csharp_csproj`
|
||||
* precedent, not a parameter;
|
||||
* - PHP runs with the Composer PSR-4 configuration every production project
|
||||
* supplies. Configured hits and unmatched dependency misses share one
|
||||
* workload, so the Composer gate cannot become an unmeasured fast path;
|
||||
* - the `const` tail of PHP's leg (`candidateFiles.length === 1`) is a
|
||||
* different ANSWER, not a different cost: `function` runs the identical
|
||||
* candidate gather and `localDefs` filter and diverges only in the last two
|
||||
|
|
@ -550,13 +547,10 @@ const HEAP_LARGE = 32000;
|
|||
const HEAP_PAD = 8;
|
||||
/** The languages whose retained per-pass index carries a BUDGET — a ceiling, a
|
||||
* floor derived from `heap_reading_bytes`, and the linear-growth ratio arm.
|
||||
* All eight are measured the same way as the other nine (`retainedPassBytes`,
|
||||
* one real import through the real resolver); what this list decides is which
|
||||
* GATE a reading gets, not whether it is taken. The first five reach the shared
|
||||
* `WorkspaceFileIndex` and retained NOTHING at BASE; `csharp_csproj` is the
|
||||
* same corpus through the same index under the csproj context, and it is here
|
||||
* rather than excluded as a duplicate because after #2903 its READ PATTERN,
|
||||
* not its corpus, decides the number.
|
||||
* All arms are measured through `retainedPassBytes`, one real import through
|
||||
* the real resolver; this list decides which GATE a reading gets, not whether
|
||||
* it is taken. The configured C# arm stays here because its read pattern
|
||||
* reaches retained structures that the unconfigured arm cannot observe.
|
||||
*
|
||||
* The remaining three are `HEAP_BOUNDED`, DERIVED from this list rather than
|
||||
* written beside it, and they carry an upper bound and NO floor. That asymmetry
|
||||
|
|
@ -565,7 +559,7 @@ const HEAP_PAD = 8;
|
|||
* would gate the noise. rust reads 16 B at both scales; swift's ratio is 0.888
|
||||
* and cobol's 1.082, both outside the linearity every budgeted arm shows, so a
|
||||
* floor and a ratio arm would be measuring the measurement. See the MEMORY
|
||||
* section of the header for what re-measuring all seventeen found. */
|
||||
* section of the header for what re-measuring the full inventory found. */
|
||||
const HEAP_BUDGETED = [
|
||||
'csharp',
|
||||
'csharp_csproj',
|
||||
|
|
@ -601,7 +595,7 @@ const HEAP_BUDGETED = [
|
|||
|
||||
/**
|
||||
* The arms handed the fifth `context` argument — `{ parsedFiles, parsedImport }`
|
||||
* — because their registered hook DECLARES it. Four of seventeen, and the
|
||||
* — because their registered hook DECLARES it. Four of seventeen arms, and the
|
||||
* inventory arm at the foot of this file reconciles that claim against
|
||||
* `SCOPE_RESOLVERS` in both directions rather than trusting this line.
|
||||
*
|
||||
|
|
@ -714,6 +708,15 @@ const joinBase = (baseUrl, rest) => (baseUrl === '' ? rest : `${baseUrl}/${rest}
|
|||
*/
|
||||
const tsBaseUrlFor = (pad) =>
|
||||
pad === 0 ? '' : Array.from({ length: pad }, (_, n) => `d${n}`).join('/');
|
||||
const phpComposerConfigFor = (pad) => ({
|
||||
psr4: new Map([['App', joinBase(tsBaseUrlFor(pad), 'src/App')]]),
|
||||
authoritativePsr4: new Set(['App']),
|
||||
});
|
||||
const renderPhpComposerConfig = (config) =>
|
||||
[...config.psr4]
|
||||
.map(([namespace, directory]) => `${namespace || '<root>'}=${directory || '<root>'}`)
|
||||
.sort()
|
||||
.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 = {
|
||||
|
|
@ -919,7 +922,7 @@ function collideDir(lang, d, i) {
|
|||
`mod${d}/src/main/kotlin/com/example/models/inner/com/example/models`
|
||||
: `mod${d}/src/main/kotlin/com/example/models`;
|
||||
}
|
||||
if (lang === 'php') return `svc${d}/src/Models`;
|
||||
if (lang === 'php') return `src/App/Svc${d}/Models`;
|
||||
if (lang === 'java') {
|
||||
return d % 7 === 0
|
||||
? `svc${d}/src/main/java/com/example/model/inner/model`
|
||||
|
|
@ -1035,6 +1038,12 @@ function buildFiles(lang, fileCount, pad, shape) {
|
|||
: ext;
|
||||
files.push(`${prefix}${dir}/${stem}${suffix}`);
|
||||
}
|
||||
// One real suffix decoy makes the PHP external gate observable: with the
|
||||
// gate, Vendor0 stays unresolved; without it, suffix fallback resolves this
|
||||
// path and the exact fingerprint/external-probe result changes.
|
||||
if (layout === 'php' && files.length > 0) {
|
||||
files[files.length - 1] = `${prefix}legacy/Vendor0/Ghost/Missing.php`;
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
|
|
@ -1145,9 +1154,8 @@ function kotlinBenchmarkPackage(filePath) {
|
|||
* The owner segment is the file's own directory name (`Ns7`, `Models`, `pkg7`),
|
||||
* which is stable across the `small`, `deep` and `collide` arms — so the `deep`
|
||||
* arm differs from `small` in path DEPTH alone, exactly as it does for the path
|
||||
* set. That matters here: `directoryAliases` emits one entry per path segment,
|
||||
* so `filesByDirectory` is O(files × depth) and the depth arm is the only one
|
||||
* that can see it.
|
||||
* set. `filesByDirectory` is exact and linear in the file count; the shared
|
||||
* suffix index remains the path-depth-sensitive structure this arm measures.
|
||||
*/
|
||||
function buildParsedFiles(lang, files) {
|
||||
const parsedFiles = [];
|
||||
|
|
@ -1247,21 +1255,18 @@ function uniqueTarget(lang, { local, r, d, j, dirs }) {
|
|||
: `com.ghost${(r >>> 4) % 97}.deep.Missing`;
|
||||
}
|
||||
if (lang === 'php') {
|
||||
// Backslash-separated, the way a `use` statement is actually written; the
|
||||
// resolver normalizes them. No composer.json is threaded (the adapter's
|
||||
// `resolutionConfig` is left undefined), so every one of these lands on
|
||||
// `suffixResolve` — the leg that ran one `findIndex` over every file per
|
||||
// path part per extension, ~50 of them, and measured 96.40 ms per import at
|
||||
// 20k files before #2901.
|
||||
return local
|
||||
? `App\\Ns${d}\\File${j}`
|
||||
: (r >>> 3) % 2 === 0
|
||||
? [
|
||||
'Psr\\Log\\LoggerInterface',
|
||||
'Symfony\\Component\\Console\\Command',
|
||||
'Doctrine\\ORM\\EntityManager',
|
||||
][(r >>> 4) % 3]
|
||||
: `Vendor${(r >>> 4) % 97}\\Ghost\\Missing`;
|
||||
if (local) {
|
||||
const namespace = d % 7 === 0 ? `Ns${d}\\Sub\\Ns${d}` : `Ns${d}`;
|
||||
const leadingSeparator = (r >>> 3) % 4 === 0 ? '\\' : '';
|
||||
return `${leadingSeparator}App\\${namespace}\\File${j}`;
|
||||
}
|
||||
return (r >>> 3) % 2 === 0
|
||||
? [
|
||||
'Psr\\Log\\LoggerInterface',
|
||||
'Symfony\\Component\\Console\\Command',
|
||||
'Doctrine\\ORM\\EntityManager',
|
||||
][(r >>> 4) % 3]
|
||||
: `Vendor${(r >>> 4) % 97}\\Ghost\\Missing`;
|
||||
}
|
||||
if (lang === 'java') {
|
||||
// Java has NO in-repo-namespace gate (#2910 is filed for it), so a JDK
|
||||
|
|
@ -1451,21 +1456,17 @@ function collideTarget(lang, { local, r, d, j, dirs }) {
|
|||
: `com.ghost${(r >>> 4) % 97}.deep.Missing`;
|
||||
}
|
||||
if (lang === 'php') {
|
||||
// `Models\Mod{n}` is carried by every service, so the segment-suffix key it
|
||||
// resolves through holds one entry no matter how many files exist: PHP
|
||||
// answers from keyed maps and is collision-IMMUNE, which is what this arm
|
||||
// asserts. The local spelling still always resolves, as it does on the
|
||||
// unique layout — PHP's cascade strips leading segments, so even the
|
||||
// nested-same-name slice is reachable by a shorter suffix.
|
||||
return local
|
||||
? `App\\Models\\Mod${Math.floor(j / dirs)}`
|
||||
: (r >>> 3) % 2 === 0
|
||||
? [
|
||||
'Psr\\Log\\LoggerInterface',
|
||||
'Symfony\\Component\\Console\\Command',
|
||||
'Doctrine\\ORM\\EntityManager',
|
||||
][(r >>> 4) % 3]
|
||||
: `Vendor${(r >>> 4) % 97}\\Ghost\\Missing`;
|
||||
if (local) {
|
||||
const leadingSeparator = (r >>> 3) % 4 === 0 ? '\\' : '';
|
||||
return `${leadingSeparator}App\\Svc${j % dirs}\\Models\\Mod${Math.floor(j / dirs)}`;
|
||||
}
|
||||
return (r >>> 3) % 2 === 0
|
||||
? [
|
||||
'Psr\\Log\\LoggerInterface',
|
||||
'Symfony\\Component\\Console\\Command',
|
||||
'Doctrine\\ORM\\EntityManager',
|
||||
][(r >>> 4) % 3]
|
||||
: `Vendor${(r >>> 4) % 97}\\Ghost\\Missing`;
|
||||
}
|
||||
if (lang === 'java') {
|
||||
// Every file declares the same package despite living under different
|
||||
|
|
@ -1607,6 +1608,9 @@ function buildRepo(lang, fileCount, pad = 0, shape = 'unique') {
|
|||
imports.push([from, mintTarget(lang, { local, r, d, j, dirs })]);
|
||||
}
|
||||
}
|
||||
if (lang === 'php' && imports.length > 0) {
|
||||
imports[0] = [files[0], 'Vendor0\\Ghost\\Missing'];
|
||||
}
|
||||
return { files, imports };
|
||||
}
|
||||
|
||||
|
|
@ -1667,7 +1671,7 @@ function newPass(lang, files, pad = 0) {
|
|||
restoreBenchmarkSideChannels(lang, parsedFiles);
|
||||
return {
|
||||
allFilePaths: new Set(parsedFiles.map((f) => f.filePath)),
|
||||
config: undefined,
|
||||
config: lang === 'php' ? phpComposerConfigFor(pad) : undefined,
|
||||
parsedFiles,
|
||||
};
|
||||
}
|
||||
|
|
@ -2039,7 +2043,9 @@ const HEAP_PROBE_TARGET = {
|
|||
// (`getFilesInDir`) before answering null — the three-map read pattern.
|
||||
csharp_csproj: 'App.Missing0',
|
||||
ruby: 'gem0/missing/thing',
|
||||
php: 'Vendor0\\Ghost\\Missing',
|
||||
// A mapped-but-missing class forces the Composer mapping and suffix-index
|
||||
// read paths. The separate external probe below keeps the fast gate visible.
|
||||
php: 'App\\HeapGhost0\\AbsentHeapProbe',
|
||||
java: 'com.google.common.vendor0.Missing',
|
||||
javascript: 'vendor0/lib/missing',
|
||||
python: 'vendor0.deep.missing',
|
||||
|
|
@ -2107,11 +2113,24 @@ function measureHeap(lang) {
|
|||
GC();
|
||||
GC();
|
||||
const probe = HEAP_PROBE_TARGET[lang];
|
||||
const read = (files) => retainedPassBytes(lang, files, probe);
|
||||
const read = (files) => retainedPassBytes(lang, files, probe, lang === 'php' ? HEAP_PAD : 0);
|
||||
const small = flatten(buildFiles(lang, HEAP_SMALL, HEAP_PAD, 'unique'));
|
||||
const bytesSmall = read(small);
|
||||
const large = flatten(buildFiles(lang, HEAP_LARGE, HEAP_PAD, 'unique'));
|
||||
const bytesLarge = read(large);
|
||||
const phpGateShape =
|
||||
lang === 'php'
|
||||
? (() => {
|
||||
const externalProbe = 'Vendor0\\Ghost\\Missing';
|
||||
const config = phpComposerConfigFor(HEAP_PAD);
|
||||
const pass = newPass(lang, large, HEAP_PAD);
|
||||
return {
|
||||
resolution_config: renderPhpComposerConfig(config),
|
||||
external_probe: externalProbe,
|
||||
external_result: renderResolved(resolveOne(lang, large[0], externalProbe, pass)),
|
||||
};
|
||||
})()
|
||||
: {};
|
||||
return {
|
||||
files_small: HEAP_SMALL,
|
||||
files_large: HEAP_LARGE,
|
||||
|
|
@ -2121,6 +2140,7 @@ function measureHeap(lang) {
|
|||
bytes_large: bytesLarge,
|
||||
mib_large: Number((bytesLarge / 1024 / 1024).toFixed(2)),
|
||||
ratio: Number((bytesLarge / bytesSmall / (HEAP_LARGE / HEAP_SMALL)).toFixed(3)),
|
||||
...phpGateShape,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -2213,10 +2233,11 @@ const CONTEXT_PROBE = {
|
|||
function measureContext(lang) {
|
||||
const { from, target, parsedFiles } = CONTEXT_PROBE[lang];
|
||||
const allFilePaths = new Set(parsedFiles.map((f) => f.filePath));
|
||||
const config = lang === 'php' ? phpComposerConfigFor(0) : undefined;
|
||||
const answer = (files) => {
|
||||
restoreBenchmarkSideChannels(lang, files ?? []);
|
||||
return renderResolved(
|
||||
resolveOne(lang, from, target, { allFilePaths, config: undefined, parsedFiles: files }),
|
||||
resolveOne(lang, from, target, { allFilePaths, config, parsedFiles: files }),
|
||||
);
|
||||
};
|
||||
return {
|
||||
|
|
@ -2249,11 +2270,11 @@ if (CHECK && GC === null) {
|
|||
/**
|
||||
* Every arm, and the registered language each one exercises.
|
||||
*
|
||||
* This used to be a hand-written list of seventeen strings under a comment
|
||||
* This used to be a hand-written list of language strings under a comment
|
||||
* claiming it was "every language in `SCOPE_RESOLVERS`" — a claim nothing in
|
||||
* the file could check, because the file never imported the registry. Adding a
|
||||
* resolver to `pipeline/registry.ts` is two lines, neither of which is this
|
||||
* one, so a seventeenth registered language would have shipped ungated and
|
||||
* one, so a newly registered language would have shipped ungated and
|
||||
* printed PASS. That is not a hypothetical failure mode: JavaScript reached
|
||||
* `suffixResolve` with no index at all and measured 25 972 µs per import at
|
||||
* 8000 files (PR #2911) for exactly as long as nothing gated it.
|
||||
|
|
@ -2265,10 +2286,9 @@ if (CHECK && GC === null) {
|
|||
* uses ten files away, and the same "one row per language" table
|
||||
* `bench/cfg/measure.mjs` keeps.
|
||||
*
|
||||
* The mapping is many-to-one on purpose: `csharp` and `csharp_csproj` are two
|
||||
* arms over one registered resolver, differing only in whether `csharpConfigs`
|
||||
* is supplied, because the no-csproj arm returns before it can reach the leg
|
||||
* #2902 indexed.
|
||||
* The mapping is many-to-one only for C#: the configured arm reaches the
|
||||
* csproj branch that the default arm cannot observe. PHP's sole arm carries
|
||||
* its production Composer configuration directly.
|
||||
*/
|
||||
const LANG_REGISTRY = {
|
||||
go: SupportedLanguages.Go,
|
||||
|
|
@ -2457,7 +2477,7 @@ const SCALE_SHAPE = {
|
|||
'one of them alone moves nothing in the others.',
|
||||
};
|
||||
/** The same, for the heap arm — the four inputs that decide what it measures.
|
||||
* Asserted for all seventeen, budgeted tier and bounded tier alike, and it is
|
||||
* Asserted for all seventeen arms, budgeted tier and bounded tier alike, and it is
|
||||
* the bounded tier that needs it most: a bound is a single comparison, so a
|
||||
* probe swapped for one that reaches less is a bound over a smaller workload
|
||||
* and there is no floor beside it to notice.
|
||||
|
|
@ -2474,6 +2494,13 @@ const HEAP_SHAPE = {
|
|||
'ceiling, floor, bound and ratio passing over an arm that changed workload. Deterministic: ' +
|
||||
'a re-run will not change it.',
|
||||
};
|
||||
const PHP_HEAP_SHAPE = {
|
||||
fields: [...HEAP_SHAPE.fields, 'resolution_config', 'external_probe', 'external_result'],
|
||||
why:
|
||||
HEAP_SHAPE.why +
|
||||
' PHP also pins the Composer mapping and a suffix-matchable external decoy so the mapped ' +
|
||||
'index path and the external fast gate remain separate observable arms.',
|
||||
};
|
||||
/** The same, for the `context` arm. All three fields are exact strings, not
|
||||
* bounds: this arm has no measurement noise at all — it resolves one import
|
||||
* two ways over a three-file corpus — so anything less than equality would be
|
||||
|
|
@ -2496,7 +2523,7 @@ const CONTEXT_SHAPE = {
|
|||
* a fifth parameter. */
|
||||
const armShapes = (lang) => [
|
||||
...SCALES.map((scale) => [scale, SCALE_SHAPE]),
|
||||
['heap', HEAP_SHAPE],
|
||||
['heap', lang === 'php' ? PHP_HEAP_SHAPE : HEAP_SHAPE],
|
||||
...(CONTEXT_LANGS.includes(lang) ? [['context', CONTEXT_SHAPE]] : []),
|
||||
];
|
||||
|
||||
|
|
|
|||
48
gitnexus/package-lock.json
generated
48
gitnexus/package-lock.json
generated
|
|
@ -1618,9 +1618,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1638,9 +1635,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1658,9 +1652,6 @@
|
|||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1678,9 +1669,6 @@
|
|||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1698,9 +1686,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1718,9 +1703,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3595,9 +3577,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.3.tgz",
|
||||
"integrity": "sha512-n+mUVyUX5bVv7G/G2zyIHOhdxfuU1dY2NOFzTQUWiMUbFss8b57NFlgCCaggU78wSw5KVS9cllzeLyzyR+n5nw==",
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.3.0.tgz",
|
||||
"integrity": "sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -3810,9 +3792,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3834,9 +3813,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3858,9 +3834,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3882,9 +3855,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -4210,9 +4180,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "8.9.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.1.tgz",
|
||||
"integrity": "sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg==",
|
||||
"version": "8.9.2",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz",
|
||||
"integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
|
|
@ -5551,9 +5521,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz",
|
||||
"integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==",
|
||||
"version": "14.0.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz",
|
||||
"integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
|
|
|
|||
|
|
@ -65,6 +65,16 @@ export const WINDOWS_WEIGHTS_SEC: Readonly<Record<string, number>> = {
|
|||
'test/integration/antigravity-hook-e2e.test.ts': 7,
|
||||
'test/unit/index-lock.test.ts': 5,
|
||||
'test/unit/setup.test.ts': 5,
|
||||
// ESTIMATE, not a measurement. This file asserts almost nothing; it READS —
|
||||
// one 4893-file pass over every tracked text file, plus an 830-file pass over
|
||||
// `src/`. Measured at 2.3 s and 0.3 s per pass on a virtualised and a local
|
||||
// Linux filesystem respectively, so the cost is entirely per-file open
|
||||
// latency, which is the term Windows inflates most (NTFS plus Defender on
|
||||
// every read). Scaled from the slower Linux figure to keep the split
|
||||
// conservative rather than let the 8 s PER_FILE_OVERHEAD floor under-charge
|
||||
// a file that touches more paths than anything else here. Replace with a real
|
||||
// figure after the first green Windows matrix run.
|
||||
'test/unit/source-control-bytes.test.ts': 15,
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -208,6 +208,18 @@ const SPAWN_CLI = [
|
|||
// exposed a file-backend double-admit race here (#2658 review); the reclaim is
|
||||
// now judgment-verified so a live holder is never displaced.
|
||||
'test/integration/analyze-index-lock-concurrency.test.ts',
|
||||
// The per-group sync lock (R9), same class of guarantee one level up: real
|
||||
// child processes contend for one group's lock while this process runs a real
|
||||
// `syncGroup`, and the CLI case spawns the real command. Everything that
|
||||
// varies here is platform-owned — which backend `selectBackend()` picks
|
||||
// (Windows named pipe / Linux abstract socket / macOS file lock), kernel
|
||||
// auto-release on SIGKILL vs. the file backend's pid-liveness reclaim, and
|
||||
// `mkdir` over an occupied path. The fail-closed cases pin
|
||||
// GITNEXUS_INDEX_LOCK_BACKEND=file so the filesystem branch is exercised on
|
||||
// every OS rather than only where it is the default; no case is skipped on
|
||||
// any platform, because a skipped case turns "a sync that cannot be protected
|
||||
// does not run" into a claim that holds on Ubuntu only.
|
||||
'test/integration/group/group-sync-lock-concurrency.test.ts',
|
||||
// The three `dist/` module-load closure guards, all built on the shared
|
||||
// child-process probe in `test/helpers/module-load-probe.ts`. That probe IS
|
||||
// the platform-varying part: it spawns `process.execPath` in array form,
|
||||
|
|
@ -261,6 +273,28 @@ const FILESYSTEM = [
|
|||
'test/integration/filesystem-walker.test.ts',
|
||||
'test/integration/markdown-processor-crlf.test.ts',
|
||||
'test/integration/ignore-and-skip-e2e.test.ts',
|
||||
// Pins that the bridge pairing verdict is measured before the database is
|
||||
// opened. The property it protects is about mtime behavior across OS and
|
||||
// filesystem, and the alternative — really opening the bridge — cannot run on
|
||||
// Windows at all (in-process write→read reopen of the same bridge.lbug is a
|
||||
// documented limitation). Running it on every platform is the whole point:
|
||||
// Windows is where an unverified assumption about mtime would hurt most.
|
||||
'test/unit/group/bridge-pairing-precedes-open.test.ts',
|
||||
// The raw-control-byte guard reads every tracked text file `git ls-files`
|
||||
// reports — 4893 of them — and decides membership from the git path, which is
|
||||
// always `/`-separated no matter what the host separator is. Both halves of
|
||||
// that are platform-varying: the collector basename-matches with
|
||||
// `path.posix.basename` against `git ls-files -z` output while the reads go
|
||||
// through `path.join`, so on Windows the same string is consumed under two
|
||||
// separator conventions in one pass, and only a real windows-latest run
|
||||
// proves they agree. It is also the file-count-heaviest read loop in the
|
||||
// suite, so it is where a per-file filesystem cost (NTFS + Defender, or
|
||||
// macOS's slower stat path) would show up first. No case is skipped on any
|
||||
// platform: a guard that only holds on Ubuntu is not a guard on the file
|
||||
// whose NUL it exists to catch. Budget: the heaviest single case is one
|
||||
// 4893-file pass — 2.3 s on a slow virtualised filesystem, 0.34 s on a local
|
||||
// disk — against a 30 s testTimeout.
|
||||
'test/unit/source-control-bytes.test.ts',
|
||||
];
|
||||
|
||||
const ALL_CROSS_PLATFORM = [
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// gitnexus/src/cli/group.ts
|
||||
import { createRequire } from 'node:module';
|
||||
import type { Command } from 'commander';
|
||||
import type { RegistryWriteOutcome } from '../core/group/sync.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
|
|
@ -120,16 +121,42 @@ export function registerGroupCommands(program: Command): void {
|
|||
indexStale: boolean;
|
||||
contractsStale: boolean;
|
||||
missing: boolean;
|
||||
/**
|
||||
* Optional here on purpose: a payload produced before the split
|
||||
* carries no such key, and an absent one must degrade to the
|
||||
* label this command has always printed rather than to the new
|
||||
* one — an unrecorded cause is not evidence of a cause.
|
||||
*/
|
||||
unresolvable?: boolean;
|
||||
unresolvableReason?: string;
|
||||
commitsBehind?: number;
|
||||
}
|
||||
>;
|
||||
missingRepos?: string[];
|
||||
unreadableRepos?: string[];
|
||||
};
|
||||
|
||||
console.log(' Repo index / contracts staleness:');
|
||||
for (const [repoPath, row] of Object.entries(st.repos || {})) {
|
||||
if (row.missing) {
|
||||
console.log(` ${repoPath.padEnd(25)} MISSING (not in registry or unreadable)`);
|
||||
// Two different facts with two different remedies: a repo the
|
||||
// registry never heard of is fixed by indexing it, while an entry
|
||||
// the resolver choked on is fixed by repairing the registry.
|
||||
// Printing "no entry in the registry" for the second one states a
|
||||
// cause that was never measured, and points at the wrong repair.
|
||||
if (row.unresolvable) {
|
||||
// The reason can be multi-line — an ambiguous registry names
|
||||
// every colliding clone. Fold it onto this row's line rather
|
||||
// than truncating it: those paths are what the operator acts on,
|
||||
// and a table row that swallows half its own explanation is the
|
||||
// failure this label exists to stop.
|
||||
const why = (row.unresolvableReason ?? 'the registry entry could not be resolved')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
console.log(` ${repoPath.padEnd(25)} UNRESOLVABLE (${why})`);
|
||||
continue;
|
||||
}
|
||||
console.log(` ${repoPath.padEnd(25)} MISSING (no entry in the registry)`);
|
||||
continue;
|
||||
}
|
||||
const idx = row.indexStale
|
||||
|
|
@ -138,6 +165,26 @@ export function registerGroupCommands(program: Command): void {
|
|||
const ctr = row.contractsStale ? ' CONTRACTS_STALE' : '';
|
||||
console.log(` ${repoPath.padEnd(25)} ${idx}${ctr}`);
|
||||
}
|
||||
// `undefined` and `[]` are different answers here: a registry written
|
||||
// before this was tracked has no opinion, while an empty array is a
|
||||
// measurement. Printing nothing for both would let an unmeasured sync
|
||||
// read as evidence that every index opened cleanly.
|
||||
//
|
||||
// `undefined` covers two ways of not knowing — the field is absent, or
|
||||
// it held something that was not a list of repo paths and `getStatus`
|
||||
// declined to guess. Naming only the first would make a corrupt
|
||||
// registry read as a merely old one, which is the same shape of wrong
|
||||
// answer this command exists to stop giving.
|
||||
const unreadable = st.unreadableRepos;
|
||||
if (unreadable === undefined) {
|
||||
console.log(
|
||||
`\n Last sync unreadable repos: not recorded` +
|
||||
`\n (the registry predates this field, or its value could not be read)` +
|
||||
`\n Re-run \`gitnexus group sync\` to record it.`,
|
||||
);
|
||||
} else if (unreadable.length > 0) {
|
||||
console.log(`\n Last sync unreadable repos: ${unreadable.join(', ')}`);
|
||||
}
|
||||
if ((st.missingRepos || []).length > 0) {
|
||||
console.log(`\n Last sync missing repos: ${st.missingRepos!.join(', ')}`);
|
||||
}
|
||||
|
|
@ -158,30 +205,93 @@ export function registerGroupCommands(program: Command): void {
|
|||
const { getGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js');
|
||||
const { loadGroupConfig } = await import('../core/group/config-parser.js');
|
||||
const { syncGroup } = await import('../core/group/sync.js');
|
||||
const { GroupSyncLockError } = await import('../core/group/group-lock.js');
|
||||
|
||||
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
|
||||
const config = await loadGroupConfig(groupDir);
|
||||
|
||||
console.log(`Syncing group "${name}" (${Object.keys(config.repos).length} repos)...\n`);
|
||||
|
||||
const result = await syncGroup(config, {
|
||||
groupDir,
|
||||
allowStale: Boolean(opts.allowStale),
|
||||
verbose: Boolean(opts.verbose),
|
||||
skipEmbeddings: Boolean(opts.skipEmbeddings),
|
||||
exactOnly: Boolean(opts.exactOnly),
|
||||
});
|
||||
let result: Awaited<ReturnType<typeof syncGroup>>;
|
||||
try {
|
||||
result = await syncGroup(config, {
|
||||
groupDir,
|
||||
allowStale: Boolean(opts.allowStale),
|
||||
verbose: Boolean(opts.verbose),
|
||||
skipEmbeddings: Boolean(opts.skipEmbeddings),
|
||||
exactOnly: Boolean(opts.exactOnly),
|
||||
});
|
||||
} catch (err) {
|
||||
// A sync that could not take the group's lock did NOT run and wrote
|
||||
// nothing (R9 fails closed). That is an operator-actionable outcome, not
|
||||
// a crash, so report it as a failed command rather than letting it
|
||||
// surface as an unhandled rejection with a stack trace — commander's
|
||||
// async actions have no error handler, so an uncaught throw here would
|
||||
// print exactly that.
|
||||
if (!(err instanceof GroupSyncLockError)) throw err;
|
||||
logger.error(`⚠️ Did not sync group "${name}": ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
// Repos we could not read are the most likely explanation for a small
|
||||
// or empty contract count, so they are reported before the counts —
|
||||
// otherwise a run that read nothing looks exactly like a clean run.
|
||||
if (result.unreadableRepos.length > 0) {
|
||||
// No "re-run with GITNEXUS_LOG_LEVEL=warn" hint: the default level is
|
||||
// `info`, and pino emits `warn` (40) at `info` (30), so the reason was
|
||||
// already printed by this same run — raising the level to `warn` would
|
||||
// only suppress the surrounding `info` output.
|
||||
console.log(
|
||||
`\n ⚠️ Could not extract contracts from: ${result.unreadableRepos.join(', ')}` +
|
||||
`\n None of their contracts are included in this sync (the warning above says why),` +
|
||||
`\n or check \`gitnexus doctor\` in the affected repo.`,
|
||||
);
|
||||
}
|
||||
if (result.missingRepos.length > 0) {
|
||||
console.log(
|
||||
`\n ⚠️ Not found in the registry: ${result.missingRepos.join(', ')}` +
|
||||
`\n Index them with \`gitnexus analyze\`, or remove them from group.yaml.`,
|
||||
);
|
||||
}
|
||||
console.log(`\nMatching cascade:`);
|
||||
const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact');
|
||||
console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`);
|
||||
console.log(` unmatched: ${result.unmatched.length} contracts`);
|
||||
console.log(
|
||||
`\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`,
|
||||
);
|
||||
// Driven by what actually happened to the file. This line used to be
|
||||
// unconditional, so a run that deliberately preserved the previous
|
||||
// registry still announced `Wrote contracts.json (0 contracts, 0
|
||||
// cross-links)` — a confident false statement about persisted state, on
|
||||
// the exact path this command exists to make legible.
|
||||
// Exhaustive by construction: a `Record` keyed on the union means a
|
||||
// new outcome fails the build here instead of printing nothing, which
|
||||
// is what previously pushed a distinct state into `preserved` and made
|
||||
// this summary false on one of the two branches it then covered.
|
||||
const OUTCOME_LINE: Record<RegistryWriteOutcome, string | null> = {
|
||||
written:
|
||||
`\nWrote contracts.json (${result.contracts.length} contracts, ` +
|
||||
`${result.crossLinks.length} cross-links)`,
|
||||
preserved:
|
||||
`\nKept the previous contracts.json — no repo in this group could be read.` +
|
||||
`\n Its contracts and cross-links are unchanged; only the unreadable/missing` +
|
||||
`\n repo lists were refreshed to describe THIS run. Fix the repos above and re-run.`,
|
||||
superseded:
|
||||
`\nDid NOT touch contracts.json — no repo in this group could be read, and another` +
|
||||
`\n sync replaced the file while this one waited for the group lock. That sync's` +
|
||||
`\n result stands and this run's repo lists were NOT recorded: they describe a` +
|
||||
`\n group state older than what is on disk. Fix the repos above and re-run.`,
|
||||
'no-prior-registry':
|
||||
`\nDid NOT write contracts.json — no repo in this group could be read,` +
|
||||
`\n and there is no previous contracts.json to fall back on. Fix the repos` +
|
||||
`\n above and re-run.`,
|
||||
// Nothing to say: the caller asked for no write.
|
||||
'not-attempted': null,
|
||||
};
|
||||
const line = OUTCOME_LINE[result.registryOutcome];
|
||||
if (line) console.log(line);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -370,7 +480,7 @@ export function registerGroupCommands(program: Command): void {
|
|||
return;
|
||||
}
|
||||
|
||||
const { contracts, crossLinks } = raw as {
|
||||
const { contracts, crossLinks, truncated, unreadableRepos, missingRepos } = raw as {
|
||||
contracts: Array<{
|
||||
role: string;
|
||||
contractId: string;
|
||||
|
|
@ -384,10 +494,19 @@ export function registerGroupCommands(program: Command): void {
|
|||
confidence: number;
|
||||
contractId: string;
|
||||
}>;
|
||||
truncated?: boolean;
|
||||
unreadableRepos?: string[];
|
||||
missingRepos?: string[];
|
||||
};
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ contracts, crossLinks }, null, 2));
|
||||
// The whole payload, not a re-serialized subset. Destructuring the two
|
||||
// fields this command happens to print and rebuilding an object from
|
||||
// them dropped everything else the service returned — which is how the
|
||||
// completeness fields were invisible here while the MCP tool carried
|
||||
// them. Printing `raw` means a field added to the service reaches
|
||||
// `--json` without a matching edit in this file.
|
||||
console.log(JSON.stringify(raw, null, 2));
|
||||
} else {
|
||||
console.log(`Contracts (${contracts.length}):`);
|
||||
for (const c of contracts) {
|
||||
|
|
@ -399,6 +518,19 @@ export function registerGroupCommands(program: Command): void {
|
|||
` ${l.from.repo} -> ${l.to.repo} [${l.matchType}, conf=${l.confidence}] ${l.contractId}`,
|
||||
);
|
||||
}
|
||||
if (truncated) {
|
||||
// Counts above are a floor, not a census. Name the repos when the
|
||||
// registry recorded them, and say so plainly when it did not — a
|
||||
// listing that cannot say what it is missing is still incomplete.
|
||||
const absent = [...(unreadableRepos ?? []), ...(missingRepos ?? [])];
|
||||
console.log(
|
||||
absent.length > 0
|
||||
? `\n⚠️ This listing is incomplete: the last sync could not account for ${absent.join(', ')}.` +
|
||||
`\n Contracts from those repos are absent, so the counts above are a lower bound.`
|
||||
: `\n⚠️ This listing is incomplete: the last sync did not record which repos it could` +
|
||||
`\n read, so the counts above are a lower bound. Re-run group sync.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await backend.dispose().catch(() => {});
|
||||
|
|
|
|||
107
gitnexus/src/core/group/REVIEW-FINDINGS-MAP.md
Normal file
107
gitnexus/src/core/group/REVIEW-FINDINGS-MAP.md
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
# Review findings → commits (PR #3012)
|
||||
|
||||
Every finding raised in review of this PR, and the commit that closes it. The
|
||||
Definition of Done claims each finding has exactly one commit and that reverting
|
||||
that commit reintroduces that finding and no other; this is what makes the claim
|
||||
checkable without the reviewer's report in hand.
|
||||
|
||||
**Not under `docs/`** — that path is gitignored, so a map written there would
|
||||
never reach the PR and nobody but its author could perform the audit. It lives
|
||||
beside the code it describes, as `PIPELINE.md` does.
|
||||
|
||||
## Revert contract
|
||||
|
||||
Revertability is **dependency-aware**. Where one commit extracts a helper that
|
||||
later commits consume, reverting the helper alone does not build. The contract
|
||||
is: reverting a commit reintroduces its own finding and no other _finding_, with
|
||||
its prerequisite commits retained.
|
||||
|
||||
One coupled set exists:
|
||||
|
||||
| Set | Commits | Why coupled |
|
||||
| -------------------------- | --------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| Shared completeness helper | `4c203ac7b` ← `79f6f5bcb`, `0fe6fc9d4`, `dbc3953b0` | The three consumers call `crossRepoCompleteness`; reverting it alone breaks the build. |
|
||||
|
||||
## Primary findings
|
||||
|
||||
| # | Finding | Commit |
|
||||
| --- | -------------------------------------------------------------------------------- | ----------- |
|
||||
| 1 | Malformed `meta.json` crashes cross-repo impact and leaks the bridge handle | `27b0069f2` |
|
||||
| 2 | Unreadable repos still contribute contracts through deferred manifest resolution | `7037e8441` |
|
||||
| 3 | Strict read accepts a registry row that cannot identify a repo | `5245b22d7` |
|
||||
| 4 | Unstamped bridge metadata is trusted without any check | `94f2a8757` |
|
||||
| 5 | A subgroup-scoped query is marked incomplete by repos it excluded | `79f6f5bcb` |
|
||||
| 6 | The preserved registry and the bridge disagree about the same sync | `4676abf03` |
|
||||
| 7 | Three surfaces compute completeness three different ways | `4c203ac7b` |
|
||||
| 8 | `group_contracts` has no channel for its own completeness | `0fe6fc9d4` |
|
||||
| 9 | `group status` cannot tell a missing entry from an unreadable registry | `a12b846c9` |
|
||||
| 10 | The sync summary describes a write that did not happen that way | `5a668455c` |
|
||||
| 11 | The total-failure log promises preservation where there is nothing to preserve | `c4b356b29` |
|
||||
| 12 | The bridge-failure warning promises a truncation the code never reports | `1df79bb9a` |
|
||||
| 13 | Two concurrent syncs of one group lose each other's writes | `4f07359bf` |
|
||||
| 14 | The bridge swap needs the lock its caller already holds | `3b6215862` |
|
||||
| 15 | The byte guard misses most tracked text files, and all extensionless ones | `07bf8be75` |
|
||||
| 16 | The byte guard reads the vendored grammar tree it does not need to judge | `3ef831a0a` |
|
||||
| 17 | The strict-read test cannot see which registry read ran | `eccc3c682` |
|
||||
| 18 | The CLI branches this PR introduced have no assertions | `535d2ad29` |
|
||||
| 19 | The MCP payloads have no assertions | `2c253b4a8` |
|
||||
| 20 | Corrupt-registry errors quote the file's bytes, credentials included | `24ba2a537` |
|
||||
| 21 | The mtime pairing's limits are recorded nowhere a reader will look | `ca0aca106` |
|
||||
| 22 | The bridge-input docstring narrows what `unreadableRepos` means | `8c930f470` |
|
||||
| 23 | The strict-read docstring's call-site count is wrong | `a95838954` |
|
||||
| 24 | Contract staging crashes on the engine's argument limit | `57eac7558` |
|
||||
| 25 | The sync tool's description names two of three reachable outcomes | `8bfd1a6ab` |
|
||||
| 26 | The impact tool and status resource do not explain incompleteness | `dbc3953b0` |
|
||||
| 27 | A lock timeout blames an `analyze` it cannot establish | `2d2a0119e` |
|
||||
| 28 | A losing sync downgrades the one that beat it to the lock | `e407f05cf` |
|
||||
|
||||
## Findings raised in review and deliberately not implemented as suggested
|
||||
|
||||
| Finding | Suggested fix | What shipped, and why |
|
||||
| ---------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Unstamped metadata is trusted | Treat every absent stamp as incomplete | Rejected. It would mark every pre-existing bridge a lower bound until re-synced — a repo-wide regression traded for a narrow window. The write-order pairing in `94f2a8757` is the narrower fix. |
|
||||
| Stale bridge signal after a failed write | Re-stamp the metadata so the warning's promise becomes true | Rejected. Re-stamping recreates the metadata/database mis-pairing that stamping exists to prevent. `1df79bb9a` corrects the warning instead. |
|
||||
| Strict row gate | Require all three fields non-blank | Narrowed to `name` and `storagePath`. This gate rejects the whole registry, which is machine-wide, so a field tightened past what identification needs lets one blank value break every group sync on the machine. |
|
||||
|
||||
## Found during execution, not in the review
|
||||
|
||||
| What | Commit |
|
||||
| --------------------------------------------------------------------------------------------- | ----------- |
|
||||
| A half-written bridge stamp read as a verified match (found by the repo's own contract check) | `066f2d802` |
|
||||
| `readBridgeMeta`'s widened return type blocked the merge on contract drift | `a9d281dd4` |
|
||||
| `group contracts --json` discarded every field it did not re-serialize | `b7753575d` |
|
||||
| `sync.ts` renders as a binary diff because the base blob carries a NUL | `1667c24b4` |
|
||||
|
||||
## Corrections to the plan, found while executing it
|
||||
|
||||
Recorded because each was a claim in the plan that the code contradicted.
|
||||
|
||||
| Claim | Reality |
|
||||
| ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| The strict gate should require the fields "the resolution path consumes" | `defaultResolveHandle` **does** consume `path`. The distinction is what _identifies_ the repo. |
|
||||
| Pass the trace's two endpoint repos as the scope predicate | A destination trace declares no `to`. Narrowing to `from` would report an unreadable provider as "no outgoing link". |
|
||||
| Filter the incomplete set by the subgroup prefix | The query's own repo must stay in scope, or an unreadable origin becomes a confident "nothing depends on this". |
|
||||
| `group status`'s third failure mode is a row that resolves but cannot be opened | Unreachable — `loadMeta` returns `null` on every error and `checkStaleness` catches everything. The reachable case is `resolveRepo` throwing. |
|
||||
| The mtime rule can only demote pairs already broken | False. `cp -r` and `rsync` without `-t` demote an intact pair. Recorded at the code in `ca0aca106`. |
|
||||
| `.scm` files are "edited constantly" here | Every tracked `.scm` is vendored. This repo writes tree-sitter queries inline in TypeScript. |
|
||||
|
||||
## Residual risks, recorded rather than closed
|
||||
|
||||
- **Credentials in the registry.** HTTPS remote URLs are persisted with their
|
||||
userinfo intact. `24ba2a537` stops one channel echoing them; it does not stop
|
||||
them being written. Pre-existing, tracked separately.
|
||||
- **`readRegistryFile`'s read error.** The ENOENT-guarded outer catch still
|
||||
rethrows the raw `fs.readFile` error into `unresolvableReason`. Node embeds
|
||||
the path, not file contents, so no registry bytes leak — but it is the one
|
||||
remaining foreign error object on that path.
|
||||
- **Abstract-socket lock scope.** Linux abstract sockets are
|
||||
network-namespace-scoped, so two containers sharing a bind-mounted group
|
||||
directory do not contend unless the file backend is forced. Recorded at
|
||||
`group-lock.ts`.
|
||||
- **Scope filter at depth > 1.** The declared-scope intersection is sound only
|
||||
while `MAX_SUPPORTED_CROSS_DEPTH` is 1. At depth 2 an out-of-scope repo can
|
||||
sit between two in-scope ones. Recorded at the intersection site.
|
||||
- **R14 is unmet on this PR.** `.gitattributes` makes TypeScript diffs render as
|
||||
text, and it works locally — but GitHub resolves the attribute from the base
|
||||
side, which does not carry it. `sync.ts` renders as binary in this PR's web
|
||||
view and will render as text for every PR after this one merges.
|
||||
|
|
@ -5,12 +5,14 @@ import lbug from '@ladybugdb/core';
|
|||
import type { LbugValue } from '@ladybugdb/core';
|
||||
import type { BridgeHandle, BridgeMeta, StoredContract, CrossLink, RepoSnapshot } from './types.js';
|
||||
import { BRIDGE_SCHEMA_QUERIES, BRIDGE_SCHEMA_VERSION } from './bridge-schema.js';
|
||||
import { recordedRepoList } from './completeness.js';
|
||||
import {
|
||||
closeLbugConnection,
|
||||
openLbugConnection,
|
||||
type LbugConnectionHandle,
|
||||
} from '../lbug/lbug-config.js';
|
||||
import { dedupeContracts, dedupeCrossLinks } from './normalization.js';
|
||||
import { withGroupSyncLock } from './group-lock.js';
|
||||
import { createLogger } from '../logger.js';
|
||||
import { retryRename, writeFileAtomic } from '../../storage/fs-atomic.js';
|
||||
|
||||
|
|
@ -650,13 +652,296 @@ export async function writeBridgeMeta(groupDir: string, meta: BridgeMeta): Promi
|
|||
await writeFileAtomic(path.join(groupDir, 'meta.json'), JSON.stringify(meta, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Does `meta` still describe the `bridge.lbug` sitting next to it?
|
||||
*
|
||||
* `writeBridge` stamps the database's size and mtime into the metadata it
|
||||
* writes, so a metadata file left over from an earlier sync cannot match a
|
||||
* database that was replaced after it. Callers whose answer depends on the
|
||||
* metadata being true of THIS database (cross-repo impact reads completeness
|
||||
* from it) must not treat a mismatch as fact.
|
||||
*
|
||||
* When BOTH halves of the stamp are absent the metadata predates stamping, and
|
||||
* it is judged on the write order of the two files instead — see
|
||||
* {@link unstampedMetaPairsByWriteOrder}. Failing every unstamped metadata
|
||||
* closed would mark all pre-existing bridges as incomplete until re-synced,
|
||||
* trading a narrow window for a repo-wide regression; accepting them all hands
|
||||
* back "verified" for the very window this pairing exists to catch.
|
||||
*
|
||||
* A stamp is a PAIR, so exactly one half present is rejected rather than waved
|
||||
* through. That is not the legacy shape: something wrote a stamp and did not
|
||||
* finish, which is the very condition stamping was added to detect. Joining the
|
||||
* two `undefined` checks with `||` returned "verified" for precisely the shape
|
||||
* that most deserves suspicion.
|
||||
*
|
||||
* Returns `false` when the database itself cannot be stat'd, on either path,
|
||||
* since metadata describing a file that is not there describes nothing.
|
||||
*
|
||||
* The checks are ORDERED by how strong their evidence is, strongest first, and
|
||||
* each later one is reached only because every earlier one had nothing to say.
|
||||
* `provenanceUnknown` therefore comes first: a metadata file whose own writer
|
||||
* says it cannot vouch for the database beside it has settled the question, and
|
||||
* neither the stamp nor the write-order heuristic may overturn that.
|
||||
*
|
||||
* The marker is not decoration. `refreshPreservedBridgeMeta` rewrites this file
|
||||
* atomically without touching the database, which leaves `meta.mtime` newer —
|
||||
* the write order a paired write produces, and the one the unstamped branch
|
||||
* ACCEPTS. Reading the marker after that branch (or not at all) hands back
|
||||
* "verified" for a pair the same code path had just found broken.
|
||||
*/
|
||||
export async function bridgeMetaMatchesFile(groupDir: string, meta: BridgeMeta): Promise<boolean> {
|
||||
if (meta.provenanceUnknown) return false;
|
||||
const stampedSize = meta.bridgeSize !== undefined;
|
||||
const stampedMtime = meta.bridgeMtimeMs !== undefined;
|
||||
if (!stampedSize && !stampedMtime) return unstampedMetaPairsByWriteOrder(groupDir);
|
||||
if (!stampedSize || !stampedMtime) return false;
|
||||
try {
|
||||
const stat = await fsp.stat(path.join(groupDir, 'bridge.lbug'));
|
||||
return stat.size === meta.bridgeSize && stat.mtimeMs === meta.bridgeMtimeMs;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Could the unstamped `meta.json` plausibly have been written by the sync that
|
||||
* put this `bridge.lbug` beside it?
|
||||
*
|
||||
* `writeBridge` renames the database into place and writes the metadata AFTER,
|
||||
* so `meta.mtime >= db.mtime` holds for any pair written together — including
|
||||
* pairs written by builds from before the stamp existed, which is what makes
|
||||
* this usable as back-compat rather than a repo-wide "re-sync everything".
|
||||
* The only way to reach a database strictly NEWER than the metadata beside it
|
||||
* is a swap whose metadata write did not land: the stale-meta-beside-a-new-
|
||||
* database window, whose completeness `runGroupImpact` would otherwise spend as
|
||||
* fact.
|
||||
*
|
||||
* This is a HEURISTIC ON WRITE ORDER, not proof of provenance. It answers "were
|
||||
* these two written in the order a successful sync writes them?", and treats
|
||||
* that as a proxy for "do these two belong together". It is wrong in two
|
||||
* directions, and neither is theoretical:
|
||||
* - FALSE ACCEPT, from a non-monotonic wall clock. `mtimeMs` is realtime, not
|
||||
* monotonic, so an NTP step backwards, a VM snapshot restore or container
|
||||
* clock skew between the database write and the metadata write can leave a
|
||||
* genuinely mis-paired set reading as ordered. Anything that touches the
|
||||
* stale metadata after a swap does the same — a restore from backup, an
|
||||
* editor save, a copy that preserves only the database's times. The STAMP
|
||||
* is what actually closes this; a pair that has one never reaches here.
|
||||
*
|
||||
* Coarse filesystem mtime granularity is NOT this hazard, despite looking
|
||||
* like it: it collapses a pair written together to equal times, and equal
|
||||
* is accepted, which is the correct verdict for that pair.
|
||||
*
|
||||
* - FALSE REJECT, from anything that rewrites the database's mtime after the
|
||||
* metadata's — `cp -r`, `rsync` without `-t`, a machine move, a restore
|
||||
* that replays files in directory order. An intact legacy pair is then
|
||||
* demoted to a lower bound and stays there until the next successful sync
|
||||
* re-stamps it; there is no other recovery, because nothing on the read
|
||||
* path can distinguish it from the swap window it is imitating.
|
||||
*
|
||||
* This direction is the safe one — it degrades an answer to a floor rather
|
||||
* than vouching for one — but it is a real, reachable cost, not a
|
||||
* theoretical one, and it is NOT true that the rule can only ever demote
|
||||
* pairs that were already broken.
|
||||
*
|
||||
* Equality counts as paired. On a filesystem with coarse mtime granularity both
|
||||
* writes land in the same tick, and demanding a strictly newer metadata file
|
||||
* would reject every legacy bridge there for a reason that is about the
|
||||
* filesystem rather than about the bridge.
|
||||
*
|
||||
* A timestamp that cannot be measured is no match, the same convention the
|
||||
* read-only handle cache applies to a bridge it could not stat: a comparison
|
||||
* that could not be made is not a comparison that succeeded.
|
||||
*/
|
||||
async function unstampedMetaPairsByWriteOrder(groupDir: string): Promise<boolean> {
|
||||
try {
|
||||
const [dbStat, metaStat] = await Promise.all([
|
||||
fsp.stat(path.join(groupDir, 'bridge.lbug')),
|
||||
fsp.stat(path.join(groupDir, 'meta.json')),
|
||||
]);
|
||||
return metaStat.mtimeMs >= dbStat.mtimeMs;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read `meta.json`, validating the SHAPE of what it holds.
|
||||
*
|
||||
* The read and the parse have always been guarded — an absent or unparseable
|
||||
* file answers `version: 0`, which every caller already treats as "no
|
||||
* provenance". What was not guarded is a file that parses into something that
|
||||
* is not this shape: `runGroupImpact` spread both repo lists directly into a
|
||||
* `Set`, so a non-iterable there threw a TypeError out of the whole cross-repo
|
||||
* query, from a point where the bridge lease had been taken and not yet
|
||||
* released. A malformed file is a reason to answer "provenance unknown", never
|
||||
* a reason to crash the question.
|
||||
*/
|
||||
export async function readBridgeMeta(groupDir: string): Promise<BridgeMeta> {
|
||||
const unreadable: BridgeMeta = { version: 0, generatedAt: '', missingRepos: [] };
|
||||
let parsed: unknown;
|
||||
try {
|
||||
const content = await fsp.readFile(path.join(groupDir, 'meta.json'), 'utf-8');
|
||||
return JSON.parse(content) as BridgeMeta;
|
||||
parsed = JSON.parse(content);
|
||||
} catch {
|
||||
return { version: 0, generatedAt: '', missingRepos: [] };
|
||||
return unreadable;
|
||||
}
|
||||
// `JSON.parse` succeeds on `null`, `7` and `[]` too, and none of them are
|
||||
// metadata. Reading `.version` off the first of those is a thrown TypeError;
|
||||
// reading it off the others silently yields `undefined`, which passes the
|
||||
// version gate as if the bridge had been vouched for.
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return unreadable;
|
||||
|
||||
const raw = parsed as Partial<BridgeMeta>;
|
||||
const missingRepos = recordedRepoList(raw.missingRepos);
|
||||
const unreadableRepos = recordedRepoList(raw.unreadableRepos);
|
||||
// Each list is judged on its own: a file whose `unreadableRepos` is garbage
|
||||
// can still carry a `missingRepos` that was genuinely measured, and throwing
|
||||
// that away would turn one unknown into two.
|
||||
const repoListsUnreadable =
|
||||
(raw.missingRepos !== undefined && missingRepos === undefined) ||
|
||||
(raw.unreadableRepos !== undefined && unreadableRepos === undefined);
|
||||
|
||||
const meta: BridgeMeta = {
|
||||
...raw,
|
||||
// A version that is not a number cannot be compared against
|
||||
// BRIDGE_SCHEMA_VERSION; `0` is this file's existing word for "provenance
|
||||
// unknown", which is exactly what such a file gives us.
|
||||
// `0` is this file's word for "no provenance". A version that is not a
|
||||
// positive integer is not a schema version, and letting one through splits
|
||||
// the four gates that read this field: `ensureBridgeReady` and
|
||||
// `openBridgeDbReadOnly` both compare `> 0 && !== CURRENT` and would open
|
||||
// the bridge, `bridgeExists` compares `=== 0 || === CURRENT` and would say
|
||||
// it is not there, and `bridgeProvenanceUnknown` compares `=== 0` and would
|
||||
// call the answer complete. Normalizing here keeps all four agreeing
|
||||
// instead of teaching each one the same new case.
|
||||
version:
|
||||
Number.isInteger(raw.version) && (raw.version as number) > 0 ? (raw.version as number) : 0,
|
||||
generatedAt: typeof raw.generatedAt === 'string' ? raw.generatedAt : '',
|
||||
missingRepos: missingRepos ?? [],
|
||||
};
|
||||
// Absent, not empty. `unreadableRepos` is optional and "not recorded" is a
|
||||
// distinct state from "measured none", so an unusable value is dropped rather
|
||||
// than carried through — `repoListsUnreadable` is what records that something
|
||||
// was there and could not be read.
|
||||
if (unreadableRepos) meta.unreadableRepos = unreadableRepos;
|
||||
else delete meta.unreadableRepos;
|
||||
if (repoListsUnreadable) meta.repoListsUnreadable = true;
|
||||
return meta;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* refreshPreservedBridgeMeta */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* What a refresh did to `meta.json`.
|
||||
*
|
||||
* - `restamped` — the pair still matched, so the lists were refreshed
|
||||
* and the stamp re-taken from the database on disk.
|
||||
* - `provenance-unknown` — the pair did NOT match (or there is no database to
|
||||
* match), so the lists were refreshed and the metadata
|
||||
* marked as unable to vouch for the file beside it.
|
||||
* - `no-bridge` — neither `meta.json` nor `bridge.lbug` exists, so
|
||||
* there is no pair to keep honest and nothing written.
|
||||
*/
|
||||
export type PreservedBridgeMetaOutcome = 'restamped' | 'provenance-unknown' | 'no-bridge';
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fsp.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring `meta.json`'s diagnostic lists up to date with a sync that PRESERVED
|
||||
* the bridge instead of rebuilding it, without ever making the metadata claim
|
||||
* more about the database than it did before.
|
||||
*
|
||||
* `syncGroup`'s total-failure path keeps the previous run's contracts and
|
||||
* deliberately leaves `bridge.lbug` alone — the contracts that bridge holds are
|
||||
* the ones being preserved. But `runGroupImpact` reads completeness from
|
||||
* `meta.json`, not from `contracts.json`, so leaving the metadata alone too left
|
||||
* the two files telling different stories: the registry said "this sync could
|
||||
* not read svc/users" while a cross-repo query answered "complete, nothing
|
||||
* depends on this" (R4/R6).
|
||||
*
|
||||
* The refresh is the whole difficulty. It rewrites `meta.json` atomically, so
|
||||
* the file's mtime becomes now while the database's stays old — which is the
|
||||
* write order a paired write produces, and precisely what
|
||||
* `unstampedMetaPairsByWriteOrder` accepts. Three rules follow, and each of them
|
||||
* is load-bearing:
|
||||
*
|
||||
* 1. Ask `bridgeMetaMatchesFile` FIRST, on the file as it stands. After the
|
||||
* write the question is unanswerable, because the write is what destroys
|
||||
* the evidence.
|
||||
* 2. Re-stamp only when that answer was yes. Re-stamping a pair that already
|
||||
* failed would MANUFACTURE the provenance the failure just denied — the
|
||||
* same metadata/database mis-pairing stamping exists to prevent (KTD6).
|
||||
* 3. When it was no, record `provenanceUnknown` explicitly and carry the
|
||||
* existing stamp fields through verbatim. Writing "no stamp" instead is
|
||||
* worse, not better: an unstamped file is judged on the two file times,
|
||||
* and this write has just put them in the accepting order.
|
||||
*
|
||||
* Nothing here opens, reads, or writes the database. The only `stat` of it
|
||||
* happens on the branch where the pair was just verified.
|
||||
*
|
||||
* NOT SPLIT into locked/unlocked halves the way {@link writeBridge} is, and
|
||||
* deliberately. Its one caller is `syncGroup`'s preserve branch, which is
|
||||
* already inside `withGroupSyncLock` — so this write is ALREADY serialized
|
||||
* against every other sync of the group, and taking the lock here would be the
|
||||
* second acquisition of a non-reentrant primitive that the split exists to
|
||||
* avoid. An acquiring wrapper would therefore have zero production callers,
|
||||
* and no test calls this function at all: it would be dead code standing in for
|
||||
* a guarantee the caller already provides. If a caller outside the critical
|
||||
* section ever appears, it needs the same treatment `writeBridge` got — a
|
||||
* wrapper, not a lock moved down here.
|
||||
*/
|
||||
export async function refreshPreservedBridgeMeta(
|
||||
groupDir: string,
|
||||
diagnostics: { missingRepos: string[]; unreadableRepos: string[] },
|
||||
): Promise<PreservedBridgeMetaOutcome> {
|
||||
const dbPath = path.join(groupDir, 'bridge.lbug');
|
||||
const [metaOnDisk, dbOnDisk] = await Promise.all([
|
||||
fileExists(path.join(groupDir, 'meta.json')),
|
||||
fileExists(dbPath),
|
||||
]);
|
||||
// Nothing on either side of the pair. `readBridgeMeta` already answers
|
||||
// `version: 0` — provenance unknown — for an absent file, so a file written
|
||||
// here would say what the absence already says while inventing state for a
|
||||
// bridge that has never existed.
|
||||
if (!metaOnDisk && !dbOnDisk) return 'no-bridge';
|
||||
|
||||
const existing = await readBridgeMeta(groupDir);
|
||||
const paired = await bridgeMetaMatchesFile(groupDir, existing);
|
||||
|
||||
const refreshed: BridgeMeta = { ...existing, ...diagnostics };
|
||||
// NEVER PERSISTED (see `BridgeMeta`): both are things a READER computes ABOUT
|
||||
// a file, and this is the first code in the repo that reads metadata and
|
||||
// writes it back. `pairedWithDatabase` is the poisonous one — persisted, it
|
||||
// would tell every future reader that the pair had been verified.
|
||||
delete refreshed.repoListsUnreadable;
|
||||
delete refreshed.pairedWithDatabase;
|
||||
|
||||
if (paired) {
|
||||
const stat = await fsp.stat(dbPath).catch(() => null);
|
||||
if (stat) {
|
||||
refreshed.bridgeSize = stat.size;
|
||||
refreshed.bridgeMtimeMs = stat.mtimeMs;
|
||||
await writeBridgeMeta(groupDir, refreshed);
|
||||
return 'restamped';
|
||||
}
|
||||
// The database disappeared between the pairing check and this stat. There
|
||||
// is nothing left to stamp, so fall through and say so rather than write a
|
||||
// stamp describing a file that is gone.
|
||||
}
|
||||
|
||||
refreshed.provenanceUnknown = true;
|
||||
await writeBridgeMeta(groupDir, refreshed);
|
||||
return 'provenance-unknown';
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
|
@ -668,6 +953,22 @@ export interface WriteBridgeInput {
|
|||
crossLinks: CrossLink[];
|
||||
repoSnapshots: Record<string, RepoSnapshot>;
|
||||
missingRepos: string[];
|
||||
/**
|
||||
* Repos this sync could not extract from — see
|
||||
* `ContractRegistry.unreadableRepos` for the full definition, which this
|
||||
* field carries unchanged.
|
||||
*
|
||||
* Deliberately not restated here. The narrower wording this once had ("whose
|
||||
* index could not be opened") described one of the two causes and silently
|
||||
* excluded the other, an extractor that threw partway through — so the same
|
||||
* field meant one thing on the registry, another on the bridge input, and a
|
||||
* third on the result. One definition, referenced twice, cannot drift.
|
||||
*
|
||||
* Recorded in meta.json so cross-repo impact can tell "nothing depends on
|
||||
* this" from "we could not look": the bridge built here is missing every
|
||||
* contract those repos own.
|
||||
*/
|
||||
unreadableRepos?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -702,7 +1003,33 @@ function errMessage(err: unknown): string {
|
|||
}
|
||||
}
|
||||
|
||||
export async function writeBridge(
|
||||
/**
|
||||
* Rebuild `bridge.lbug` and its `meta.json`, ASSUMING THE CALLER ALREADY HOLDS
|
||||
* THE GROUP SYNC LOCK for `groupDir` (R9).
|
||||
*
|
||||
* PRECONDITION — the group lock is held. There is exactly one production call
|
||||
* site, `syncGroup` in sync.ts, and it is already inside
|
||||
* `withGroupSyncLock(groupDir, …)` when it gets here. Enforced by this comment
|
||||
* rather than by a type, matching `registerRepoUnlocked` / `withRegistryLock`
|
||||
* in repo-manager.ts, which splits the same shape for the same reason.
|
||||
*
|
||||
* WHY THE SPLIT EXISTS AT ALL. The swap this function performs — old database
|
||||
* aside, temp database into place, then `meta.json` written as a SECOND
|
||||
* operation — is the write two concurrent syncs can interleave into a pairing
|
||||
* that never existed: one sync's metadata beside the other's database. That
|
||||
* needs mutual exclusion. But taking the lock HERE would be a second
|
||||
* acquisition of a non-reentrant primitive inside a region that already holds
|
||||
* it, and it would hang every single sync on the happy path, not some rare
|
||||
* interleave. So the exclusion is the caller's, and this function only states
|
||||
* the precondition. {@link writeBridge} is the acquiring wrapper for callers
|
||||
* who are not already inside that region.
|
||||
*
|
||||
* SCOPE — writer-writer only. The reader-side promotion of a leftover
|
||||
* `bridge.lbug.bak` runs on ordinary reads, outside anybody's critical section;
|
||||
* `bridgeMetaMatchesFile` remains the reader's defense there and is not
|
||||
* replaced by this lock.
|
||||
*/
|
||||
export async function writeBridgeUnlocked(
|
||||
groupDir: string,
|
||||
input: WriteBridgeInput,
|
||||
): Promise<WriteBridgeReport> {
|
||||
|
|
@ -962,11 +1289,39 @@ export async function writeBridge(
|
|||
}
|
||||
await removeLbugFile(bakPath);
|
||||
|
||||
// 4. Write meta.json
|
||||
// 4. Write the new meta.json, STAMPED WITH THE FILE IT DESCRIBES.
|
||||
//
|
||||
// meta.json carries the bridge's completeness, and since #3011 that is
|
||||
// load-bearing: `runGroupImpact` folds `unreadableRepos ∪ missingRepos`
|
||||
// into its truncation fields. The swap above and this write are two
|
||||
// operations, so a sync that stops between them leaves the previous sync's
|
||||
// meta beside a new database — and reading that as fact is a confidently
|
||||
// wrong answer about the one thing this channel exists to make legible.
|
||||
//
|
||||
// Deleting the old meta before the swap would decide which way that window
|
||||
// fails, but at an unacceptable price: the rename of the old database is
|
||||
// wrapped in a catch that also swallows a FAILED rename (a held read-only
|
||||
// handle does this on Windows), so `writeBridge` can throw with the old,
|
||||
// perfectly good database still in place — and its metadata already gone,
|
||||
// unrecoverably, for as long as the swap keeps failing.
|
||||
//
|
||||
// So destroy nothing and pair the two instead: record the size and mtime of
|
||||
// the database this metadata describes, and let readers check that the pair
|
||||
// still belongs together (`bridgeMetaMatchesFile`). A stale meta cannot match
|
||||
// a freshly renamed database, and a sync that fails before the swap leaves a
|
||||
// matching pair untouched.
|
||||
const finalStat = await fsp.stat(finalPath);
|
||||
await writeBridgeMeta(groupDir, {
|
||||
version: BRIDGE_SCHEMA_VERSION,
|
||||
generatedAt: new Date().toISOString(),
|
||||
bridgeSize: finalStat.size,
|
||||
bridgeMtimeMs: finalStat.mtimeMs,
|
||||
missingRepos: input.missingRepos,
|
||||
// Persisted whenever the caller supplied it, `[]` included: an empty list
|
||||
// is the measurement "this sync accounted for every repo", and it is a
|
||||
// different claim from a bridge that never recorded the field. Omitted
|
||||
// only when the caller passed nothing to record.
|
||||
...(input.unreadableRepos ? { unreadableRepos: input.unreadableRepos } : {}),
|
||||
});
|
||||
|
||||
return report;
|
||||
|
|
@ -982,6 +1337,33 @@ export async function writeBridge(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild `bridge.lbug` and its `meta.json` as the only writer of `groupDir`.
|
||||
*
|
||||
* The acquiring half of the split described on {@link writeBridgeUnlocked}: for
|
||||
* callers that are NOT already inside the group's critical section, this takes
|
||||
* the group sync lock around the whole swap and releases it afterwards. Two
|
||||
* concurrent calls therefore run one after the other, so the `meta.json` left
|
||||
* on disk is stamped for the `bridge.lbug` left on disk instead of for the
|
||||
* loser's, which is the pairing the swap-plus-metadata sequence would otherwise
|
||||
* let them interleave into.
|
||||
*
|
||||
* NOT used by `syncGroup`, and it must not be: that path already holds this
|
||||
* lock, and `acquireIndexLock` is not reentrant, so routing it here would make
|
||||
* every ordinary sync wait out the full `GROUP_SYNC_LOCK_TIMEOUT_MS` ceiling
|
||||
* against itself. It calls {@link writeBridgeUnlocked} directly.
|
||||
*
|
||||
* Fails closed exactly as `withGroupSyncLock` does: if the lock cannot be
|
||||
* acquired, a `GroupSyncLockError` is thrown and NOTHING is written —
|
||||
* `bridge.lbug` and `meta.json` are left as they were.
|
||||
*/
|
||||
export async function writeBridge(
|
||||
groupDir: string,
|
||||
input: WriteBridgeInput,
|
||||
): Promise<WriteBridgeReport> {
|
||||
return withGroupSyncLock(groupDir, () => writeBridgeUnlocked(groupDir, input));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* openBridgeDbReadOnly */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
|
|
|||
137
gitnexus/src/core/group/completeness.ts
Normal file
137
gitnexus/src/core/group/completeness.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
/**
|
||||
* The one computation of "is this cross-repo answer complete?" (KTD10), and the
|
||||
* truncation vocabulary it speaks.
|
||||
*
|
||||
* A LEAF MODULE, deliberately, and that is the whole reason it exists apart from
|
||||
* `cross-impact.ts`. Three surfaces need this fold — impact, trace, and the
|
||||
* contract listing — but `cross-impact.ts` statically imports `bridge-db.ts`,
|
||||
* and through it the native LadybugDB binding. `service.ts` therefore had to
|
||||
* reach the fold through `await import('./cross-impact.js')`, which loaded that
|
||||
* entire module graph on the first `group_contracts` of every process — 44-51ms
|
||||
* and 8.4MB of RSS to run a `Set` union and a ternary, once per CLI invocation.
|
||||
*
|
||||
* Nothing here imports anything but types. Keep it that way: the moment this
|
||||
* file gains a runtime import, every consumer pays for it again.
|
||||
*/
|
||||
import type { GroupImpactTruncationReason } from './types.js';
|
||||
|
||||
/**
|
||||
* A union rather than `Pick<GroupImpactResult, ...>` so the two states are
|
||||
* distinguishable by their `truncated` discriminant: a caller that folds these
|
||||
* fields into its own result (see `crossRepoCompleteness`) can then read
|
||||
* `truncationReason` on the truncated branch without a fallback for a value
|
||||
* that cannot be absent there.
|
||||
*/
|
||||
export type TruncationFields =
|
||||
| { truncated: false }
|
||||
| {
|
||||
truncated: true;
|
||||
truncationReason: GroupImpactTruncationReason;
|
||||
riskEpistemic: 'lower-bound';
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the truncation fields every `runGroupImpact` return path shares.
|
||||
*
|
||||
* `riskEpistemic` must follow `truncated` mechanically: it is the marker that
|
||||
* tells a caller the `risk` value is a floor rather than a verdict, and
|
||||
* `mergeRisk` can only under-report once a crossing is dropped. Attaching it at
|
||||
* each return let two of the four paths set `truncated` without it, so a
|
||||
* truncated result read as complete — deriving it in one place is what keeps
|
||||
* the invariant from drifting again (#2787).
|
||||
*/
|
||||
export function truncationFields(
|
||||
truncated: boolean,
|
||||
// Only read on the truncated branch, so the not-truncated call sites omit it
|
||||
// rather than passing a reason that is thrown away.
|
||||
reasonIfTruncated: GroupImpactTruncationReason = 'partial',
|
||||
): TruncationFields {
|
||||
if (!truncated) return { truncated: false };
|
||||
return { truncated: true, truncationReason: reasonIfTruncated, riskEpistemic: 'lower-bound' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything a caller needs in order to say whether a cross-repo answer is
|
||||
* complete — deliberately WITHOUT naming where any of it came from.
|
||||
*
|
||||
* `BridgeMeta` is not in this signature, and must not be: `groupContracts`
|
||||
* answers the same question from `contracts.json` (via
|
||||
* `loadContractRegistryResilient`) and never opens a bridge at all, so
|
||||
* `version` / `repoListsUnreadable` / `pairedWithDatabase` do not exist on that
|
||||
* path. Each caller computes its own `provenanceUnknown` from whatever
|
||||
* provenance IT has and passes the boolean in.
|
||||
*/
|
||||
export interface CrossRepoCompletenessInput {
|
||||
/**
|
||||
* Repos the sync could not extract from, and repos it found no entry for.
|
||||
* Two independent diagnostics with one consequence — none of those repos'
|
||||
* contracts are in the artifact — so they are folded into one set.
|
||||
*/
|
||||
unreadableRepos?: readonly string[];
|
||||
missingRepos?: readonly string[];
|
||||
/** Computed by the caller; see `bridgeProvenanceUnknown` for the bridge one. */
|
||||
provenanceUnknown: boolean;
|
||||
/**
|
||||
* The query's DECLARED scope, not the set of repos the walk happened to
|
||||
* reach: the subgroup filter for an impact query, the two endpoint repos for
|
||||
* a trace, every member for a query that names none. An incomplete repo the
|
||||
* caller never asked about cannot make the caller's answer a floor, and
|
||||
* marking it anyway is how the marker stops meaning anything. Passing the
|
||||
* predicate in — rather than a repo list, or a subgroup — is what keeps
|
||||
* narrowing a scope a call-site change.
|
||||
*/
|
||||
inScope: (repoPath: string) => boolean;
|
||||
}
|
||||
|
||||
/** The structured triple, plus the in-scope repos that produced it. */
|
||||
export type CrossRepoCompleteness = TruncationFields & {
|
||||
/**
|
||||
* In-scope repos absent from the artifact, deduped, in first-seen order.
|
||||
* Empty on a provenance-unknown answer: nothing was measured there, and
|
||||
* inventing names out of an unreadable value is not a measurement.
|
||||
*/
|
||||
incompleteRepos: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The ONE computation of "is this cross-repo answer complete?" (KTD10).
|
||||
*
|
||||
* Three surfaces can return a partial cross-repo answer — impact, trace, and
|
||||
* the contract listing — and each used to decide for itself, in its own
|
||||
* vocabulary, which is how two of them ended up saying it in prose only. The
|
||||
* answer is the same structured triple `GroupImpactResult` already carries, so
|
||||
* an agent reading any of them learns "complete" vs "floor" the same way.
|
||||
*
|
||||
* `truncationFields` derives `riskEpistemic` from `truncated` mechanically, and
|
||||
* is reused here rather than re-implemented for the same reason it exists: the
|
||||
* marker that says "this is a floor, not a verdict" may never drift away from
|
||||
* the flag that says the answer was cut short (#2787).
|
||||
*/
|
||||
export function crossRepoCompleteness(input: CrossRepoCompletenessInput): CrossRepoCompleteness {
|
||||
const incompleteRepos = [
|
||||
...new Set([...(input.unreadableRepos ?? []), ...(input.missingRepos ?? [])]),
|
||||
].filter((repoPath) => input.inScope(repoPath));
|
||||
return {
|
||||
...truncationFields(input.provenanceUnknown || incompleteRepos.length > 0, 'incomplete-sync'),
|
||||
incompleteRepos,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A recorded repo list is an array of strings. Anything else — a bare string, an
|
||||
* object, an array of objects — is a value we could not read, which is "not
|
||||
* recorded", not "none".
|
||||
*
|
||||
* ONE definition, deliberately. This gate is the predicate the whole
|
||||
* absent-vs-empty-vs-populated distinction rests on, and it applies to the same
|
||||
* two lists on both the registry and the bridge metadata. It lived in two files
|
||||
* verbatim, which meant tightening it — say, to reject blank strings — would
|
||||
* have fixed one surface and silently left the other.
|
||||
*
|
||||
* `Array.isArray` alone is not enough: only an array of strings survives
|
||||
* `cli/group.ts`'s `.join(', ')` as repo paths rather than as `[object Object]`.
|
||||
*/
|
||||
export function recordedRepoList(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
return value.every((entry) => typeof entry === 'string') ? (value as string[]) : undefined;
|
||||
}
|
||||
|
|
@ -7,11 +7,11 @@ import fsp from 'node:fs/promises';
|
|||
import path from 'node:path';
|
||||
import type {
|
||||
BridgeHandle,
|
||||
BridgeMeta,
|
||||
ContractType,
|
||||
CrossRepoImpact,
|
||||
GroupConfig,
|
||||
GroupImpactResult,
|
||||
GroupImpactTruncationReason,
|
||||
MatchType,
|
||||
OutOfScopeLink,
|
||||
} from './types.js';
|
||||
|
|
@ -24,12 +24,23 @@ import {
|
|||
} from './group-path-utils.js';
|
||||
import { getGroupDir } from './storage.js';
|
||||
import {
|
||||
bridgeMetaMatchesFile,
|
||||
closeBridgeDb,
|
||||
getCachedBridgeReadOnly,
|
||||
queryBridge,
|
||||
readBridgeMeta,
|
||||
} from './bridge-db.js';
|
||||
import { BRIDGE_SCHEMA_VERSION } from './bridge-schema.js';
|
||||
// Re-exported so the three surfaces keep one import site for the vocabulary,
|
||||
// while the fold itself stays in a leaf module no native binding reaches.
|
||||
export {
|
||||
truncationFields,
|
||||
crossRepoCompleteness,
|
||||
type TruncationFields,
|
||||
type CrossRepoCompleteness,
|
||||
type CrossRepoCompletenessInput,
|
||||
} from './completeness.js';
|
||||
import { truncationFields, crossRepoCompleteness } from './completeness.js';
|
||||
import { compareCodeUnits } from '../../lib/utils.js';
|
||||
|
||||
// High limit for the local phase of group impact so collectImpactSymbolUids
|
||||
|
|
@ -381,23 +392,30 @@ export function mergeRisk(localRisk: string, cross: CrossRepoImpact[]): string {
|
|||
}
|
||||
|
||||
/**
|
||||
* Build the truncation fields every `runGroupImpact` return path shares.
|
||||
* Is this bridge's metadata unable to say where its contents came from?
|
||||
*
|
||||
* `riskEpistemic` must follow `truncated` mechanically: it is the marker that
|
||||
* tells a caller the `risk` value is a floor rather than a verdict, and
|
||||
* `mergeRisk` can only under-report once a crossing is dropped. Attaching it at
|
||||
* each return let two of the four paths set `truncated` without it, so a
|
||||
* truncated result read as complete — deriving it in one place is what keeps
|
||||
* the invariant from drifting again (#2787).
|
||||
* The three reads are all about a `BridgeMeta` and stay OUT of
|
||||
* `crossRepoCompleteness` on purpose (see its doc): they are how a caller that
|
||||
* opened a bridge computes `provenanceUnknown`, not how every caller does.
|
||||
*
|
||||
* - `version === 0` — no readable meta.json at all (`readBridgeMeta` answers
|
||||
* that for both "absent" and "unparseable");
|
||||
* - `repoListsUnreadable` — a meta.json that parsed but whose repo lists are
|
||||
* not repo lists. A value we could not read is not a measurement of zero,
|
||||
* so it may not be spent as one;
|
||||
* - `pairedWithDatabase === false` — a meta.json that does not describe the
|
||||
* database sitting beside it, which is what a sync interrupted between the
|
||||
* swap and the metadata write leaves behind. Measured by
|
||||
* `ensureBridgeReady` BEFORE the database is opened and carried on the
|
||||
* meta; this only reads the answer (#3012).
|
||||
*
|
||||
* Treating any of them as complete is the fail-open the completeness channel
|
||||
* exists to close.
|
||||
*/
|
||||
function truncationFields(
|
||||
truncated: boolean,
|
||||
// Only read on the truncated branch, so the not-truncated call sites omit it
|
||||
// rather than passing a reason that is thrown away.
|
||||
reasonIfTruncated: GroupImpactTruncationReason = 'partial',
|
||||
): Pick<GroupImpactResult, 'truncated' | 'truncationReason' | 'riskEpistemic'> {
|
||||
if (!truncated) return { truncated: false };
|
||||
return { truncated: true, truncationReason: reasonIfTruncated, riskEpistemic: 'lower-bound' };
|
||||
export function bridgeProvenanceUnknown(meta: BridgeMeta): boolean {
|
||||
return (
|
||||
meta.version === 0 || meta.repoListsUnreadable === true || meta.pairedWithDatabase === false
|
||||
);
|
||||
}
|
||||
|
||||
function addCrossImpact(cross: CrossRepoImpact[], candidate: CrossRepoImpact): void {
|
||||
|
|
@ -418,7 +436,7 @@ function addCrossImpact(cross: CrossRepoImpact[], candidate: CrossRepoImpact): v
|
|||
|
||||
export async function ensureBridgeReady(
|
||||
groupDir: string,
|
||||
): Promise<{ handle: BridgeHandle } | { error: string }> {
|
||||
): Promise<{ handle: BridgeHandle; meta: BridgeMeta } | { error: string }> {
|
||||
const meta = await readBridgeMeta(groupDir);
|
||||
if (meta.version > 0 && meta.version !== BRIDGE_SCHEMA_VERSION) {
|
||||
return {
|
||||
|
|
@ -433,6 +451,13 @@ export async function ensureBridgeReady(
|
|||
error: `No bridge.lbug in this group directory. Run gitnexus group sync (schema ${BRIDGE_SCHEMA_VERSION}).`,
|
||||
};
|
||||
}
|
||||
// Pair the metadata to the database BEFORE opening it, and carry the answer.
|
||||
// An unstamped pair is judged on the two files' write order, so any open that
|
||||
// touched `bridge.lbug`'s mtime would silently convert "legacy but intact"
|
||||
// into "provenance unknown" for every pre-stamp bridge on that platform. This
|
||||
// ordering removes the question rather than betting on the answer.
|
||||
meta.pairedWithDatabase = await bridgeMetaMatchesFile(groupDir, meta);
|
||||
|
||||
// Use the cached read-only handle if available — avoids reopening the same
|
||||
// bridge.lbug in a long-lived MCP server, which fails on Windows because
|
||||
// the OS handle isn't fully released before the next open races in.
|
||||
|
|
@ -442,7 +467,7 @@ export async function ensureBridgeReady(
|
|||
error: `Could not open bridge.lbug read-only (schema ${BRIDGE_SCHEMA_VERSION}). Run gitnexus group sync.`,
|
||||
};
|
||||
}
|
||||
return { handle };
|
||||
return { handle, meta };
|
||||
}
|
||||
|
||||
function rowToNeighbor(r: Record<string, unknown>): BridgeNeighborRow | null {
|
||||
|
|
@ -641,6 +666,25 @@ export async function runGroupImpact(
|
|||
if ('error' in bridgePrep) return { error: bridgePrep.error };
|
||||
|
||||
const handle = bridgePrep.handle;
|
||||
// Repos the sync that built this bridge could not account for. Their
|
||||
// contracts — and every cross-link touching them — are simply absent from
|
||||
// bridge.lbug, and nothing else in this walk can notice that: the only
|
||||
// incompleteness channel on the result is `truncationFields`, driven by
|
||||
// fan-out state. Without folding these in, a query about a symbol whose one
|
||||
// downstream consumer lives in an unreadable repo returns
|
||||
// `{ cross: [], truncated: false }` — "complete: nothing depends on this" —
|
||||
// which is a wrong answer, not an empty one, for a tool an agent uses to
|
||||
// license a delete or a rename.
|
||||
//
|
||||
// The metadata read that answers it (`bridgeProvenanceUnknown`) happens
|
||||
// INSIDE the `try` below, and the flag is initialized fail-closed here only
|
||||
// because it outlives that block. The lease taken by `ensureBridgeReady` is
|
||||
// released by the `finally` and nowhere else, so work done between the lease
|
||||
// and the `try` is work whose every throw leaks a refcount the cached handle
|
||||
// can never get back — which is how a malformed meta.json used to wedge the
|
||||
// handle as well as crash the query. (The repo lists are folded in after the
|
||||
// `finally`, where a throw can no longer strand the lease.)
|
||||
let provenanceUnknown = true;
|
||||
const cross: CrossRepoImpact[] = [];
|
||||
const outOfScope: OutOfScopeLink[] = [];
|
||||
const truncatedRepos: string[] = [];
|
||||
|
|
@ -650,6 +694,8 @@ export async function runGroupImpact(
|
|||
let fanoutTimedOut = false;
|
||||
|
||||
try {
|
||||
provenanceUnknown = bridgeProvenanceUnknown(bridgePrep.meta);
|
||||
|
||||
const neighbors = await resolveBridgeNeighbors(handle, {
|
||||
localRepo: repoPath,
|
||||
uids,
|
||||
|
|
@ -782,7 +828,45 @@ export async function runGroupImpact(
|
|||
const localSum = (local as { summary?: Record<string, number> })?.summary || {};
|
||||
const localRisk = String((local as { risk?: string }).risk ?? 'LOW');
|
||||
const localPartial = Boolean((local as { partial?: boolean }).partial);
|
||||
const truncated = truncatedRepos.length > 0 || localPartial;
|
||||
// The bridge's own incompleteness, in the shared vocabulary, read through
|
||||
// what this query DECLARED. The fan-out above already drops every neighbour
|
||||
// outside `subgroup`, so an incomplete repo the query excluded could not have
|
||||
// contributed a crossing to this answer — marking the answer a floor because
|
||||
// of it makes the marker fire on results it does not describe, which is how a
|
||||
// caller learns to ignore it. An unscoped query passes `subgroup: undefined`,
|
||||
// which `repoInSubgroup` answers true for, so the intersection is the whole
|
||||
// set and that path is byte-for-byte the old behaviour.
|
||||
//
|
||||
// The declared scope is the subgroup PLUS the query's own repo (`exact`
|
||||
// reuses the one membership helper for the equality, rather than growing a
|
||||
// second notion of it): the walk starts from `repoPath`'s contracts in the
|
||||
// bridge, so if THAT is the repo the sync could not read there are no
|
||||
// crossings to find for any scope, and a subgroup excluding it must not turn
|
||||
// that vacuum into a confident "complete".
|
||||
//
|
||||
// Declared scope, not traversed scope: an incomplete repo's contracts are
|
||||
// absent from the bridge by definition, so it is never in the set the walk
|
||||
// reached — filtering on what was traversed would empty the intersection on
|
||||
// every query and silently restore the fail-open.
|
||||
//
|
||||
// Sound only while `MAX_SUPPORTED_CROSS_DEPTH` is 1. At depth 2+ an
|
||||
// out-of-scope repo can sit BETWEEN two in-scope ones, so dropping it would
|
||||
// convert a genuine lower bound into a confident complete answer; widen this
|
||||
// predicate in the same change that raises the depth.
|
||||
const bridge = crossRepoCompleteness({
|
||||
unreadableRepos: bridgePrep.meta.unreadableRepos,
|
||||
missingRepos: bridgePrep.meta.missingRepos,
|
||||
provenanceUnknown,
|
||||
inScope: (candidate) =>
|
||||
repoInSubgroup(candidate, subgroup) || repoInSubgroup(candidate, repoPath, true),
|
||||
});
|
||||
// One predicate, read twice below. Written out at both sites, a third runtime
|
||||
// cause added to the flag and forgotten at the reason would label a
|
||||
// retry-able answer `incomplete-sync` — telling the operator to re-sync for
|
||||
// something a retry fixes. That reason-vs-flag drift is what `truncationFields`
|
||||
// exists to prevent.
|
||||
const runtimeTruncated = truncatedRepos.length > 0 || localPartial;
|
||||
const truncated = runtimeTruncated || bridge.truncated;
|
||||
|
||||
const result: GroupImpactResult = {
|
||||
local,
|
||||
|
|
@ -794,8 +878,17 @@ export async function runGroupImpact(
|
|||
// and under-reporting a blast radius is the unsafe direction (an agent told
|
||||
// LOW proceeds; told CRITICAL it stops). Marking the floor keeps the
|
||||
// warning intact while making the incompleteness legible.
|
||||
...truncationFields(truncated, fanoutTimedOut ? 'timeout' : 'partial'),
|
||||
truncatedRepos: [...new Set(truncatedRepos)],
|
||||
// Runtime limits first — they are what the caller can retry. 'incomplete-sync'
|
||||
// is the remaining cause once nothing was merely cut short, and its remedy is
|
||||
// a different one: re-run `gitnexus group sync`, not the query. Computed
|
||||
// inline because `truncationFields` reads the reason ONLY on the truncated
|
||||
// branch — naming it in a variable invited reading it on the complete path,
|
||||
// where it would say 'incomplete-sync' about a complete result.
|
||||
...truncationFields(
|
||||
truncated,
|
||||
fanoutTimedOut ? 'timeout' : runtimeTruncated ? 'partial' : 'incomplete-sync',
|
||||
),
|
||||
truncatedRepos: [...new Set([...truncatedRepos, ...bridge.incompleteRepos])],
|
||||
summary: {
|
||||
direct: localSum.direct ?? 0,
|
||||
processes_affected: localSum.processes_affected ?? 0,
|
||||
|
|
|
|||
|
|
@ -25,16 +25,29 @@
|
|||
|
||||
import { GroupNotFoundError, loadGroupConfig } from './config-parser.js';
|
||||
import { getGroupDir } from './storage.js';
|
||||
import { ensureBridgeReady, MAX_SUPPORTED_CROSS_DEPTH } from './cross-impact.js';
|
||||
import {
|
||||
bridgeProvenanceUnknown,
|
||||
crossRepoCompleteness,
|
||||
ensureBridgeReady,
|
||||
MAX_SUPPORTED_CROSS_DEPTH,
|
||||
} from './cross-impact.js';
|
||||
import type { CrossRepoCompleteness } from './completeness.js';
|
||||
import { truncationFields } from './completeness.js';
|
||||
import { compareCodeUnits } from '../../lib/utils.js';
|
||||
import { closeBridgeDb, queryBridge } from './bridge-db.js';
|
||||
import { repoInSubgroup } from './group-path-utils.js';
|
||||
import type {
|
||||
GroupPdgFlowHop,
|
||||
GroupRepoHandle,
|
||||
GroupSymbolResolution,
|
||||
GroupToolPort,
|
||||
} from './service.js';
|
||||
import type { BridgeHandle, GroupConfig } from './types.js';
|
||||
import type {
|
||||
BridgeHandle,
|
||||
BridgeMeta,
|
||||
GroupConfig,
|
||||
GroupImpactTruncationReason,
|
||||
} from './types.js';
|
||||
|
||||
// ── Result types (discriminated on `status`) ─────────────────────────────
|
||||
|
||||
|
|
@ -77,7 +90,29 @@ export interface GroupTraceEndpoint {
|
|||
repo: string;
|
||||
}
|
||||
|
||||
export interface GroupTraceOkResult {
|
||||
/**
|
||||
* The incompleteness vocabulary, verbatim from `GroupImpactResult` (KTD10).
|
||||
*
|
||||
* A cross-repo trace and a cross-repo impact can both be cut short by the same
|
||||
* two kinds of cause — a runtime limit inside this walk, or a bridge that never
|
||||
* held part of the group — and an agent must not have to learn a second
|
||||
* vocabulary (or parse a `notes` string) to tell "no path exists" from "we
|
||||
* could not have seen the path". Every field here means exactly what it means
|
||||
* on `GroupImpactResult`; `notes` stays a human-readable ADDITION to them,
|
||||
* never the machine-readable channel.
|
||||
*/
|
||||
export interface GroupTraceCompleteness {
|
||||
/** True when this answer is a floor rather than a verdict. */
|
||||
truncated?: boolean;
|
||||
/** Why, when `truncated` — runtime limit ('partial'/'timeout') before structure. */
|
||||
truncationReason?: GroupImpactTruncationReason;
|
||||
/** Set with `truncated`: the answer under-reports, it never over-reports. */
|
||||
riskEpistemic?: 'lower-bound';
|
||||
/** In-scope repos absent from the bridge; omitted when none were measured. */
|
||||
truncatedRepos?: string[];
|
||||
}
|
||||
|
||||
export interface GroupTraceOkResult extends GroupTraceCompleteness {
|
||||
status: 'ok';
|
||||
group: string;
|
||||
from: GroupTraceEndpoint;
|
||||
|
|
@ -89,7 +124,6 @@ export interface GroupTraceOkResult {
|
|||
edges: TraceEdge[];
|
||||
/** Present only when PDG enrichment ran for at least one segment. */
|
||||
dataFlow?: SegmentDataFlow[];
|
||||
truncated?: boolean;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
|
|
@ -101,23 +135,23 @@ export interface GroupTraceCandidate {
|
|||
startLine: number;
|
||||
}
|
||||
|
||||
export interface GroupTraceNotFoundResult {
|
||||
/**
|
||||
* `truncated: true` here means the answer is NOT authoritative — either the
|
||||
* crossing cap (`MAX_CROSSINGS_TO_TRY`) was hit so a connecting ContractLink
|
||||
* ranked beyond it may have been skipped, or the bridge itself never held part
|
||||
* of the group. Both read as "unknown", not as "no path exists";
|
||||
* `truncationReason` says which.
|
||||
*/
|
||||
export interface GroupTraceNotFoundResult extends GroupTraceCompleteness {
|
||||
status: 'not_found';
|
||||
group: string;
|
||||
role?: 'from' | 'to';
|
||||
query?: string;
|
||||
/**
|
||||
* True when the answer is NOT authoritative: the crossing cap
|
||||
* (`MAX_CROSSINGS_TO_TRY`) was hit, so a connecting ContractLink ranked beyond
|
||||
* the cap may have been skipped. A consumer should treat this as "unknown",
|
||||
* not "no path exists".
|
||||
*/
|
||||
truncated?: boolean;
|
||||
notes: string[];
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
export interface GroupTraceAmbiguousResult {
|
||||
export interface GroupTraceAmbiguousResult extends GroupTraceCompleteness {
|
||||
status: 'ambiguous';
|
||||
group: string;
|
||||
role: 'from' | 'to';
|
||||
|
|
@ -187,6 +221,54 @@ export const TRACE_NOTES = {
|
|||
'The candidates are listed; trace from the exact calling function or pass `to_uid`.',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Fold this bridge's completeness into the runtime-truncation flag a trace call
|
||||
* site already computed, and answer in the shared vocabulary.
|
||||
*
|
||||
* Precedence mirrors `runGroupImpact`: a runtime limit wins the reason, because
|
||||
* it is the cause the caller can act on (narrow the query, raise maxDepth),
|
||||
* while `'incomplete-sync'` needs a different remedy — `gitnexus group sync` —
|
||||
* and would otherwise mask it.
|
||||
*
|
||||
* Returns `{}` — not `{ truncated: false }` — when the answer is complete, so a
|
||||
* clean trace result keeps the exact shape it has always had.
|
||||
*/
|
||||
function traceCompleteness(
|
||||
bridge: CrossRepoCompleteness,
|
||||
runtimeTruncated: boolean,
|
||||
): GroupTraceCompleteness {
|
||||
const repos = bridge.incompleteRepos.length > 0 ? { truncatedRepos: bridge.incompleteRepos } : {};
|
||||
// Through `truncationFields`, not hand-written: `riskEpistemic` must follow
|
||||
// `truncated` mechanically, and a third writer of that pair is how the
|
||||
// invariant drifts (#2787). The bridge branch re-spreads the helper's own
|
||||
// output rather than naming its fields.
|
||||
if (runtimeTruncated) return { ...truncationFields(true, 'partial'), ...repos };
|
||||
if (!bridge.truncated) return {};
|
||||
const { incompleteRepos: _incompleteRepos, ...fields } = bridge;
|
||||
return { ...fields, ...repos };
|
||||
}
|
||||
|
||||
/**
|
||||
* The trace's declared scope for `crossRepoCompleteness`.
|
||||
*
|
||||
* A symbol-to-symbol trace asks about exactly two repos, so an unreadable third
|
||||
* member cannot make its answer a floor. A DESTINATION trace declares no `to`
|
||||
* at all — the call may land in any member — so every repo is in scope there,
|
||||
* which is why the predicate is built per call site rather than derived from
|
||||
* the endpoints inside the helper.
|
||||
*/
|
||||
function bridgeCompletenessFor(
|
||||
meta: BridgeMeta,
|
||||
inScope: (repoPath: string) => boolean,
|
||||
): CrossRepoCompleteness {
|
||||
return crossRepoCompleteness({
|
||||
unreadableRepos: meta.unreadableRepos,
|
||||
missingRepos: meta.missingRepos,
|
||||
provenanceUnknown: bridgeProvenanceUnknown(meta),
|
||||
inScope,
|
||||
});
|
||||
}
|
||||
|
||||
/** Repo-relative path equality, tolerant of a leading "./" / "/" or a repo prefix. */
|
||||
function sameFile(a: string, b: string): boolean {
|
||||
if (!a || !b) return false;
|
||||
|
|
@ -873,6 +955,23 @@ async function stitchCrossRepo(
|
|||
if (p.pdg) notes.push(TRACE_NOTES.pdgRequested);
|
||||
|
||||
try {
|
||||
// Inside the `try`, like `runGroupImpact`'s equivalent: the lease taken by
|
||||
// `ensureBridgeReady` is released by this block's `finally` and nowhere
|
||||
// else, so anything computed between the lease and the `try` is work whose
|
||||
// every throw would strand a refcount the cached handle never gets back.
|
||||
//
|
||||
// Declared scope = the two endpoint repos. Whether either of them is a repo
|
||||
// this bridge could not read decides whether "no ContractLink connects
|
||||
// them" is a verdict or a floor.
|
||||
const bridge = bridgeCompletenessFor(
|
||||
bridgePrep.meta,
|
||||
// `repoInSubgroup(..., exact)` rather than `===`: it normalizes separators
|
||||
// and strips trailing slashes, which bare equality does not, so the same
|
||||
// group.yaml spelling cannot be in scope for impact and out of scope here.
|
||||
(repoPath) =>
|
||||
repoInSubgroup(repoPath, fromEp.member.repoPath, true) ||
|
||||
repoInSubgroup(repoPath, toEp.member.repoPath, true),
|
||||
);
|
||||
const { crossings, truncated: crossingsTruncated } = await listCrossingsBetween(
|
||||
handle,
|
||||
fromEp.member.repoPath,
|
||||
|
|
@ -883,6 +982,10 @@ async function stitchCrossRepo(
|
|||
return {
|
||||
status: 'not_found',
|
||||
group: p.name,
|
||||
// No crossings at all is exactly the answer a bridge that never held an
|
||||
// endpoint's repo produces, so it is the one that most needs the floor
|
||||
// marker. (Nothing was capped: there were zero rows to cap.)
|
||||
...traceCompleteness(bridge, false),
|
||||
notes,
|
||||
suggestion:
|
||||
'The endpoints live in different repos with no ContractLink between them. ' +
|
||||
|
|
@ -1016,6 +1119,13 @@ async function stitchCrossRepo(
|
|||
hopCount: edges.length,
|
||||
hops: [...hopsA, ...hopsB],
|
||||
edges,
|
||||
// A found path is still an answer from this bridge: if its provenance is
|
||||
// unknown, or an endpoint's repo never made it in, the path may be stale
|
||||
// and it is certainly not the only one. An incompleteness channel that
|
||||
// fires only on the empty answer teaches an agent that a non-empty one
|
||||
// is always complete. The crossing cap is NOT folded in here — a path
|
||||
// that connected is not a capped search — so this site passes `false`.
|
||||
...traceCompleteness(bridge, false),
|
||||
notes,
|
||||
...(dataFlow.length > 0 ? { dataFlow } : {}),
|
||||
};
|
||||
|
|
@ -1028,7 +1138,7 @@ async function stitchCrossRepo(
|
|||
return {
|
||||
status: 'not_found',
|
||||
group: p.name,
|
||||
...(crossingsTruncated ? { truncated: true } : {}),
|
||||
...traceCompleteness(bridge, crossingsTruncated),
|
||||
notes,
|
||||
suggestion: crossingsTruncated
|
||||
? `No connecting crossing among the ${MAX_CROSSINGS_TO_TRY} highest-confidence ` +
|
||||
|
|
@ -1099,6 +1209,12 @@ async function stitchToDestination(
|
|||
if (p.crossDepthClamped) notes.push(TRACE_NOTES.crossDepthClamped);
|
||||
|
||||
try {
|
||||
// Inside the `try` for the lease reason above `stitchCrossRepo`'s copy. A
|
||||
// destination trace declares NO `to`: the call may land in any member, so
|
||||
// every repo is in the query's scope and no incomplete one can be filtered
|
||||
// out. An unreadable provider repo is precisely how "no outgoing
|
||||
// ContractLink leaves this repo" becomes a wrong answer, not an empty one.
|
||||
const bridge = bridgeCompletenessFor(bridgePrep.meta, () => true);
|
||||
const { crossings, truncated } = await listCrossingsFrom(handle, fromEp.member.repoPath);
|
||||
if (crossings.length === 0) {
|
||||
notes.push(TRACE_NOTES.destinationNoLink);
|
||||
|
|
@ -1107,6 +1223,8 @@ async function stitchToDestination(
|
|||
group: p.name,
|
||||
role: 'to',
|
||||
query: p.from_uid ?? p.from,
|
||||
// Zero rows to cap, so only the bridge's own completeness can speak.
|
||||
...traceCompleteness(bridge, false),
|
||||
notes,
|
||||
suggestion: 'Pass a `to` symbol for a symbol-to-symbol trace, or run group_sync.',
|
||||
};
|
||||
|
|
@ -1224,7 +1342,9 @@ async function stitchToDestination(
|
|||
hopCount: edgesA.length + 1,
|
||||
hops: [...hopsA, providerHop],
|
||||
edges: [...edgesA, boundaryEdge],
|
||||
...(truncated ? { truncated: true } : {}),
|
||||
// The cap already marked this result; the bridge's completeness folds
|
||||
// into the same fields rather than beside them.
|
||||
...traceCompleteness(bridge, truncated),
|
||||
notes: resultNotes,
|
||||
};
|
||||
};
|
||||
|
|
@ -1240,6 +1360,8 @@ async function stitchToDestination(
|
|||
group: p.name,
|
||||
role: 'to',
|
||||
candidates: candidatesFrom(precise),
|
||||
// The candidate LIST is what an incomplete bridge shortens here.
|
||||
...traceCompleteness(bridge, truncated),
|
||||
notes: [...notes, TRACE_NOTES.destinationMultiple],
|
||||
};
|
||||
}
|
||||
|
|
@ -1255,6 +1377,7 @@ async function stitchToDestination(
|
|||
group: p.name,
|
||||
role: 'to',
|
||||
candidates: candidatesFrom(fileLevel),
|
||||
...traceCompleteness(bridge, truncated),
|
||||
notes: [...notes, TRACE_NOTES.destinationAmbiguousFile],
|
||||
};
|
||||
}
|
||||
|
|
@ -1265,7 +1388,7 @@ async function stitchToDestination(
|
|||
group: p.name,
|
||||
role: 'to',
|
||||
query: p.from_uid ?? p.from,
|
||||
...(truncated ? { truncated: true } : {}),
|
||||
...traceCompleteness(bridge, truncated),
|
||||
notes,
|
||||
suggestion: 'Trace from the function that issues the HTTP request, or pass a `to` symbol.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -28,6 +28,13 @@ import {
|
|||
REQUEST_LINE_CONFIDENCE,
|
||||
EXCHANGE_CONFIDENCE,
|
||||
} from './spring-consumer-shared.js';
|
||||
import {
|
||||
extractJavaModuleConstants,
|
||||
foldJavaOperands,
|
||||
isJavaConstantFile,
|
||||
parseJavaConstOperands,
|
||||
type RepoConstants,
|
||||
} from '../../../ingestion/route-extractors/java-const-resolver.js';
|
||||
import {
|
||||
extractStaticPathExpression,
|
||||
inferOkHttpMethod,
|
||||
|
|
@ -165,6 +172,34 @@ const JAVA_ROUTE_ANNOTATION_PATTERNS = compilePatterns({
|
|||
key: (identifier) @key
|
||||
value: [(string_literal) @value (element_value_array_initializer (string_literal) @value)]))))
|
||||
name: (identifier) @member) @node
|
||||
(class_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: [(identifier) (scoped_identifier)] @ann
|
||||
arguments: (annotation_argument_list [(identifier) @value_expr (field_access) @value_expr (binary_expression) @value_expr])))) @node
|
||||
(class_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: [(identifier) (scoped_identifier)] @ann
|
||||
arguments: (annotation_argument_list
|
||||
(element_value_pair
|
||||
key: (identifier) @key
|
||||
value: [(identifier) @value_expr (field_access) @value_expr (binary_expression) @value_expr]))))) @node
|
||||
(method_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: [(identifier) (scoped_identifier)] @ann
|
||||
arguments: (annotation_argument_list [(identifier) @value_expr (field_access) @value_expr (binary_expression) @value_expr])))
|
||||
name: (identifier) @member) @node
|
||||
(method_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: [(identifier) (scoped_identifier)] @ann
|
||||
arguments: (annotation_argument_list
|
||||
(element_value_pair
|
||||
key: (identifier) @key
|
||||
value: [(identifier) @value_expr (field_access) @value_expr (binary_expression) @value_expr]))))
|
||||
name: (identifier) @member) @node
|
||||
]
|
||||
`,
|
||||
},
|
||||
|
|
@ -469,6 +504,12 @@ interface MethodRouteAnnotation {
|
|||
rawPath: string;
|
||||
/** OpenFeign's single effective verb; null means its contract is invalid/ambiguous. */
|
||||
feignHttpMethod?: string | null;
|
||||
/**
|
||||
* Non-literal path operands (constant ref or `+`-concat), captured when the
|
||||
* annotation value is not a string literal. Resolved against the repo-wide
|
||||
* Java constant map in scan(); a failed fold drops the route (skip floor).
|
||||
*/
|
||||
pathOperands?: readonly import('../../../ingestion/route-extractors/constant-resolver.js').Operand[];
|
||||
}
|
||||
|
||||
interface RequestLineAnnotation {
|
||||
|
|
@ -484,6 +525,16 @@ interface RouteAnnotationScan {
|
|||
feignPrefixByInterfaceId: Map<number, string[]>;
|
||||
/** Spring HTTP Interface `@HttpExchange(url|value)` type-level prefixes per class/interface node id. */
|
||||
httpExchangePrefixByTypeId: Map<number, string[]>;
|
||||
/**
|
||||
* Class node ids whose `@RequestMapping` prefix is a constant reference or
|
||||
* concat rather than a literal. Folding a TYPE-level prefix would need the
|
||||
* repo constant map inside `scanRouteAnnotations`, which has no access to it,
|
||||
* so `scan()` suppresses every method route under such a class instead of
|
||||
* emitting it with the prefix silently dropped (a wrong path, not a missing
|
||||
* one). Ingestion's `extractSpringRoutes` applies the identical rule — R4
|
||||
* parity.
|
||||
*/
|
||||
typesWithUnfoldablePrefix: Set<number>;
|
||||
/** Resolved Spring shortcut/`@RequestMapping` routes — paths × verbs yield one entry each. */
|
||||
methodRoutes: MethodRouteAnnotation[];
|
||||
/** One entry per OpenFeign `@RequestLine` whose value parses to a verb + path. */
|
||||
|
|
@ -511,6 +562,7 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
|
|||
// feeds the OpenFeign *consumer* path in scan(). An interface carrying both
|
||||
// `@RequestMapping` and `@FeignClient(path)` lands a different value in each.
|
||||
const prefixByTypeId = new Map<number, string[]>();
|
||||
const typesWithUnfoldablePrefix = new Set<number>();
|
||||
const feignPrefixByInterfaceId = new Map<number, string[]>();
|
||||
const httpExchangePrefixByTypeId = new Map<number, string[]>();
|
||||
const methodRoutes: MethodRouteAnnotation[] = [];
|
||||
|
|
@ -527,7 +579,10 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
|
|||
const annNode = captures.ann;
|
||||
const node = captures.node;
|
||||
const valueNode = captures.value;
|
||||
if (!annNode || !node || !valueNode) continue;
|
||||
// A non-literal annotation value (constant ref / `+`-concat) is captured
|
||||
// as @value_expr instead of @value — one of the two must be present.
|
||||
const valueExprNode = captures.value_expr;
|
||||
if (!annNode || !node || (!valueNode && !valueExprNode)) continue;
|
||||
// Discrimination is on the trailing segment only (`simpleName`), so a
|
||||
// non-Spring annotation whose last segment collides with a route annotation
|
||||
// (e.g. `@com.evil.GetMapping("/x")`) is treated as a route. This is the
|
||||
|
|
@ -550,7 +605,7 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
|
|||
const feignHttpMethod =
|
||||
httpMethods.length === 1 ? (httpMethods[0] === '*' ? 'GET' : httpMethods[0]) : null;
|
||||
if (!isRouteMemberKey(keyNode)) continue;
|
||||
const rawPath = unquoteLiteral(valueNode.text);
|
||||
const rawPath = valueNode ? unquoteLiteral(valueNode.text) : null;
|
||||
if (rawPath !== null) {
|
||||
for (const httpMethod of httpMethods) {
|
||||
methodRoutes.push({
|
||||
|
|
@ -561,10 +616,33 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
|
|||
feignHttpMethod,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Non-literal path (a constant reference or `+`-concatenation).
|
||||
// Defer to scan(): the fold needs the repo-wide constant map built
|
||||
// by prepareRepo. Capture the operand list now; resolution happens
|
||||
// in scan() against JavaRepoContext, and an unresolvable operand
|
||||
// list leaves the route skipped (KTD5 skip floor).
|
||||
const operands = parseJavaConstOperands(valueExprNode);
|
||||
if (operands !== null) {
|
||||
for (const httpMethod of httpMethods) {
|
||||
methodRoutes.push({
|
||||
methodNode: node,
|
||||
methodName: captures.member?.text ?? null,
|
||||
httpMethod,
|
||||
rawPath: '',
|
||||
feignHttpMethod,
|
||||
pathOperands: operands,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (ann === 'RequestLine') {
|
||||
// Feign packs verb + path in one literal; its only named argument is `value`.
|
||||
if (keyNode && keyNode.text !== 'value') continue;
|
||||
// A constant-valued `@RequestLine` arrives as @value_expr, not @value —
|
||||
// `valueNode` is undefined in that shape. Skip rather than dereference
|
||||
// (constant folding for Feign verb+path literals is out of scope here).
|
||||
if (!valueNode) continue;
|
||||
const raw = unquoteLiteral(valueNode.text);
|
||||
const parsed = raw !== null ? parseRequestLine(raw) : null;
|
||||
if (parsed) {
|
||||
|
|
@ -579,7 +657,7 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
|
|||
// `url` or `value` attribute (or positionally); other attributes
|
||||
// (`accept`, `contentType`, …) are not routes.
|
||||
if (keyNode && keyNode.text !== 'url' && keyNode.text !== 'value') continue;
|
||||
const rawPath = unquoteLiteral(valueNode.text);
|
||||
const rawPath = valueNode ? unquoteLiteral(valueNode.text) : null;
|
||||
if (rawPath !== null) {
|
||||
exchangeRoutes.push({
|
||||
methodNode: node,
|
||||
|
|
@ -596,6 +674,11 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
|
|||
// — on an interface — an OpenFeign `@FeignClient(path = "...")` prefix.
|
||||
if (ann === 'RequestMapping') {
|
||||
if (!isRouteMemberKey(keyNode)) continue;
|
||||
if (!valueNode) {
|
||||
// Constant-valued class prefix — see `typesWithUnfoldablePrefix`.
|
||||
typesWithUnfoldablePrefix.add(node.id);
|
||||
continue;
|
||||
}
|
||||
const prefix = unquoteLiteral(valueNode.text);
|
||||
if (prefix !== null) {
|
||||
pushPrefix(prefixByTypeId, node.id, prefix);
|
||||
|
|
@ -606,13 +689,13 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
|
|||
} else if (ann === 'FeignClient' && node.type === 'interface_declaration') {
|
||||
// Feign's `name`/`value` identify a service, not a path — only `path` is a prefix.
|
||||
if (!keyNode || keyNode.text !== 'path') continue;
|
||||
const prefix = unquoteLiteral(valueNode.text);
|
||||
const prefix = valueNode ? unquoteLiteral(valueNode.text) : null;
|
||||
if (prefix !== null) pushPrefix(feignPrefixByInterfaceId, node.id, prefix);
|
||||
} else if (ann === 'HttpExchange') {
|
||||
// Spring HTTP Interface type-level prefix: the path lives in `url`/`value`
|
||||
// (or positionally). Applies to its `@(Get|...)Exchange` consumer methods.
|
||||
if (keyNode && keyNode.text !== 'url' && keyNode.text !== 'value') continue;
|
||||
const prefix = unquoteLiteral(valueNode.text);
|
||||
const prefix = valueNode ? unquoteLiteral(valueNode.text) : null;
|
||||
if (prefix !== null) pushPrefix(httpExchangePrefixByTypeId, node.id, prefix);
|
||||
}
|
||||
}
|
||||
|
|
@ -662,6 +745,7 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
|
|||
|
||||
return {
|
||||
prefixByTypeId,
|
||||
typesWithUnfoldablePrefix,
|
||||
feignPrefixByInterfaceId,
|
||||
httpExchangePrefixByTypeId,
|
||||
methodRoutes: constrainedMethodRoutes,
|
||||
|
|
@ -707,9 +791,20 @@ function collectImplementedInterfaces(typeNode: Parser.SyntaxNode): string[] {
|
|||
}
|
||||
|
||||
function collectSpringTypes(filePath: string, tree: Parser.Tree): SharedSpringType[] {
|
||||
const { prefixByTypeId, methodRoutes } = scanRouteAnnotations(tree);
|
||||
const { prefixByTypeId, typesWithUnfoldablePrefix, methodRoutes } = scanRouteAnnotations(tree);
|
||||
const routesByMethodId = new Map<number, Array<{ method: string; path: string }>>();
|
||||
for (const route of methodRoutes) {
|
||||
// Constant-valued class prefix: no single prefix string exists here, so the
|
||||
// inheritance view would publish this route unprefixed. Skip — same rule as
|
||||
// scan() and as ingestion (R4 parity).
|
||||
const owner = findEnclosingClass(route.methodNode);
|
||||
if (owner && typesWithUnfoldablePrefix.has(owner.id)) continue;
|
||||
// A constant-referencing route still carries `rawPath: ''` here — folding
|
||||
// happens in scan() against the repo constant map, which this
|
||||
// inheritance-view collector has no access to. Emitting it as an empty
|
||||
// path would publish `POST /`-shaped noise into the shared type view;
|
||||
// skip instead (ingestion keeps the same skip floor — R4 parity).
|
||||
if (route.pathOperands) continue;
|
||||
const routes = routesByMethodId.get(route.methodNode.id) ?? [];
|
||||
routes.push({ method: route.httpMethod, path: route.rawPath });
|
||||
routesByMethodId.set(route.methodNode.id, routes);
|
||||
|
|
@ -781,8 +876,62 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
|||
content,
|
||||
);
|
||||
},
|
||||
scan(tree) {
|
||||
prepareRepo(args) {
|
||||
// Build the repo-wide Java string-constant map once per extract() run
|
||||
// (mirrors the Python binding's cost-gated pre-pass). A cheap content
|
||||
// gate keeps literal-only repos at zero parses: only files containing a
|
||||
// `static final String` declaration are parsed for constants.
|
||||
try {
|
||||
// The orchestrator hands over a bare Parser (no language set yet);
|
||||
// bind Java explicitly — Python's prepareRepo does the same — otherwise
|
||||
// parseSourceSafe spins to its 15 s budget per file.
|
||||
args.parser.setLanguage(Java);
|
||||
} catch {
|
||||
// fall through: a parser that rejects binding cannot produce a constant
|
||||
// map; per-file try/catch below then skips everything harmlessly.
|
||||
}
|
||||
const constants = new Map<
|
||||
string,
|
||||
import('../../../ingestion/route-extractors/constant-resolver.js').ModuleConstants
|
||||
>();
|
||||
for (const rel of args.files) {
|
||||
if (!rel.endsWith('.java')) continue;
|
||||
try {
|
||||
const src = args.readFile(rel);
|
||||
// Cheap content gate: only constant-DEFINITION candidates get parsed
|
||||
// here (~hundreds of files). Import-only files (every controller)
|
||||
// are deliberately NOT parsed in this pass — scan() lazily extracts
|
||||
// the importing file's own import table from the tree it already
|
||||
// holds when a constant-referencing route actually needs the fold.
|
||||
// A gate that also matched `import ...;` would parse the entire
|
||||
// repository here (tens of thousands of files) just to build import
|
||||
// tables the fold can derive per-file on demand.
|
||||
//
|
||||
// The predicate is the SHARED one the ingestion provider uses, so the
|
||||
// two subsystems agree on which files define constants. Its previous
|
||||
// local spelling missed `final static String` and lowercase interface
|
||||
// names, and admitted an interface that ingestion's gate rejected.
|
||||
if (!src || !isJavaConstantFile(src)) {
|
||||
continue;
|
||||
}
|
||||
const tree = args.parseSource(args.parser, src);
|
||||
if (!tree) continue;
|
||||
const mc = extractJavaModuleConstants(tree);
|
||||
if (mc.literals.size > 0 || mc.exprs.size > 0 || mc.imports.size > 0) {
|
||||
constants.set(rel, mc);
|
||||
}
|
||||
} catch {
|
||||
// Per-file resilience: one unreadable/oversized/ill-formed file must
|
||||
// not forfeit the whole repo's constant map (a missing constants
|
||||
// class only degrades refs that pointed at it).
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return { constants };
|
||||
},
|
||||
scan(tree, repoContext, fileRel) {
|
||||
const out: HttpDetection[] = [];
|
||||
const javaCtx = repoContext as { constants: RepoConstants } | undefined;
|
||||
|
||||
// ─── Spring providers + OpenFeign consumers (one query pass) ────
|
||||
// `scanRouteAnnotations` resolves every route-defining annotation —
|
||||
|
|
@ -790,6 +939,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
|||
// `@RequestLine`s — from a single `matches()` pass over the tree.
|
||||
const {
|
||||
prefixByTypeId,
|
||||
typesWithUnfoldablePrefix,
|
||||
feignPrefixByInterfaceId,
|
||||
httpExchangePrefixByTypeId,
|
||||
methodRoutes,
|
||||
|
|
@ -802,7 +952,48 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
|||
// class is a Spring *provider*. A mapping on a non-Feign interface has no
|
||||
// enclosing class and is dropped here — interface→controller inheritance is
|
||||
// handled by `scanProject`.
|
||||
// Lazy per-file constants view. prepareRepo only indexes constant-
|
||||
// DEFINING files (cheap gate); an importing controller is absent from
|
||||
// that map. When a route actually references a constant, extract THIS
|
||||
// file's import table from the tree scan() already holds (zero extra
|
||||
// parses) and overlay it for the fold. Files whose routes are all
|
||||
// literal — the overwhelming majority — never pay this cost.
|
||||
let foldConstants: RepoConstants | undefined;
|
||||
const getFoldConstants = (): RepoConstants | undefined => {
|
||||
if (foldConstants !== undefined) return foldConstants;
|
||||
foldConstants = javaCtx?.constants;
|
||||
if (!javaCtx?.constants || !fileRel) return foldConstants;
|
||||
if (javaCtx.constants.has(fileRel)) return foldConstants;
|
||||
try {
|
||||
const mc = extractJavaModuleConstants(tree);
|
||||
if (mc.imports.size > 0) {
|
||||
const merged = new Map(javaCtx.constants);
|
||||
merged.set(fileRel, mc);
|
||||
foldConstants = merged;
|
||||
}
|
||||
} catch {
|
||||
// fold falls back to the repo-wide map (imports stay unresolved)
|
||||
}
|
||||
return foldConstants;
|
||||
};
|
||||
|
||||
for (const route of methodRoutes) {
|
||||
// A constant-valued CLASS prefix cannot be folded here, so every method
|
||||
// route under such a class is suppressed rather than emitted at a wrong
|
||||
// (unprefixed) path — the same rule `classesWithArrayPrefix` already
|
||||
// encodes for the array form, and the same rule ingestion applies.
|
||||
const owner = findEnclosingClass(route.methodNode);
|
||||
if (owner && typesWithUnfoldablePrefix.has(owner.id)) continue;
|
||||
// Non-literal route path: fold the operand list against the repo-wide
|
||||
// constant map. Skip (never a guessed path) when the fold fails or the
|
||||
// repo context is absent (context-less fallback scanning).
|
||||
if (route.pathOperands && javaCtx && fileRel) {
|
||||
const resolved = foldJavaOperands(fileRel, route.pathOperands, getFoldConstants()!);
|
||||
if (resolved === null) continue;
|
||||
route.rawPath = resolved;
|
||||
} else if (route.pathOperands) {
|
||||
continue;
|
||||
}
|
||||
const enclosingInterface = findEnclosingInterface(route.methodNode);
|
||||
if (enclosingInterface && hasAnnotation(enclosingInterface, 'FeignClient')) {
|
||||
if (!route.feignHttpMethod) continue;
|
||||
|
|
|
|||
|
|
@ -9,11 +9,21 @@ import {
|
|||
type LanguagePatterns,
|
||||
type PatternSpec,
|
||||
} from '../tree-sitter-scanner.js';
|
||||
import type { HttpDetection, HttpLanguagePlugin } from './types.js';
|
||||
import type { HttpDetection, HttpLanguagePlugin, RepoContext } from './types.js';
|
||||
import { MAX_FOLD_LENGTH } from '../../../ingestion/route-extractors/constant-resolver.js';
|
||||
import {
|
||||
DATA_ROUTE_TABLE_SOURCE,
|
||||
scanDataRouteTables,
|
||||
} from '../../../ingestion/route-extractors/data-route-table.js';
|
||||
import {
|
||||
buildJsRepoFacts,
|
||||
extractJsModuleFacts,
|
||||
isAxiosNamespace,
|
||||
isHttpClientRef,
|
||||
resolveJsPathExpression,
|
||||
type JsModuleFacts,
|
||||
type JsRepoFacts,
|
||||
} from '../../../ingestion/route-extractors/js-const-resolver.js';
|
||||
|
||||
/**
|
||||
* Node.js / TypeScript HTTP plugin family. Handles:
|
||||
|
|
@ -98,15 +108,28 @@ const FETCH_WITH_OPTIONS_SPEC: PatternSpec<Record<string, never>> = {
|
|||
`,
|
||||
};
|
||||
|
||||
// ─── Consumer: axios.get/post/... ────────────────────────────────────
|
||||
const AXIOS_SPEC: PatternSpec<Record<string, never>> = {
|
||||
// ─── Consumer: <httpClient>.get/post/... ─────────────────────────────
|
||||
// Widened from a literal `axios` receiver with a literal path. Application
|
||||
// code satisfies neither: it calls through a configured instance
|
||||
// (`const api = axios.create({ baseURL })`, imported at the call site under
|
||||
// whatever name the app chose) and passes the path by reference from a shared
|
||||
// route table (`api.get(API_ROUTE_PATH.LINKS)`). The query therefore matches
|
||||
// ANY identifier receiver with an HTTP-verb method and ANY first argument;
|
||||
// `scanBundle` admits a match only after PROVING the receiver is an axios
|
||||
// instance and resolving the argument to a path.
|
||||
//
|
||||
// The proof gate is load-bearing, not belt-and-braces: EXPRESS_SPEC above
|
||||
// matches `router.get('/x', handler)` / `app.post(...)` as PROVIDERS. A
|
||||
// receiver admitted on spelling alone would re-emit every Express route in the
|
||||
// repo as a consumer of itself, on both sides of every cross-repo pair.
|
||||
const HTTP_CLIENT_SPEC: PatternSpec<Record<string, never>> = {
|
||||
meta: {},
|
||||
query: `
|
||||
(call_expression
|
||||
function: (member_expression
|
||||
object: (identifier) @obj (#eq? @obj "axios")
|
||||
object: (identifier) @obj
|
||||
property: (property_identifier) @http_method (#match? @http_method "^(get|post|put|delete|patch)$"))
|
||||
arguments: (arguments . [(string) (template_string)] @path))
|
||||
arguments: (arguments . (_) @path))
|
||||
`,
|
||||
};
|
||||
|
||||
|
|
@ -158,7 +181,7 @@ interface NodePatternBundle {
|
|||
express: CompiledPatterns<Record<string, never>>;
|
||||
fetchNoOptions: CompiledPatterns<Record<string, never>>;
|
||||
fetchWithOptions: CompiledPatterns<Record<string, never>>;
|
||||
axios: CompiledPatterns<Record<string, never>>;
|
||||
httpClient: CompiledPatterns<Record<string, never>>;
|
||||
jqueryShorthand: CompiledPatterns<Record<string, never>>;
|
||||
jqueryAjax: CompiledPatterns<Record<string, never>>;
|
||||
axiosObject: CompiledPatterns<Record<string, never>>;
|
||||
|
|
@ -177,7 +200,7 @@ function compileBundle(language: unknown, name: string): NodePatternBundle {
|
|||
express: mk(EXPRESS_SPEC, 'express'),
|
||||
fetchNoOptions: mk(FETCH_NO_OPTIONS_SPEC, 'fetch-no-options'),
|
||||
fetchWithOptions: mk(FETCH_WITH_OPTIONS_SPEC, 'fetch-with-options'),
|
||||
axios: mk(AXIOS_SPEC, 'axios'),
|
||||
httpClient: mk(HTTP_CLIENT_SPEC, 'http-client'),
|
||||
jqueryShorthand: mk(JQUERY_SHORTHAND_SPEC, 'jquery-shorthand'),
|
||||
jqueryAjax: mk(JQUERY_AJAX_SPEC, 'jquery-ajax'),
|
||||
axiosObject: mk(AXIOS_OBJECT_SPEC, 'axios-object'),
|
||||
|
|
@ -309,12 +332,22 @@ function findDecoratedMethod(decoratorNode: Parser.SyntaxNode): Parser.SyntaxNod
|
|||
*/
|
||||
function buildImportMap(tree: Parser.Tree): Map<string, { name: string; module: string }> {
|
||||
const map = new Map<string, { name: string; module: string }>();
|
||||
const walk = (node: Parser.SyntaxNode): void => {
|
||||
// Both walks are explicit-stack, not recursive. They visit EVERY node of the
|
||||
// file, so their depth is the source's nesting depth — and `scan` may not
|
||||
// throw: a `RangeError` here escapes to `sync.ts`, which records the repo as
|
||||
// an unexplained "missing repo" and drops every contract of every kind for
|
||||
// it, silently. A file nesting template substitutions ~4 000 deep (well
|
||||
// inside what tree-sitter will parse) was enough.
|
||||
const stack: Parser.SyntaxNode[] = [tree.rootNode];
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop() as Parser.SyntaxNode;
|
||||
if (node.type === 'import_statement') {
|
||||
const sourceNode = node.childForFieldName('source');
|
||||
const module = sourceNode ? unquoteLiteral(sourceNode.text) : null;
|
||||
if (module !== null) {
|
||||
const collect = (n: Parser.SyntaxNode): void => {
|
||||
const inner: Parser.SyntaxNode[] = [node];
|
||||
while (inner.length > 0) {
|
||||
const n = inner.pop() as Parser.SyntaxNode;
|
||||
if (n.type === 'import_specifier') {
|
||||
const nameNode = n.childForFieldName('name');
|
||||
const aliasNode = n.childForFieldName('alias');
|
||||
|
|
@ -323,25 +356,234 @@ function buildImportMap(tree: Parser.Tree): Map<string, { name: string; module:
|
|||
map.set(local.text, { name: nameNode.text, module });
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < n.namedChildCount; i++) {
|
||||
const c = n.namedChild(i);
|
||||
if (c) collect(c);
|
||||
}
|
||||
};
|
||||
collect(node);
|
||||
for (const c of n.namedChildren) inner.push(c);
|
||||
}
|
||||
}
|
||||
// An import statement cannot contain another one, and the inner loop has
|
||||
// already visited its whole subtree.
|
||||
continue;
|
||||
}
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const c = node.namedChild(i);
|
||||
if (c) walk(c);
|
||||
}
|
||||
};
|
||||
walk(tree.rootNode);
|
||||
for (const c of node.namedChildren) stack.push(c);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection[] {
|
||||
// ─── Repo pre-pass: cross-file route tables + HTTP client instances ──
|
||||
//
|
||||
// Both halves of a real consumer call live in files OTHER than the call site:
|
||||
// the client is created in `lib/axios.config.ts` and the path in
|
||||
// `shared/api-routes.ts`. A per-file scan cannot see either, which is why the
|
||||
// literal-only patterns matched almost nothing on application code. The
|
||||
// pre-pass builds a repo-wide fact map once so `scan` can resolve both.
|
||||
|
||||
interface NodeRepoContext {
|
||||
readonly facts: JsRepoFacts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared across the three JS/TS plugins.
|
||||
*
|
||||
* JS, TS and TSX are distinct `HttpLanguagePlugin` objects, and the
|
||||
* orchestrator caches `prepareRepo` output per plugin NAME — so a polyglot
|
||||
* frontend would build this identical map three times. All three are called
|
||||
* with the same memoized file-list array within one extraction run, so keying
|
||||
* on that array's identity collapses the work to a single pass. A later run
|
||||
* passes a different array and correctly rebuilds; the weak key lets the old
|
||||
* map be collected with it.
|
||||
*/
|
||||
const REPO_CONTEXT_BY_FILE_LIST = new WeakMap<readonly string[], NodeRepoContext>();
|
||||
|
||||
/**
|
||||
* Skip ceiling for the pre-pass, mirroring the analyzer's default
|
||||
* `--max-file-size`. A minified bundle is megabytes on one line and defines no
|
||||
* route table a human wrote; parsing it costs far more than it can return.
|
||||
*/
|
||||
const MAX_PREPASS_FILE_BYTES = 512 * 1024;
|
||||
|
||||
/** Repo-relative path in the same POSIX form the fact map is keyed by. */
|
||||
function normalizeRel(rel: string): string {
|
||||
return rel.replace(/\\/g, '/').replace(/^\.\//, '');
|
||||
}
|
||||
|
||||
/** The grammar a JS/TS-family file should be parsed with, or null if not one. */
|
||||
function grammarForFile(rel: string): unknown | null {
|
||||
const lower = rel.toLowerCase();
|
||||
if (lower.endsWith('.tsx')) return TypeScript.tsx;
|
||||
if (/\.[cm]?ts$/.test(lower)) return TypeScript.typescript;
|
||||
if (/\.[cm]?jsx?$/.test(lower)) return JavaScript;
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildNodeRepoContext(args: {
|
||||
files: string[];
|
||||
readFile: (rel: string) => string | null;
|
||||
parseSource: (parser: Parser, src: string) => Parser.Tree | null;
|
||||
}): NodeRepoContext {
|
||||
const cached = REPO_CONTEXT_BY_FILE_LIST.get(args.files);
|
||||
if (cached) return cached;
|
||||
|
||||
const byFile = new Map<string, JsModuleFacts>();
|
||||
const parsers = new Map<unknown, Parser>();
|
||||
const parserFor = (language: unknown): Parser => {
|
||||
let parser = parsers.get(language);
|
||||
if (!parser) {
|
||||
parser = new Parser();
|
||||
parser.setLanguage(language as Parameters<Parser['setLanguage']>[0]);
|
||||
parsers.set(language, parser);
|
||||
}
|
||||
return parser;
|
||||
};
|
||||
|
||||
// Cost gate, in the spirit of the sibling `python.ts` pre-pass: every fact
|
||||
// this map holds exists to prove a receiver is an axios instance or to fold a
|
||||
// path for one. A repo where the string `axios` appears nowhere can prove no
|
||||
// receiver, so every parse below is dead work — and parsing is the expensive
|
||||
// half (measured 4.36 s / +258 MB RSS over 827 TypeScript files, on top of
|
||||
// the parse `getScanInput` already does).
|
||||
// Only the file's identity is carried between the passes, never its text: a
|
||||
// large monorepo's whole source tree held in one array at once is the shape
|
||||
// that produced the analyzer's scale problems, and the second read is cheap
|
||||
// beside the parse it gates.
|
||||
const eligible: Array<{ rel: string; language: unknown }> = [];
|
||||
let sawAxios = false;
|
||||
for (const rel of args.files) {
|
||||
const language = grammarForFile(rel);
|
||||
if (language === null) continue;
|
||||
const content = args.readFile(rel);
|
||||
// `MAX_PREPASS_FILE_BYTES` is a BYTE ceiling; `String.length` counts UTF-16
|
||||
// code units, which under-counts every multi-byte source.
|
||||
if (content === null || Buffer.byteLength(content, 'utf8') > MAX_PREPASS_FILE_BYTES) continue;
|
||||
if (!sawAxios && content.includes('axios')) sawAxios = true;
|
||||
eligible.push({ rel, language });
|
||||
}
|
||||
|
||||
if (sawAxios) {
|
||||
for (const { rel, language } of eligible) {
|
||||
try {
|
||||
const content = args.readFile(rel);
|
||||
if (content === null) continue;
|
||||
// `parseSource` belongs INSIDE the guard: `safe-parse.ts` throws
|
||||
// `ParseTimeoutError` and makes catching it a per-caller obligation, and
|
||||
// `prepareRepo` is contractually non-throwing. One escape here left the
|
||||
// fact map unwritten for the WHOLE repo — and, because the orchestrator
|
||||
// caches per plugin NAME, made all three JS/TS plugins re-walk it and
|
||||
// fail the same way before falling back to literal-only scanning.
|
||||
const tree = args.parseSource(parserFor(language), content);
|
||||
if (!tree) continue;
|
||||
byFile.set(normalizeRel(rel), extractJsModuleFacts(tree));
|
||||
} catch {
|
||||
// One malformed file must never abort the pre-pass — it simply stays
|
||||
// unresolved, exactly as it is without this pass at all.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ctx: NodeRepoContext = { facts: buildJsRepoFacts(byFile) };
|
||||
REPO_CONTEXT_BY_FILE_LIST.set(args.files, ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** The repo facts to resolve against, or null when there was no pre-pass. */
|
||||
function resolveFactsFor(
|
||||
repoContext: RepoContext | undefined,
|
||||
fileRel: string | undefined,
|
||||
): JsRepoFacts | null {
|
||||
const ctx = repoContext as NodeRepoContext | undefined;
|
||||
if (!ctx || fileRel === undefined) return null;
|
||||
return ctx.facts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a folded first argument is plausibly a URL path.
|
||||
*
|
||||
* The query now captures ANY first argument, and "it folded to a string" is not
|
||||
* "it is a path" — `normalizeConsumerPath` is a canonicalizer, not a validator,
|
||||
* and it happily turns non-paths into contracts that exact-match real provider
|
||||
* routes:
|
||||
*
|
||||
* api.get(CONFIG.TIMEOUT) // "5000" -> http::GET::/{param}
|
||||
* api.post(MSG.ERROR) // "Could not reach the …" -> http::POST::/could not reach the server
|
||||
*
|
||||
* `/{param}` matches every one-segment provider route in the group, and
|
||||
* `matching.exclude_links_param_only_paths` defaults to `false`. A path whose
|
||||
* leading term is an unresolved placeholder is refused for the same reason —
|
||||
* nothing pins where it starts. (`resolveJsPathExpression` already refuses those
|
||||
* it folded itself; this also covers the literal fallback below.)
|
||||
*/
|
||||
function looksLikeHttpPath(path: string): boolean {
|
||||
if (path === '') return false;
|
||||
if (/^https?:\/\//i.test(path)) return true;
|
||||
// A `${…}` term is a runtime value that `normalizeConsumerPath` rewrites to
|
||||
// `{param}`; its SOURCE text can be any expression (`${draft ? 'a' : 'b'}`,
|
||||
// `${id ?? ''}`), so the checks below have to run against the normalized
|
||||
// shape. Testing the raw source dropped every partially folded path whose
|
||||
// unresolved term happened to contain a space.
|
||||
const shape = path.replace(/\$\{[^}]+\}/g, '{param}');
|
||||
if (/\s/.test(shape)) return false;
|
||||
if (shape.startsWith('{param}')) return false;
|
||||
// An all-digit string is a path only when it is written as one. A leading
|
||||
// slash is that evidence: `client.get('/123')` is a route whose segment the
|
||||
// consumer normalizer reads as `{param}`, while a bare `"5000"` folded out of
|
||||
// `CONFIG.TIMEOUT` is a timeout that would match every one-segment provider.
|
||||
if (!shape.startsWith('/')) return !/^\d+$/.test(shape);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The path a consumer call's first argument denotes.
|
||||
*
|
||||
* Prefers full resolution against the repo facts; falls back to the raw
|
||||
* literal for a string/template node so a repo with no pre-pass (or an
|
||||
* unresolvable reference) behaves exactly as it did before.
|
||||
*
|
||||
* `fileKey` is already `normalizeRel`-ed by the caller — see `scanBundle`.
|
||||
*
|
||||
* `legacyShape` marks the exact combination this pattern matched BEFORE it was
|
||||
* widened: the literal receiver `axios` with a string or template-string first
|
||||
* argument. That combination keeps its old output verbatim, so this PR adds
|
||||
* detections without removing any — `axios.get(`${API_BASE}/users`)` still
|
||||
* yields `/{param}/users`. Everything the widened query NEWLY admits (any other
|
||||
* receiver, or any non-literal argument) has to clear the gates.
|
||||
*/
|
||||
function resolveConsumerPath(
|
||||
pathNode: Parser.SyntaxNode,
|
||||
facts: JsRepoFacts | null,
|
||||
fileKey: string | undefined,
|
||||
legacyShape: boolean,
|
||||
): string | null {
|
||||
if (facts && fileKey !== undefined) {
|
||||
const resolved = resolveJsPathExpression(fileKey, pathNode, facts);
|
||||
if (resolved !== null && looksLikeHttpPath(resolved)) return resolved;
|
||||
}
|
||||
// The fallback is deliberately gated on node TYPE: `unquoteLiteral` returns
|
||||
// unrecognized input unchanged, so handing it a `member_expression` would
|
||||
// yield the literal text `API_ROUTE_PATH.LINKS` as if it were a URL path.
|
||||
if (pathNode.type !== 'string' && pathNode.type !== 'template_string') return null;
|
||||
const literal = unquoteLiteral(pathNode.text);
|
||||
// The fold bails past `MAX_FOLD_LENGTH`; the raw source it falls back to has
|
||||
// no such bound and lands in `contractId` and `meta.path` all the same.
|
||||
if (literal === null || literal.length > MAX_FOLD_LENGTH) return null;
|
||||
return legacyShape || looksLikeHttpPath(literal) ? literal : null;
|
||||
}
|
||||
|
||||
function scanBundle(
|
||||
bundle: NodePatternBundle,
|
||||
tree: Parser.Tree,
|
||||
repoContext?: RepoContext,
|
||||
fileRel?: string,
|
||||
): HttpDetection[] {
|
||||
const out: HttpDetection[] = [];
|
||||
// Repo-wide constant / HTTP-client facts, when the orchestrator ran the
|
||||
// `prepareRepo` pre-pass. Absent for a bare `scan(tree)` call, in which case
|
||||
// every cross-file resolution below floors to the literal-only behavior.
|
||||
const facts = resolveFactsFor(repoContext, fileRel);
|
||||
// The fact map is keyed by `normalizeRel(rel)`. Normalizing at ONE place and
|
||||
// using that value for every read keeps the two sides in step: the receiver
|
||||
// gate used to read the raw `fileRel`, and `isHttpClientRef` cannot tell a key
|
||||
// miss from "not a client", so any non-POSIX path (glob v13 has no
|
||||
// `posix: true` and its walker joins with the platform separator; graph rows
|
||||
// are a second unnormalized source) silently returned zero consumers.
|
||||
const fileKey = fileRel === undefined ? undefined : normalizeRel(fileRel);
|
||||
// Local-binding → { declared export name, module } for the file's named
|
||||
// imports, so an express handler that is an imported (possibly aliased)
|
||||
// symbol resolves to the real definition rather than its local alias text.
|
||||
|
|
@ -471,22 +713,57 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection
|
|||
});
|
||||
}
|
||||
|
||||
// Consumer: axios.<verb>(url)
|
||||
for (const match of runCompiledPatterns(bundle.axios, tree)) {
|
||||
// Consumer: <httpClient>.<verb>(url) — `axios` itself, or any receiver the
|
||||
// repo pre-pass proves is an axios instance.
|
||||
for (const match of runCompiledPatterns(bundle.httpClient, tree)) {
|
||||
const methodNode = match.captures.http_method;
|
||||
const pathNode = match.captures.path;
|
||||
if (!methodNode || !pathNode) continue;
|
||||
const path = unquoteLiteral(pathNode.text);
|
||||
if (path === null) continue;
|
||||
out.push({
|
||||
role: 'consumer',
|
||||
framework: 'axios',
|
||||
method: methodNode.text.toUpperCase(),
|
||||
path,
|
||||
name: null,
|
||||
line: pathNode.startPosition.row + 1,
|
||||
confidence: 0.7,
|
||||
});
|
||||
const objNode = match.captures.obj;
|
||||
if (!methodNode || !pathNode || !objNode) continue;
|
||||
|
||||
// Receiver gate. `axios.get(...)` needs no proof; anything else must be
|
||||
// traced to an `axios.create(...)` binding, or it is not ours to claim.
|
||||
const receiver = objNode.text;
|
||||
|
||||
// Cross-file resolution is the only work in this file that walks a
|
||||
// repo-wide graph, and `HttpLanguagePlugin.scan` may not throw: a single
|
||||
// hostile call site must cost its own detection, not the repo's whole
|
||||
// contract set (`sync.ts` catches a throw here as an unexplained "missing
|
||||
// repo", silently, for every contract type).
|
||||
try {
|
||||
// The receiver is admitted when it IS the axios module — the bare
|
||||
// spelling this pattern trusted before it was widened, or a declared
|
||||
// import/require of 'axios' under any name — or when it traces to an
|
||||
// `axios.create(...)` instance. Nothing else.
|
||||
const isModule =
|
||||
facts === null || fileKey === undefined
|
||||
? receiver === 'axios'
|
||||
: isAxiosNamespace(fileKey, receiver, facts);
|
||||
if (!isModule) {
|
||||
if (!facts || fileKey === undefined) continue;
|
||||
if (!isHttpClientRef(fileKey, receiver, facts)) continue;
|
||||
}
|
||||
|
||||
const path = resolveConsumerPath(
|
||||
pathNode,
|
||||
facts,
|
||||
fileKey,
|
||||
isModule && (pathNode.type === 'string' || pathNode.type === 'template_string'),
|
||||
);
|
||||
if (path === null) continue;
|
||||
|
||||
out.push({
|
||||
role: 'consumer',
|
||||
framework: 'axios',
|
||||
method: methodNode.text.toUpperCase(),
|
||||
path,
|
||||
name: null,
|
||||
line: pathNode.startPosition.row + 1,
|
||||
confidence: 0.7,
|
||||
});
|
||||
} catch {
|
||||
// Unresolvable is the same outcome as unresolved — skip this call site.
|
||||
}
|
||||
}
|
||||
|
||||
// Consumer: jQuery shorthand $.get(url) / $.post(url, ...)
|
||||
|
|
@ -574,17 +851,20 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection
|
|||
export const JAVASCRIPT_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
name: 'javascript-http',
|
||||
language: JavaScript,
|
||||
scan: (tree) => scanBundle(JAVASCRIPT_BUNDLE, tree),
|
||||
prepareRepo: buildNodeRepoContext,
|
||||
scan: (tree, repoContext, fileRel) => scanBundle(JAVASCRIPT_BUNDLE, tree, repoContext, fileRel),
|
||||
};
|
||||
|
||||
export const TYPESCRIPT_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
name: 'typescript-http',
|
||||
language: TypeScript.typescript,
|
||||
scan: (tree) => scanBundle(TYPESCRIPT_BUNDLE, tree),
|
||||
prepareRepo: buildNodeRepoContext,
|
||||
scan: (tree, repoContext, fileRel) => scanBundle(TYPESCRIPT_BUNDLE, tree, repoContext, fileRel),
|
||||
};
|
||||
|
||||
export const TSX_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
name: 'tsx-http',
|
||||
language: TypeScript.tsx,
|
||||
scan: (tree) => scanBundle(TSX_BUNDLE, tree),
|
||||
prepareRepo: buildNodeRepoContext,
|
||||
scan: (tree, repoContext, fileRel) => scanBundle(TSX_BUNDLE, tree, repoContext, fileRel),
|
||||
};
|
||||
|
|
|
|||
201
gitnexus/src/core/group/group-lock.ts
Normal file
201
gitnexus/src/core/group/group-lock.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
/**
|
||||
* Cross-process single-writer lock for one group's persisted state (R9).
|
||||
*
|
||||
* A group sync ends by REPLACING `contracts.json` and rebuilding `bridge.lbug`
|
||||
* from a snapshot it computed minutes earlier. Two syncs of the same group that
|
||||
* overlap therefore do not merge — the second one's write simply overwrites the
|
||||
* first one's, and whichever finishes last wins with a registry assembled from
|
||||
* repo state the other run never saw. Nothing detects it afterwards: both runs
|
||||
* report success, and the group's contracts silently describe a mixture that was
|
||||
* never true at any instant. This module serializes that section so one sync at
|
||||
* a time can be inside it.
|
||||
*
|
||||
* WHERE THE LOCK LIVES. On a dedicated `sync-lock` directory INSIDE the group
|
||||
* directory — mirroring `withRegistryLock`, which locks a `registry-lock`
|
||||
* directory beside the registry rather than the registry's own directory
|
||||
* (repo-manager.ts). {@link acquireIndexLock} is NOT reentrant and its file
|
||||
* backend writes `analyze.lock` into the directory it is handed, so pointing it
|
||||
* at a directory that some other code path might also lock — or that already
|
||||
* holds a per-repo index slot — reintroduces exactly the collision the registry
|
||||
* lock's own comment warns about. `<groupDir>/sync-lock` is a namespace nothing
|
||||
* else claims: group directories live under `~/.gitnexus/groups/<name>` (or
|
||||
* `$GITNEXUS_HOME`), never under a repo's `.gitnexus[/branches/<slug>]`.
|
||||
*
|
||||
* WHY IT FAILS CLOSED, unlike the registry lock. `withRegistryLock` degrades to
|
||||
* running UNLOCKED on timeout, and that is right for it: it guards a sub-second
|
||||
* JSON read/merge/write on a latency-critical path (`augment` runs on every
|
||||
* editor tool call), and running unlocked is merely the pre-lock status quo. A
|
||||
* group sync is the opposite on every axis — it is long, expensive, operator-
|
||||
* initiated, and its lost update destroys contracts rather than a registry field.
|
||||
* A sync that cannot be protected must not run at all, and there are three
|
||||
* distinct ways it can fail to be protected; all three throw
|
||||
* {@link GroupSyncLockError}:
|
||||
*
|
||||
* 1. TIMEOUT — the holder is still alive when the ceiling elapses.
|
||||
* 2. LOCK-FREE DEGRADATION — `acquireIndexLock` answers a read-only or
|
||||
* permission-denied filesystem with a no-op handle that is byte-identical
|
||||
* to a real one at the API boundary. That is a deliberate tolerance for
|
||||
* `analyze` (an unwritable index dir rejects every write anyway, so the
|
||||
* lock is moot), but here it would hand back a handle that protects
|
||||
* nothing while the sync went on to attempt its writes. The handle now
|
||||
* carries {@link IndexLockHandle.lockFree}, so we can see it and refuse.
|
||||
* 3. ANY OTHER ACQUIRE FAILURE — e.g. `sync-lock` cannot be created because a
|
||||
* regular file already occupies the path. Silently proceeding on an error
|
||||
* we did not anticipate is the same unprotected run under another name.
|
||||
*
|
||||
* WHY THE CEILING IS PASSED EXPLICITLY. The magnitude is not the point — 10
|
||||
* minutes deliberately matches `acquireIndexLock`'s own default, because a group
|
||||
* sync is analyze-shaped and a legitimately queued second sync must be able to
|
||||
* wait out a full first one (the registry lock's 5s is sized for a sub-second
|
||||
* merge and is the wrong model here). The reason to pass it is
|
||||
* `resolveTimeoutMs`: it prefers an explicit argument over
|
||||
* `GITNEXUS_INDEX_LOCK_TIMEOUT_MS`, and that variable's `<= 0` case resolves to
|
||||
* `Number.POSITIVE_INFINITY`. Inheriting it would let an environment turn this
|
||||
* lock's fail-closed timeout into an unbounded hang.
|
||||
*
|
||||
* ACQUIRED EXACTLY ONCE, by `syncGroup`, around its whole persist section.
|
||||
* Nothing it calls beneath that point — `writeContractRegistry`,
|
||||
* `refreshPreservedBridgeMeta`, `writeBridgeUnlocked` — takes this lock; a
|
||||
* second acquisition would deadlock a non-reentrant primitive on the HAPPY
|
||||
* path, not on some edge case. `bridge-db.ts` exports the swap in both forms
|
||||
* for exactly that reason: `writeBridgeUnlocked` for the held-lock caller
|
||||
* (`syncGroup`), and the `writeBridge` wrapper, which acquires here, for direct
|
||||
* callers that are outside the region. The same split `repo-manager.ts` uses
|
||||
* for `registerRepoUnlocked` / `registerRepo`.
|
||||
*
|
||||
* SCOPE CAVEAT (recorded, not solved): the default socket backend uses Linux
|
||||
* abstract sockets, which are network-namespace-scoped. Two containers that
|
||||
* share a bind-mounted group directory but sit in separate netns will NOT
|
||||
* contend, exactly as documented for the index lock itself; forcing
|
||||
* `GITNEXUS_INDEX_LOCK_BACKEND=file` is what covers that deployment.
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import {
|
||||
acquireIndexLock,
|
||||
IndexLockTimeoutError,
|
||||
type IndexLockHandle,
|
||||
} from '../../storage/index-lock.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
/** Lock-directory name inside the group directory. Never the group dir itself. */
|
||||
export const GROUP_SYNC_LOCK_DIRNAME = 'sync-lock';
|
||||
|
||||
/** The dedicated lock namespace for one group: `<groupDir>/sync-lock`. */
|
||||
export const getGroupSyncLockDir = (groupDir: string): string =>
|
||||
path.join(groupDir, GROUP_SYNC_LOCK_DIRNAME);
|
||||
|
||||
/**
|
||||
* Wait ceiling for the group sync lock (10 min). See the module header: the
|
||||
* magnitude matches `acquireIndexLock`'s analyze-sized default on purpose; the
|
||||
* reason it is passed EXPLICITLY is to keep `GITNEXUS_INDEX_LOCK_TIMEOUT_MS`
|
||||
* (whose `<= 0` case means unbounded) from turning fail-closed into a hang.
|
||||
*/
|
||||
export const GROUP_SYNC_LOCK_TIMEOUT_MS = 600_000;
|
||||
|
||||
/** Which of the three fail-closed exits produced a {@link GroupSyncLockError}. */
|
||||
export type GroupSyncLockFailure = 'timeout' | 'lock-free' | 'unavailable';
|
||||
|
||||
/**
|
||||
* A group sync could not be protected, so it did not run. One class for all
|
||||
* three exits so both callers — the CLI command and the MCP service — have a
|
||||
* single thing to catch and report.
|
||||
*/
|
||||
export class GroupSyncLockError extends Error {
|
||||
readonly reason: GroupSyncLockFailure;
|
||||
readonly groupDir: string;
|
||||
constructor(reason: GroupSyncLockFailure, groupDir: string, message: string, cause?: unknown) {
|
||||
super(message, cause === undefined ? undefined : { cause });
|
||||
this.name = 'GroupSyncLockError';
|
||||
this.reason = reason;
|
||||
this.groupDir = groupDir;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `operation` as the only group sync touching `groupDir`, or throw
|
||||
* {@link GroupSyncLockError} without running it at all.
|
||||
*
|
||||
* The lock is released in a `finally`, so it is dropped whether the operation
|
||||
* succeeds or throws.
|
||||
*/
|
||||
export const withGroupSyncLock = async <T>(
|
||||
groupDir: string,
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> => {
|
||||
let handle: IndexLockHandle;
|
||||
// The wrapper times the acquisition itself. `IndexLockTimeoutError` carries
|
||||
// `holder` and `holderKnown` and nothing else — the elapsed wait exists only
|
||||
// inside its inherited message string, so the figure has to be measured here
|
||||
// to be reported without that message. `Date.now()` matches how the primitive
|
||||
// measures its own wait.
|
||||
const acquireStartedAt = Date.now();
|
||||
try {
|
||||
handle = await acquireIndexLock(getGroupSyncLockDir(groupDir), {
|
||||
timeoutMs: GROUP_SYNC_LOCK_TIMEOUT_MS,
|
||||
// `acquireIndexLock`'s own `log` texts name an "analyze" holder, which
|
||||
// misattributes a group-sync wait — the same reason `withRegistryLock`
|
||||
// supplies its own line instead of passing `log` through.
|
||||
onWaitStart: () =>
|
||||
logger.info(
|
||||
{ groupDir },
|
||||
'Waiting for another GitNexus process to finish syncing this group…',
|
||||
),
|
||||
});
|
||||
} catch (err) {
|
||||
// The inherited message names "another gitnexus analyze" as the holder —
|
||||
// a cause this detection path cannot establish. Nothing but a group sync
|
||||
// ever locks `<groupDir>/sync-lock` (see the module header), and on the
|
||||
// socket backend the holder is not identifiable at all. Re-word it around
|
||||
// what IS known: which group, which operation, and how long we waited.
|
||||
if (err instanceof IndexLockTimeoutError) {
|
||||
throw new GroupSyncLockError(
|
||||
'timeout',
|
||||
groupDir,
|
||||
`Timed out after ${Date.now() - acquireStartedAt}ms waiting for the sync lock on ` +
|
||||
`group "${path.basename(groupDir)}" (${getGroupSyncLockDir(groupDir)}). ` +
|
||||
// `holderKnown` is false on the socket backend and on the file
|
||||
// backend's malformed/vanished-lock timeouts, where `holder` is a
|
||||
// placeholder (`pid -1`). Presenting that as a real owner would be the
|
||||
// same unestablished claim in a new form.
|
||||
(err.holderKnown
|
||||
? `Held by pid ${err.holder.pid} on ${err.holder.hostname} ` +
|
||||
`(invocation ${err.holder.invocationId}). `
|
||||
: `The lock stayed held for the whole wait, but this lock backend ` +
|
||||
`cannot identify the holder. `) +
|
||||
`Nothing was written and this group was not synced. ` +
|
||||
`Re-run once the other sync of this group has finished.`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
throw new GroupSyncLockError(
|
||||
'unavailable',
|
||||
groupDir,
|
||||
`Could not acquire the sync lock for this group (${getGroupSyncLockDir(groupDir)}): ` +
|
||||
`${err instanceof Error ? err.message : String(err)}. Nothing was written.`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
if (handle.lockFree) {
|
||||
// A handle that owns nothing. Release it anyway (it is a no-op, but the
|
||||
// contract is that every handle is released) and refuse to run: this sync
|
||||
// would otherwise write `contracts.json` and `bridge.lbug` with no
|
||||
// protection at all against a concurrent sync doing the same.
|
||||
handle.release();
|
||||
throw new GroupSyncLockError(
|
||||
'lock-free',
|
||||
groupDir,
|
||||
`The sync lock for this group could not be created at ` +
|
||||
`${getGroupSyncLockDir(groupDir)} (read-only or permission-denied filesystem), ` +
|
||||
`so this sync cannot be protected against a concurrent one. Nothing was written. ` +
|
||||
`Make the group directory writable and re-run.`,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
handle.release();
|
||||
}
|
||||
};
|
||||
|
|
@ -6,7 +6,16 @@
|
|||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { checkStaleness } from '../git-staleness.js';
|
||||
import { loadMeta, type RepoMeta } from '../../storage/repo-manager.js';
|
||||
import {
|
||||
canonicalizePath,
|
||||
loadMeta,
|
||||
readRegistryStrict,
|
||||
registryPathEquals,
|
||||
type RegistryEntry,
|
||||
type RepoMeta,
|
||||
} from '../../storage/repo-manager.js';
|
||||
import { crossRepoCompleteness } from './completeness.js';
|
||||
import { recordedRepoList } from './completeness.js';
|
||||
import { GroupNotFoundError, loadGroupConfig } from './config-parser.js';
|
||||
import {
|
||||
fileMatchesServicePrefix,
|
||||
|
|
@ -222,6 +231,34 @@ function isCrossLink(raw: unknown): raw is CrossLink {
|
|||
return typeof o.contractId === 'string' && typeof o.type === 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the global registry hold a row for this configured group member?
|
||||
*
|
||||
* Consulted only once resolution has ALREADY failed, to choose which of the
|
||||
* two failures `group status` reports. It mirrors the two tiers
|
||||
* `LocalBackend.resolveRepo` matches a bare group-config value on — the
|
||||
* registry `name`, case-insensitively, and the repo `path` — and deliberately
|
||||
* stops short of its hashed-id and partial-name tiers: those exist to be
|
||||
* generous about what an operator typed, while this predicate only decides
|
||||
* between two labels, and a looser match here would relabel a genuine registry
|
||||
* miss as an unresolvable row. That is the same conflation this reporting
|
||||
* exists to remove, pointed the other way.
|
||||
*/
|
||||
function registryIdentifies(entries: RegistryEntry[], registryName: string): boolean {
|
||||
const wantedName = registryName.toLowerCase();
|
||||
// Path equality goes through the registry's own rule rather than a local
|
||||
// `resolve` + platform-case compare. `canonicalizePath` also follows symlinks,
|
||||
// so a row registered through one and looked up through the other still
|
||||
// matches — and there is one definition of registry path identity instead of
|
||||
// a third, weaker copy of it living in a group module nobody would grep.
|
||||
const wantedPath = canonicalizePath(registryName);
|
||||
return entries.some((entry) => {
|
||||
if (typeof entry.name === 'string' && entry.name.toLowerCase() === wantedName) return true;
|
||||
if (typeof entry.path !== 'string') return false;
|
||||
return registryPathEquals(canonicalizePath(entry.path), wantedPath);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadContractRegistryResilient(
|
||||
groupDir: string,
|
||||
): Promise<
|
||||
|
|
@ -288,6 +325,8 @@ async function loadContractRegistryResilient(
|
|||
}
|
||||
}
|
||||
|
||||
// Bound once: the gate is a full array scan and the ternary below used it twice.
|
||||
const recordedUnreadable = recordedRepoList(base.unreadableRepos);
|
||||
const registry: ContractRegistry = {
|
||||
version: typeof base.version === 'number' ? base.version : 0,
|
||||
generatedAt: typeof base.generatedAt === 'string' ? base.generatedAt : '',
|
||||
|
|
@ -295,7 +334,20 @@ async function loadContractRegistryResilient(
|
|||
base.repoSnapshots && typeof base.repoSnapshots === 'object' && base.repoSnapshots !== null
|
||||
? (base.repoSnapshots as Record<string, { indexedAt: string; lastCommit: string }>)
|
||||
: {},
|
||||
missingRepos: Array.isArray(base.missingRepos) ? (base.missingRepos as string[]) : [],
|
||||
// Same gate as `groupStatus` uses on the same field, for the same reason:
|
||||
// `Array.isArray` alone waves through `[{repo:'x'}]`, and `groupContracts`
|
||||
// now returns this list AND folds it into its completeness answer, so a
|
||||
// value we could not read would be reported as a repo name. `missingRepos`
|
||||
// has always been required, so — unlike `unreadableRepos` below — there is
|
||||
// no "not recorded" state to preserve: an unreadable value degrades to empty.
|
||||
missingRepos: recordedRepoList(base.missingRepos) ?? [],
|
||||
// Spread, not `?? []`. `ContractRegistry.unreadableRepos` documents absence
|
||||
// as "not recorded", and a registry written before the field existed has no
|
||||
// opinion about which indexes were readable. Normalizing that to `[]` hands
|
||||
// the caller "the last sync found none unreadable" — an unmeasured state
|
||||
// rendered as a clean result, which is the same conflation this whole
|
||||
// change removes.
|
||||
...(recordedUnreadable ? { unreadableRepos: recordedUnreadable } : {}),
|
||||
contracts,
|
||||
crossLinks,
|
||||
};
|
||||
|
|
@ -347,18 +399,34 @@ export class GroupService {
|
|||
// MCP server startup entirely and off every non-sync group call. The CLI
|
||||
// already does exactly this at `cli/group.ts`'s sync command.
|
||||
const { syncGroup } = await import('./sync.js');
|
||||
const result = await syncGroup(config, {
|
||||
groupDir,
|
||||
exactOnly: Boolean(params.exactOnly),
|
||||
skipEmbeddings: Boolean(params.skipEmbeddings),
|
||||
allowStale: Boolean(params.allowStale),
|
||||
verbose: Boolean(params.verbose),
|
||||
});
|
||||
const { GroupSyncLockError } = await import('./group-lock.js');
|
||||
let result: Awaited<ReturnType<typeof syncGroup>>;
|
||||
try {
|
||||
result = await syncGroup(config, {
|
||||
groupDir,
|
||||
exactOnly: Boolean(params.exactOnly),
|
||||
skipEmbeddings: Boolean(params.skipEmbeddings),
|
||||
allowStale: Boolean(params.allowStale),
|
||||
verbose: Boolean(params.verbose),
|
||||
});
|
||||
} catch (err) {
|
||||
// Fails closed (R9): this sync could not be protected against a concurrent
|
||||
// one, so it did not run and wrote nothing. Return it through the same
|
||||
// error channel a missing group uses — NEVER as a success payload of zeroes,
|
||||
// which an agent would read as "the group genuinely has no contracts".
|
||||
if (!(err instanceof GroupSyncLockError)) throw err;
|
||||
return { error: err.message };
|
||||
}
|
||||
return {
|
||||
contracts: result.contracts.length,
|
||||
crossLinks: result.crossLinks.length,
|
||||
unmatched: result.unmatched.length,
|
||||
missingRepos: result.missingRepos,
|
||||
unreadableRepos: result.unreadableRepos,
|
||||
// An agent that calls group_sync and then group_contracts a moment later
|
||||
// can otherwise see contract counts that disagree with this payload, with
|
||||
// nothing here explaining why the write was skipped.
|
||||
registryOutcome: result.registryOutcome,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -386,7 +454,38 @@ export class GroupService {
|
|||
);
|
||||
contracts = contracts.filter((c) => !matchedIds.has(`${c.repo}::${c.contractId}`));
|
||||
}
|
||||
const out: Record<string, unknown> = { contracts, crossLinks: registry.crossLinks };
|
||||
// `loadContractRegistryResilient` already applied `recordedRepoList` to
|
||||
// both: `undefined` here is "the last sync recorded no opinion" (a registry
|
||||
// written before the field existed, or a value we could not read), which is
|
||||
// NOT the same answer as the measured empty list.
|
||||
const { unreadableRepos, missingRepos } = registry;
|
||||
// `incompleteRepos` is dropped on this surface only because the two lists it
|
||||
// is derived from are returned verbatim right below; the truncation triple is
|
||||
// the part that has no other channel here.
|
||||
const { incompleteRepos: _incompleteRepos, ...truncation } = crossRepoCompleteness({
|
||||
unreadableRepos,
|
||||
missingRepos,
|
||||
// An unrecorded `unreadableRepos` means this listing cannot say which
|
||||
// repos the sync failed to read — so it cannot claim to be complete.
|
||||
provenanceUnknown: unreadableRepos === undefined,
|
||||
// A contract LISTING declares no scope to intersect with: it is the whole
|
||||
// registry, so every configured repo is in scope by construction. The
|
||||
// `type`/`repo`/`unmatchedOnly` filters above narrow which rows are shown,
|
||||
// not which repos the sync had to read to produce them.
|
||||
inScope: () => true,
|
||||
});
|
||||
const out: Record<string, unknown> = {
|
||||
contracts,
|
||||
crossLinks: registry.crossLinks,
|
||||
missingRepos,
|
||||
// Omitted rather than `[]` when the registry never recorded it — the same
|
||||
// convention `skippedCorrupt` follows below, and the difference between
|
||||
// "the sync measured zero unreadable repos" and "the sync never said".
|
||||
...(unreadableRepos ? { unreadableRepos } : {}),
|
||||
// The structured triple, verbatim from the impact surface (KTD10):
|
||||
// `truncated` always, `truncationReason` + `riskEpistemic` with it.
|
||||
...truncation,
|
||||
};
|
||||
if (skippedCorrupt > 0) out.skippedCorrupt = skippedCorrupt;
|
||||
return out;
|
||||
}
|
||||
|
|
@ -573,17 +672,80 @@ export class GroupService {
|
|||
}
|
||||
const registry = await readContractRegistry(groupDir);
|
||||
|
||||
/**
|
||||
* The STRICT global-registry read, deliberately — this is the one caller
|
||||
* that has to tell "the registry says nothing about this repo" apart from
|
||||
* "the registry could not be read at all", and only the strict mode can.
|
||||
* `readRegistry`'s `catch { return [] }` collapses a malformed registry
|
||||
* into an empty one, which is indistinguishable from a genuine absence and
|
||||
* would report every configured repo as having no entry — the exact
|
||||
* conflation the two labels below exist to remove.
|
||||
*
|
||||
* The consequence is accepted knowingly: the strict read rejects the WHOLE
|
||||
* registry when any single row fails to identify a repo, so one malformed
|
||||
* row renders every member of the group unresolvable, including members
|
||||
* whose own rows are fine. That is the honest verdict — a registry the
|
||||
* resolver cannot trust row-wise cannot be trusted about any row — and it
|
||||
* is reported as an unresolved state, never as a clean one.
|
||||
*
|
||||
* ENOENT is not a failure in either mode: no registry file genuinely means
|
||||
* nothing has been registered yet, so every repo is legitimately missing.
|
||||
*/
|
||||
let registryEntries: RegistryEntry[] | null = null;
|
||||
let registryReadError: string | null = null;
|
||||
try {
|
||||
registryEntries = await readRegistryStrict();
|
||||
} catch (err) {
|
||||
registryReadError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
const repoStatuses: Record<
|
||||
string,
|
||||
{
|
||||
indexStale: boolean;
|
||||
contractsStale: boolean;
|
||||
/**
|
||||
* Unchanged meaning: this repo has no usable status. It stays `true`
|
||||
* for BOTH failures below, so a consumer written before the split
|
||||
* still sees every unusable repo flagged. Reporting an unresolvable
|
||||
* repo as `missing: false` would hand that consumer `indexStale:
|
||||
* false` for a repo nothing was ever read from — a false all-clear.
|
||||
*/
|
||||
missing: boolean;
|
||||
/**
|
||||
* Which failure `missing` means: `false` is a genuine registry miss,
|
||||
* `true` is an entry the resolver could not turn into a repo. Additive
|
||||
* — always present on every row, so an agent can branch on it without
|
||||
* having to treat an absent key as either answer.
|
||||
*/
|
||||
unresolvable: boolean;
|
||||
/** Set only when `unresolvable`; says what could not be resolved. */
|
||||
unresolvableReason?: string;
|
||||
commitsBehind?: number;
|
||||
}
|
||||
> = {};
|
||||
|
||||
for (const [repoPath, registryName] of Object.entries(config.repos)) {
|
||||
if (registryEntries === null) {
|
||||
repoStatuses[repoPath] = {
|
||||
indexStale: false,
|
||||
contractsStale: false,
|
||||
missing: true,
|
||||
unresolvable: true,
|
||||
unresolvableReason: `the global registry could not be read: ${registryReadError}`,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
// Only `resolveRepo` is inside the try that produces the
|
||||
// "did not resolve" label, so the label is earned rather than assumed.
|
||||
// `loadMeta` and `checkStaleness` cannot throw — the first returns null on
|
||||
// every error, the second catches everything — but the reading below them
|
||||
// can, and did: `registry.repoSnapshots` is read off a bare
|
||||
// `JSON.parse(...) as ContractRegistry` with no shape check, so a
|
||||
// contracts.json missing that field threw a TypeError into this catch and
|
||||
// reported every repo as an unresolvable GLOBAL-registry entry. That sent
|
||||
// the operator to repair the wrong file. The optional chain below closes
|
||||
// the crash; this split stops the next one being mislabelled the same way.
|
||||
try {
|
||||
const repoObj = await this.port.resolveRepo(registryName);
|
||||
const meta: Partial<Pick<RepoMeta, 'lastCommit' | 'indexedAt'>> =
|
||||
|
|
@ -593,7 +755,7 @@ export class GroupService {
|
|||
? checkStaleness(repoObj.repoPath, meta.lastCommit)
|
||||
: { isStale: true, commitsBehind: -1 };
|
||||
|
||||
const snapshot = registry?.repoSnapshots[repoPath];
|
||||
const snapshot = registry?.repoSnapshots?.[repoPath];
|
||||
const contractsStale =
|
||||
snapshot && meta.indexedAt ? snapshot.indexedAt !== meta.indexedAt : !snapshot;
|
||||
|
||||
|
|
@ -601,17 +763,45 @@ export class GroupService {
|
|||
indexStale: staleness.isStale,
|
||||
contractsStale: Boolean(contractsStale),
|
||||
missing: false,
|
||||
unresolvable: false,
|
||||
commitsBehind: staleness.commitsBehind,
|
||||
};
|
||||
} catch {
|
||||
repoStatuses[repoPath] = { indexStale: false, contractsStale: false, missing: true };
|
||||
} catch (err) {
|
||||
// The registry read succeeded, so its answer about this row is
|
||||
// trustworthy: a row that is there and still would not resolve is a
|
||||
// different fact from a row that was never there, and the operator's
|
||||
// next move differs (repair the entry vs. index the repo).
|
||||
const known = registryIdentifies(registryEntries, registryName);
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
repoStatuses[repoPath] = {
|
||||
indexStale: false,
|
||||
contractsStale: false,
|
||||
missing: true,
|
||||
unresolvable: known,
|
||||
...(known
|
||||
? { unresolvableReason: `registry entry "${registryName}" did not resolve: ${reason}` }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
group: name,
|
||||
lastSync: registry?.generatedAt || null,
|
||||
missingRepos: registry?.missingRepos || [],
|
||||
// `readContractRegistry` is a bare `JSON.parse(...) as ContractRegistry`,
|
||||
// so both of these are whatever the file happened to hold — the
|
||||
// validation in `loadContractRegistryResilient` never runs on this path.
|
||||
// A `contracts.json` carrying a string here reached `cli/group.ts` and
|
||||
// died in `.join(', ')`, i.e. an unreadable registry crashing the command
|
||||
// whose job is to explain unreadable things.
|
||||
//
|
||||
// `missingRepos` has always been required, so there is no "not recorded"
|
||||
// state to preserve for it — an unreadable value degrades to empty.
|
||||
missingRepos: recordedRepoList(registry?.missingRepos) ?? [],
|
||||
// `unreadableRepos` does have one: absent means "not recorded", not
|
||||
// "none" (see ContractRegistry), and a value we could not read is equally
|
||||
// unrecorded. Reporting either as an empty list is the same conflation.
|
||||
unreadableRepos: recordedRepoList(registry?.unreadableRepos),
|
||||
repos: repoStatuses,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import * as os from 'node:os';
|
|||
import type { ContractRegistry } from './types.js';
|
||||
import { writeFileAtomic } from '../../storage/fs-atomic.js';
|
||||
|
||||
const CONTRACTS_FILE = 'contracts.json';
|
||||
export const CONTRACTS_FILE = 'contracts.json';
|
||||
|
||||
export function getDefaultGitnexusDir(): string {
|
||||
return process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus');
|
||||
|
|
@ -30,6 +30,11 @@ export function getGroupDir(gitnexusDir: string, groupName: string): string {
|
|||
return path.join(gitnexusDir, 'groups', groupName);
|
||||
}
|
||||
|
||||
/** The registry path, so callers that stat or watch the file do not respell its name. */
|
||||
export function getContractRegistryPath(groupDir: string): string {
|
||||
return path.join(groupDir, CONTRACTS_FILE);
|
||||
}
|
||||
|
||||
export async function writeContractRegistry(
|
||||
groupDir: string,
|
||||
registry: ContractRegistry,
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -100,7 +100,20 @@ export interface ContractRegistry {
|
|||
version: number;
|
||||
generatedAt: string;
|
||||
repoSnapshots: Record<string, RepoSnapshot>;
|
||||
/** Configured repos with no entry in the registry. */
|
||||
missingRepos: string[];
|
||||
/**
|
||||
* Configured repos that ARE registered but that this sync could not extract
|
||||
* from — the index would not open (version skew, lock, corruption), or an
|
||||
* extractor threw partway through. The two are one bucket because the
|
||||
* consequence is one thing: NONE of that repo's contracts are in this
|
||||
* registry. Distinct from `missingRepos`, which is "no entry in the
|
||||
* registry at all" and needs a different answer from the operator.
|
||||
*
|
||||
* Optional so a registry written before this field existed still parses —
|
||||
* absent means "not recorded", not "none".
|
||||
*/
|
||||
unreadableRepos?: string[];
|
||||
contracts: StoredContract[];
|
||||
crossLinks: CrossLink[];
|
||||
}
|
||||
|
|
@ -117,8 +130,24 @@ export interface RepoHandle {
|
|||
storagePath: string;
|
||||
}
|
||||
|
||||
/** Why local impact or fan-out stopped early (e.g. wall-clock budget exhausted). */
|
||||
export type GroupImpactTruncationReason = 'timeout' | 'partial';
|
||||
/**
|
||||
* Why local impact or fan-out stopped early (e.g. wall-clock budget exhausted).
|
||||
*
|
||||
* `'timeout'` and `'partial'` are runtime limits — the same query can succeed on
|
||||
* a retry. `'incomplete-sync'` is structural: the bridge itself was built from a
|
||||
* sync that could not read every configured repo, so those repos' contracts are
|
||||
* absent from every query against it until `gitnexus group sync` succeeds.
|
||||
*
|
||||
* A runtime array rather than a bare type union: every value here has to be
|
||||
* explained on the agent-facing surface that returns it, and only an enumerable
|
||||
* list lets a guard test assert that. A test that hand-lists the members passes
|
||||
* forever once a fourth is added — which is the exact drift the guard exists to
|
||||
* catch, so the list an agent is promised and the list the code can emit have
|
||||
* to come from the same place.
|
||||
*/
|
||||
export const GROUP_IMPACT_TRUNCATION_REASONS = ['timeout', 'partial', 'incomplete-sync'] as const;
|
||||
|
||||
export type GroupImpactTruncationReason = (typeof GROUP_IMPACT_TRUNCATION_REASONS)[number];
|
||||
|
||||
export interface GroupImpactResult {
|
||||
local: unknown;
|
||||
|
|
@ -222,5 +251,110 @@ export interface BridgeHandle {
|
|||
export interface BridgeMeta {
|
||||
version: number;
|
||||
generatedAt: string;
|
||||
/**
|
||||
* Size and mtime of the `bridge.lbug` this metadata was written for, so a
|
||||
* reader can tell whether the two still belong together.
|
||||
*
|
||||
* `writeBridge` replaces the database and writes this file as two operations;
|
||||
* a sync that stops between them leaves the PREVIOUS sync's metadata beside a
|
||||
* new database, and `runGroupImpact` reads completeness from that metadata.
|
||||
* Stamping the pair is what lets `bridgeMetaMatchesFile` reject the mismatch
|
||||
* without anything having to be deleted — deleting the old metadata up front
|
||||
* would lose it permanently on a swap that fails with the old database still
|
||||
* in place, which is a normal Windows outcome when a read-only handle is held.
|
||||
*
|
||||
* Optional: metadata written before this existed carries no stamp. Such a
|
||||
* file is not waved through — `bridgeMetaMatchesFile` falls back to comparing
|
||||
* the two files' modification times, since a successful write orders the
|
||||
* database rename before the metadata write and a database NEWER than the
|
||||
* metadata beside it therefore cannot be the one it describes.
|
||||
*
|
||||
* That fallback proves WRITE ORDER, not provenance, and is wrong in both
|
||||
* directions — a non-monotonic clock can make a mis-paired set read as
|
||||
* ordered, and any copy or restore that rewrites the database's times after
|
||||
* the metadata's demotes an intact legacy pair to a lower bound until the
|
||||
* next sync re-stamps it. A stamped pair never reaches that fallback, which
|
||||
* is the reason to prefer stamping over widening the heuristic. Both
|
||||
* directions are spelled out at `bridgeMetaMatchesFile`.
|
||||
*/
|
||||
bridgeSize?: number;
|
||||
bridgeMtimeMs?: number;
|
||||
/**
|
||||
* Reader-side only: true when `meta.json` parsed but one of its repo lists
|
||||
* held a value that was not a list of repo paths.
|
||||
*
|
||||
* NEVER PERSISTED. `readBridgeMeta` sets it to describe what it found in the
|
||||
* file; `writeBridgeMeta`'s only caller builds a fresh literal, so it cannot
|
||||
* round-trip back to disk. It lives on this interface rather than on a
|
||||
* reader-only subtype so that `readBridgeMeta` keeps the exact signature
|
||||
* every caller already compiles against.
|
||||
*
|
||||
* The unusable value is dropped rather than normalized, so `missingRepos: []`
|
||||
* on such a result is inert filler — this flag, not the empty list, is what
|
||||
* says the bridge's provenance is unknown.
|
||||
*/
|
||||
repoListsUnreadable?: boolean;
|
||||
/**
|
||||
* Reader-side only: did this metadata pair with the `bridge.lbug` beside it,
|
||||
* measured BEFORE anything opened that database?
|
||||
*
|
||||
* NEVER PERSISTED, for the same reason as `repoListsUnreadable`.
|
||||
*
|
||||
* The measurement has to happen before the open, and the answer has to be
|
||||
* carried rather than recomputed. `runGroupImpact` and `runGroupTrace` open
|
||||
* the bridge and only then ask about provenance, so a platform where a
|
||||
* read-only open advances the database's mtime would fail every unstamped
|
||||
* pair the moment it was read — turning back-compat for pre-stamp bridges
|
||||
* into a repo-wide "everything is a lower bound". Whether any given
|
||||
* LadybugDB build and OS does that is not something a reader should have to
|
||||
* know, and it cannot be observed on Windows, where the in-process
|
||||
* write→read reopen this would need is a documented limitation. Ordering the
|
||||
* check ahead of the open makes the question moot on every platform instead
|
||||
* of true on the ones that happen to be testable.
|
||||
*/
|
||||
pairedWithDatabase?: boolean;
|
||||
/**
|
||||
* PERSISTED, unlike the two fields above: the writer of this metadata could
|
||||
* not establish that it describes the `bridge.lbug` beside it, and no reader
|
||||
* may conclude otherwise from the files alone.
|
||||
*
|
||||
* Written by `refreshPreservedBridgeMeta` — the preserve path in `syncGroup`,
|
||||
* which refreshes the diagnostic lists of a bridge it deliberately does NOT
|
||||
* rebuild. That refresh rewrites `meta.json` ATOMICALLY, so this file's mtime
|
||||
* becomes now while the database's stays old; and "metadata newer than the
|
||||
* database beside it" is exactly the write order that
|
||||
* `unstampedMetaPairsByWriteOrder` accepts. A refresh that simply carried the
|
||||
* old fields forward would therefore convert a pair that check had been
|
||||
* REJECTING into one it waves through — laundering unknown provenance into
|
||||
* verified provenance, which is the fail-open this whole channel exists to
|
||||
* close.
|
||||
*
|
||||
* "Just don't write a stamp" is not a substitute, and is worse: an unstamped
|
||||
* metadata file is judged on the two file times, and the refresh has already
|
||||
* moved them into the accepting order. The verdict has to be recorded IN the
|
||||
* file, because the write that records it is itself what destroys the
|
||||
* evidence a reader would otherwise use.
|
||||
*
|
||||
* `bridgeMetaMatchesFile` rejects on this ahead of both the stamp and the
|
||||
* write-order heuristic, so `ensureBridgeReady` answers
|
||||
* `pairedWithDatabase: false` and `bridgeProvenanceUnknown` reports the
|
||||
* cross-repo answer as a lower bound. That is the ONE enforcement point; do
|
||||
* not add a second reader for this field.
|
||||
*
|
||||
* Self-clearing: a successful `writeBridge` builds fresh metadata from a
|
||||
* literal and never sets it, so the next good sync retires the marker without
|
||||
* anything having to delete it.
|
||||
*/
|
||||
provenanceUnknown?: boolean;
|
||||
missingRepos: string[];
|
||||
/**
|
||||
* Configured repos the sync that produced this bridge could not extract from
|
||||
* (see `ContractRegistry.unreadableRepos`). Their contracts and every
|
||||
* cross-link touching them are absent from `bridge.lbug`, so a cross-repo
|
||||
* impact query against this bridge is a lower bound, not a verdict —
|
||||
* `runGroupImpact` folds a non-empty value into its truncation fields for
|
||||
* exactly that reason.
|
||||
* Optional: a bridge written before this field existed does not record it.
|
||||
*/
|
||||
unreadableRepos?: string[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,13 +49,29 @@ export function resolvePhpImportInternal(
|
|||
|
||||
if (composerConfig) {
|
||||
const sorted = getSortedPsr4(composerConfig);
|
||||
const authoritativePsr4 =
|
||||
composerConfig.authoritativePsr4 ?? new Set(sorted.map(([namespace]) => namespace));
|
||||
let matchedAuthoritativeNamespace = false;
|
||||
let hasAuthoritativeCatchAllNamespace = false;
|
||||
const ownershipPath = normalized.replace(/^\/+/, '');
|
||||
|
||||
for (const [nsPrefix, dirPrefix] of sorted) {
|
||||
const nsPrefixSlash = nsPrefix.replace(/\\/g, '/');
|
||||
if (normalized.startsWith(nsPrefixSlash + '/') || normalized === nsPrefixSlash) {
|
||||
const remainder = normalized.slice(nsPrefixSlash.length).replace(/^\//, '');
|
||||
const nsPrefixSlash = nsPrefix.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
const isCatchAll = nsPrefixSlash === '';
|
||||
if (
|
||||
isCatchAll ||
|
||||
ownershipPath.startsWith(nsPrefixSlash + '/') ||
|
||||
ownershipPath === nsPrefixSlash
|
||||
) {
|
||||
const isAuthoritative = authoritativePsr4.has(nsPrefix);
|
||||
matchedAuthoritativeNamespace ||= isAuthoritative;
|
||||
hasAuthoritativeCatchAllNamespace ||= isAuthoritative && isCatchAll;
|
||||
const remainder = ownershipPath.slice(nsPrefixSlash.length).replace(/^\//, '');
|
||||
|
||||
// 1. Try class-style PSR-4: full path → file (e.g. App\Models\User → app/Models/User.php)
|
||||
const filePath = dirPrefix + (remainder ? '/' + remainder : '') + '.php';
|
||||
const mappedPath =
|
||||
dirPrefix === '' ? remainder : dirPrefix + (remainder ? '/' + remainder : '');
|
||||
const filePath = mappedPath + '.php';
|
||||
if (allFiles.has(filePath)) return filePath;
|
||||
if (index) {
|
||||
const result = index.getInsensitive(filePath);
|
||||
|
|
@ -64,45 +80,64 @@ export function resolvePhpImportInternal(
|
|||
|
||||
// 2. Function/constant fallback: strip last segment (symbol name), scan namespace directory.
|
||||
// e.g. App\Models\getUser → directory app/Models/, find first .php file in that dir.
|
||||
const lastSlash = remainder.lastIndexOf('/');
|
||||
const nsDir = lastSlash >= 0 ? dirPrefix + '/' + remainder.slice(0, lastSlash) : dirPrefix;
|
||||
// A root/catch-all mapping cannot safely infer a symbol's declaring
|
||||
// file from an arbitrary sibling. The higher-level PHP resolver has
|
||||
// parsed symbol-kind and declaration evidence for function/const
|
||||
// imports; class imports must not inherit this directory heuristic.
|
||||
if (!isCatchAll && dirPrefix !== '') {
|
||||
const lastSlash = remainder.lastIndexOf('/');
|
||||
const relativeNamespace = lastSlash >= 0 ? remainder.slice(0, lastSlash) : '';
|
||||
const nsDir = relativeNamespace === '' ? dirPrefix : `${dirPrefix}/${relativeNamespace}`;
|
||||
|
||||
// Prefer SuffixIndex directory lookup (O(log n + matches)) over linear scan.
|
||||
//
|
||||
// An EMPTY bucket is a final answer, not a miss to retry with the scan
|
||||
// below — which is what the `else` restores, and what this comment
|
||||
// always claimed. Re-scanning on empty was the last per-import
|
||||
// workspace traversal left in PHP resolution after #2901: any `use`
|
||||
// matching a PSR-4 prefix whose directory holds no direct `.php` child
|
||||
// (`App\Legacy\Ghost`) paid a full pass, measured at 201 traversals for
|
||||
// 200 imports.
|
||||
//
|
||||
// The bucket is a superset of what the scan can find, for BOTH index
|
||||
// shapes that reach here. A root-anchored direct child `nsDir/<x>.php`
|
||||
// has its directory exactly equal to `nsDir`, and `nsDir` is always one
|
||||
// of that directory's own suffixes — so the shared `dirMap` (keyed on
|
||||
// every directory suffix) necessarily contains it, as does the
|
||||
// root-anchored parity index `languages/php/import-target.ts` builds.
|
||||
// Empty superset therefore implies empty scan, and control falls
|
||||
// through to the next PSR-4 prefix exactly as before.
|
||||
if (index) {
|
||||
const candidates = index.getFilesInDir(nsDir, '.php');
|
||||
if (candidates.length > 0) return candidates[0];
|
||||
} else {
|
||||
// Linear scan, only when a SuffixIndex is genuinely unavailable.
|
||||
const nsDirPrefix = nsDir.endsWith('/') ? nsDir : nsDir + '/';
|
||||
for (const f of allFiles) {
|
||||
if (
|
||||
f.startsWith(nsDirPrefix) &&
|
||||
f.endsWith('.php') &&
|
||||
!f.slice(nsDirPrefix.length).includes('/')
|
||||
) {
|
||||
return f;
|
||||
// Prefer SuffixIndex directory lookup (O(log n + matches)) over linear scan.
|
||||
//
|
||||
// An EMPTY bucket is a final answer, not a miss to retry with the scan
|
||||
// below — which is what the `else` restores, and what this comment
|
||||
// always claimed. Re-scanning on empty was the last per-import
|
||||
// workspace traversal left in PHP resolution after #2901: any `use`
|
||||
// matching a PSR-4 prefix whose directory holds no direct `.php` child
|
||||
// (`App\Legacy\Ghost`) paid a full pass, measured at 201 traversals for
|
||||
// 200 imports.
|
||||
//
|
||||
// The bucket is a superset of what the scan can find, for BOTH index
|
||||
// shapes that reach here. A root-anchored direct child `nsDir/<x>.php`
|
||||
// has its directory exactly equal to `nsDir`, and `nsDir` is always one
|
||||
// of that directory's own suffixes — so the shared `dirMap` (keyed on
|
||||
// every directory suffix) necessarily contains it, as does the
|
||||
// root-anchored parity index `languages/php/import-target.ts` builds.
|
||||
// Empty superset therefore implies empty scan, and control falls
|
||||
// through to the next PSR-4 prefix exactly as before.
|
||||
if (index) {
|
||||
const candidates = index.getFilesInDir(nsDir, '.php');
|
||||
if (candidates.length > 0) return candidates[0];
|
||||
} else {
|
||||
// Linear scan, only when a SuffixIndex is genuinely unavailable.
|
||||
const nsDirPrefix = nsDir.endsWith('/') ? nsDir : nsDir + '/';
|
||||
for (const f of allFiles) {
|
||||
if (
|
||||
f.startsWith(nsDirPrefix) &&
|
||||
f.endsWith('.php') &&
|
||||
!f.slice(nsDirPrefix.length).includes('/')
|
||||
) {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A non-empty PSR-4 map is authoritative for namespaces it does not own.
|
||||
// Preserve the existing mapped-namespace fallback behavior; #2962 is the
|
||||
// conservative external-namespace gate, not a rewrite of mapped lookup.
|
||||
// A catch-all owns every namespace, so its misses remain authoritative.
|
||||
if (
|
||||
authoritativePsr4.size > 0 &&
|
||||
!composerConfig.hasUnmodeledAutoload &&
|
||||
(!matchedAuthoritativeNamespace || hasAuthoritativeCatchAllNamespace)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: suffix matching (works without composer.json)
|
||||
|
|
|
|||
|
|
@ -30,11 +30,103 @@ export interface GoModuleConfig {
|
|||
export interface ComposerConfig {
|
||||
/** Map of namespace prefix -> directory (e.g., "App\\" -> "app/") */
|
||||
psr4: Map<string, string>;
|
||||
/** Production `autoload.psr-4` prefixes that may gate external namespaces.
|
||||
* Absent on legacy/manual configs, where every mapping remains authoritative. */
|
||||
authoritativePsr4?: ReadonlySet<string>;
|
||||
/** True when Composer also declares an autoload mechanism this resolver does not model. */
|
||||
hasUnmodeledAutoload?: boolean;
|
||||
/** PSR-4 entries sorted by namespace length descending (longest match wins).
|
||||
* Cached once at config load time to avoid re-sorting on every import. */
|
||||
psr4Sorted?: readonly [string, string][];
|
||||
}
|
||||
|
||||
function normalizeComposerDirectory(baseDir: string, directory: string): string {
|
||||
const normalizedBase = baseDir.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
|
||||
const normalizedDirectory = directory
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^(?:\.\/)+/, '')
|
||||
.replace(/\/+$/, '');
|
||||
if (normalizedBase === '') return normalizedDirectory;
|
||||
if (normalizedDirectory === '') return normalizedBase;
|
||||
return path.posix.normalize(`${normalizedBase}/${normalizedDirectory}`);
|
||||
}
|
||||
|
||||
/** Parse one Composer manifest without performing I/O. */
|
||||
export function parseComposerConfig(value: unknown, baseDir = ''): ComposerConfig | null {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
|
||||
|
||||
const composer = value as Record<string, unknown>;
|
||||
const autoload = composer.autoload;
|
||||
const autoloadDev = composer['autoload-dev'];
|
||||
if (autoload === undefined && autoloadDev === undefined) return null;
|
||||
|
||||
const psr4 = new Map<string, string>();
|
||||
const authoritativePsr4 = new Set<string>();
|
||||
let hasUnmodeledAutoload = false;
|
||||
|
||||
const addSection = (sectionValue: unknown, authoritative: boolean): void => {
|
||||
if (typeof sectionValue !== 'object' || sectionValue === null || Array.isArray(sectionValue)) {
|
||||
return;
|
||||
}
|
||||
const section = sectionValue as Record<string, unknown>;
|
||||
if ('psr-0' in section || 'classmap' in section) hasUnmodeledAutoload = true;
|
||||
|
||||
const rawPsr4 = section['psr-4'];
|
||||
if (typeof rawPsr4 !== 'object' || rawPsr4 === null || Array.isArray(rawPsr4)) return;
|
||||
|
||||
for (const [namespace, directories] of Object.entries(rawPsr4)) {
|
||||
const stringDirectories = Array.isArray(directories)
|
||||
? directories.filter((entry): entry is string => typeof entry === 'string')
|
||||
: typeof directories === 'string'
|
||||
? [directories]
|
||||
: [];
|
||||
if (stringDirectories.length === 0) continue;
|
||||
if (stringDirectories.length > 1) hasUnmodeledAutoload = true;
|
||||
|
||||
const normalizedNamespace = namespace.replace(/\\+$/, '');
|
||||
const normalizedDirectory = normalizeComposerDirectory(baseDir, stringDirectories[0]);
|
||||
const existing = psr4.get(normalizedNamespace);
|
||||
if (existing !== undefined && existing !== normalizedDirectory) {
|
||||
hasUnmodeledAutoload = true;
|
||||
continue;
|
||||
}
|
||||
if (existing === undefined) psr4.set(normalizedNamespace, normalizedDirectory);
|
||||
if (authoritative) authoritativePsr4.add(normalizedNamespace);
|
||||
}
|
||||
};
|
||||
|
||||
// Production mappings win duplicate prefixes. Development mappings remain
|
||||
// usable for test code but do not establish authority for the external gate.
|
||||
addSection(autoload, true);
|
||||
addSection(autoloadDev, false);
|
||||
|
||||
return { psr4, authoritativePsr4, hasUnmodeledAutoload };
|
||||
}
|
||||
|
||||
/** Merge package-local Composer manifests into one repository-relative config. */
|
||||
export function mergeComposerConfigs(configs: readonly ComposerConfig[]): ComposerConfig | null {
|
||||
if (configs.length === 0) return null;
|
||||
|
||||
const psr4 = new Map<string, string>();
|
||||
const authoritativePsr4 = new Set<string>();
|
||||
let hasUnmodeledAutoload = false;
|
||||
for (const config of configs) {
|
||||
hasUnmodeledAutoload ||= config.hasUnmodeledAutoload === true;
|
||||
for (const [namespace, directory] of config.psr4) {
|
||||
const existing = psr4.get(namespace);
|
||||
if (existing !== undefined && existing !== directory) {
|
||||
hasUnmodeledAutoload = true;
|
||||
continue;
|
||||
}
|
||||
if (existing === undefined) psr4.set(namespace, directory);
|
||||
}
|
||||
for (const namespace of config.authoritativePsr4 ?? config.psr4.keys()) {
|
||||
authoritativePsr4.add(namespace);
|
||||
}
|
||||
}
|
||||
return { psr4, authoritativePsr4, hasUnmodeledAutoload };
|
||||
}
|
||||
|
||||
/** C# project config parsed from .csproj files */
|
||||
export interface CSharpProjectConfig {
|
||||
/** Root namespace from <RootNamespace> or assembly name (default: project directory name) */
|
||||
|
|
@ -161,22 +253,13 @@ export async function loadComposerConfig(repoRoot: string): Promise<ComposerConf
|
|||
try {
|
||||
const composerPath = path.join(repoRoot, 'composer.json');
|
||||
const raw = await fs.readFile(composerPath, 'utf-8');
|
||||
const composer = JSON.parse(raw);
|
||||
const psr4Raw = composer.autoload?.['psr-4'] ?? {};
|
||||
const psr4Dev = composer['autoload-dev']?.['psr-4'] ?? {};
|
||||
const merged = { ...psr4Raw, ...psr4Dev };
|
||||
|
||||
const psr4 = new Map<string, string>();
|
||||
for (const [ns, dir] of Object.entries(merged)) {
|
||||
const nsNorm = (ns as string).replace(/\\+$/, '');
|
||||
const dirNorm = (dir as string).replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
psr4.set(nsNorm, dirNorm);
|
||||
}
|
||||
const config = parseComposerConfig(JSON.parse(raw));
|
||||
if (config === null) return null;
|
||||
|
||||
if (isDev) {
|
||||
logger.info(`📦 Loaded ${psr4.size} PSR-4 mappings from composer.json`);
|
||||
logger.info(`📦 Loaded ${config.psr4.size} PSR-4 mappings from composer.json`);
|
||||
}
|
||||
return { psr4 };
|
||||
return config;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,11 @@ import type { CfgVisitor } from './cfg/types.js';
|
|||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import type { ExtractedRoute } from './route-extractors/laravel.js';
|
||||
import type { SharedSpringType } from './route-extractors/spring-shared.js';
|
||||
import type {
|
||||
ModuleConstants,
|
||||
Operand,
|
||||
RepoConstants,
|
||||
} from './route-extractors/constant-resolver.js';
|
||||
import type Parser from 'tree-sitter';
|
||||
import type { ExtractedDecoratorRoute } from './workers/parse-worker.js';
|
||||
|
||||
|
|
@ -46,6 +51,44 @@ import type { ExtractedDecoratorRoute } from './workers/parse-worker.js';
|
|||
/** Tree-sitter query captures: capture name → AST node (or undefined if not captured). */
|
||||
export type CaptureMap = Record<string, SyntaxNode | undefined>;
|
||||
|
||||
export interface DefinitionPropertiesContext {
|
||||
readonly nodeLabel: NodeLabel;
|
||||
readonly nodeName: string;
|
||||
readonly definitionNode: SyntaxNode;
|
||||
readonly parsedImports: readonly ParsedImport[];
|
||||
readonly isExported: boolean;
|
||||
}
|
||||
|
||||
export type DefinitionPropertiesExtractor = (
|
||||
context: DefinitionPropertiesContext,
|
||||
) => Readonly<Record<string, unknown>> | undefined;
|
||||
|
||||
/** Run optional provider enrichment without allowing one hook failure to drop
|
||||
* the rest of the worker's language batch. */
|
||||
export function runDefinitionPropertiesExtractor(
|
||||
extractor: DefinitionPropertiesExtractor,
|
||||
context: DefinitionPropertiesContext,
|
||||
onError: (error: unknown) => void,
|
||||
): Readonly<Record<string, unknown>> | undefined {
|
||||
try {
|
||||
return extractor(context);
|
||||
} catch (error) {
|
||||
onError(error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Provider metadata is additive; graph identity and source-location fields
|
||||
* supplied by the worker remain authoritative. */
|
||||
export function mergeCanonicalDefinitionProperties<
|
||||
TCanonical extends Readonly<Record<string, unknown>>,
|
||||
>(
|
||||
providerProperties: Readonly<Record<string, unknown>>,
|
||||
canonicalProperties: TCanonical,
|
||||
): Record<string, unknown> & TCanonical {
|
||||
return { ...providerProperties, ...canonicalProperties } as Record<string, unknown> & TCanonical;
|
||||
}
|
||||
|
||||
// ── Strategy tag types ─────────────────────────────────────────────────────
|
||||
// NOTE: `MroStrategy` is defined in `gitnexus-shared` and re-exported above
|
||||
// so `core/ingestion/model/resolve.ts` can consume it without importing from
|
||||
|
|
@ -64,6 +107,25 @@ export interface AstFrameworkPatternConfig {
|
|||
* Required fields must be explicitly set; optional fields have defaults
|
||||
* applied by defineLanguage().
|
||||
*/
|
||||
/**
|
||||
* Should the parse worker run {@link LanguageProviderConfig.extractModuleConstants}
|
||||
* on this file?
|
||||
*
|
||||
* Exported so the DECISION is testable without booting a worker. It encodes the
|
||||
* one rule that is easy to get backwards: a provider that declares no
|
||||
* `moduleConstantHeuristic` harvests unconditionally. Writing the gate as
|
||||
* `provider.moduleConstantHeuristic?.(content)` reads `undefined` as "skip" and
|
||||
* silently disables the hook for every provider without a heuristic — which is
|
||||
* exactly how Python's already-shipped harvest was turned off (#2391/#2980).
|
||||
*/
|
||||
export function shouldHarvestModuleConstants(
|
||||
provider: Pick<LanguageProvider, 'extractModuleConstants' | 'moduleConstantHeuristic'>,
|
||||
content: string,
|
||||
): boolean {
|
||||
if (!provider.extractModuleConstants) return false;
|
||||
return !provider.moduleConstantHeuristic || provider.moduleConstantHeuristic(content);
|
||||
}
|
||||
|
||||
interface LanguageProviderConfig {
|
||||
// ── Identity ──────────────────────────────────────────────────────
|
||||
readonly id: SupportedLanguages;
|
||||
|
|
@ -252,6 +314,10 @@ interface LanguageProviderConfig {
|
|||
* constant, and static declarations. Produces VariableInfo with type, visibility,
|
||||
* isConst, isStatic, isMutable metadata. Default: undefined (no variable extraction). */
|
||||
readonly variableExtractor?: VariableExtractor;
|
||||
/** Add language-owned, structured properties to a definition node. Values
|
||||
* cross the worker boundary and must therefore be structured-clone-safe.
|
||||
* Shared ingestion code treats these properties as opaque. */
|
||||
readonly definitionPropertiesExtractor?: DefinitionPropertiesExtractor;
|
||||
/** Class/type extractor for deriving canonical qualified names for class-like symbols.
|
||||
* Uses the same provider-driven strategy pattern as method/field extraction so
|
||||
* namespace/package/module rules stay language-specific. */
|
||||
|
|
@ -336,6 +402,58 @@ interface LanguageProviderConfig {
|
|||
filePath: string,
|
||||
) => SharedSpringType[];
|
||||
|
||||
/**
|
||||
* Harvest this file's module-level string constants (#2391 core, #2980 Java
|
||||
* parity) into the language-agnostic {@link ModuleConstants} shape, so the
|
||||
* parse phase can resolve non-literal decorator route paths cross-file.
|
||||
*
|
||||
* The worker calls this when BOTH hold:
|
||||
* - the provider declares no `moduleConstantHeuristic`, or the one it
|
||||
* declares matched — syntax-driven, e.g. a `static final String` field or
|
||||
* a constants-bearing import; NEVER a class-name pattern like
|
||||
* `*Constants`, which silently drops route constants living in classes
|
||||
* named e.g. `ApiPaths`/`Routes`, and
|
||||
* - the extraction yields something resolvable (a literal, an expression, or
|
||||
* an import binding), keeping the aggregate bounded on large repos.
|
||||
*
|
||||
* Default: undefined (no constant harvest; non-literal route paths of this
|
||||
* language floor to skip).
|
||||
*/
|
||||
readonly extractModuleConstants?: (tree: Parser.Tree) => ModuleConstants;
|
||||
|
||||
/**
|
||||
* Cheap content heuristic deciding whether the worker should run
|
||||
* {@link extractModuleConstants} on a file. Guards the harvest cost on huge
|
||||
* repos: files that cannot contribute (no constant-bearing syntax) are not
|
||||
* walked. Must be syntax-driven (field/import shape), not identifier
|
||||
* pattern-matching on class names.
|
||||
*
|
||||
* Default: undefined — harvest EVERY file of this language. A gate is opt-in
|
||||
* because getting it wrong silently drops routes that already resolve, and a
|
||||
* missed gate only costs time. Declare one only where the cost bites (Java's
|
||||
* Maven monorepos) and only after checking it against every shape
|
||||
* {@link extractModuleConstants} accepts.
|
||||
*/
|
||||
readonly moduleConstantHeuristic?: (content: string) => boolean;
|
||||
|
||||
/**
|
||||
* Fold one file's non-literal route-path operand list
|
||||
* (`routePathExpr`/`routePathOperands` of an `ExtractedDecoratorRoute`)
|
||||
* against the repo-wide, file-path-keyed constant map, or null when it cannot
|
||||
* be fully folded (skip floor — never a phantom path). Languages whose
|
||||
* qualified refs resolve through class imports (`Outer.CONST`,
|
||||
* `com.example.ApiPaths.USERS`) need this hook because the shared fold has no
|
||||
* notion of qualified names; Python's bare-name refs use the shared default.
|
||||
*
|
||||
* Default: undefined (the parse phase falls back to the shared
|
||||
* language-agnostic operand fold).
|
||||
*/
|
||||
readonly foldRoutePathOperands?: (
|
||||
filePath: string,
|
||||
operands: readonly Operand[],
|
||||
repo: RepoConstants,
|
||||
) => string | null;
|
||||
|
||||
// ── Noise filtering ────────────────────────────────────────────────
|
||||
/** Built-in/stdlib names that should be filtered from the call graph for this language.
|
||||
* Default: undefined (no language-specific filtering). */
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@ import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
|||
import { createLeadingDocDescriptionExtractor } from '../utils/ast-helpers.js';
|
||||
import { javaTypeConfig } from '../type-extractors/jvm.js';
|
||||
import { extractSpringRoutes, extractSpringTypes } from '../route-extractors/spring.js';
|
||||
import {
|
||||
extractJavaModuleConstants,
|
||||
foldJavaOperands,
|
||||
isJavaConstantFile,
|
||||
} from '../route-extractors/java-const-resolver.js';
|
||||
import { javaExportChecker } from '../export-detection.js';
|
||||
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
||||
import { javaImportConfig } from '../import-resolvers/configs/jvm.js';
|
||||
|
|
@ -216,4 +221,26 @@ export const javaProvider = defineLanguage({
|
|||
// ── Route extraction ──
|
||||
extractDecoratorRoutes: extractSpringRoutes,
|
||||
extractRouteInheritanceTypes: extractSpringTypes,
|
||||
|
||||
// ── #2980: constant harvest + qualified-ref fold for non-literal mapping
|
||||
// paths (`@PostMapping(ApiPaths.SAVE_V1)`) — kept behind provider hooks so
|
||||
// the shared ingestion layers stay language-agnostic. The heuristic is
|
||||
// SYNTAX-driven (field/import shape), never a class-name pattern: constant
|
||||
// classes are routinely named `ApiPaths`/`Routes`/`Paths`, which a
|
||||
// `*Constants`-style gate would silently drop (review round-2 High finding).
|
||||
extractModuleConstants: extractJavaModuleConstants,
|
||||
// One gate, shared with the group side's `prepareRepo` pre-pass so the two
|
||||
// subsystems cannot disagree about which files define constants (see
|
||||
// JAVA_CONSTANT_FILE_RE — the previous divergence dropped constant
|
||||
// INTERFACES on this side only, which cost the graph its Route nodes while
|
||||
// the group still published the contract).
|
||||
moduleConstantHeuristic: (content) =>
|
||||
isJavaConstantFile(content) ||
|
||||
// `import com.winning.opt.common.ApiPaths;` — ANY class import can bind a
|
||||
// constant ref (`ApiPaths.X` at an annotation site), so gate on the
|
||||
// general import shape, not on the imported name. Ingestion-only: this
|
||||
// side needs the importing controller's own import table, which the group
|
||||
// side instead derives lazily from the tree it already holds.
|
||||
/\bimport\s+(?:static\s+)?[\w.]+\s*;/.test(content),
|
||||
foldRoutePathOperands: foldJavaOperands,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,9 +21,13 @@ import { resolvePhpImportInternal } from '../../import-resolvers/php.js';
|
|||
import type { SuffixIndex } from '../../import-resolvers/utils.js';
|
||||
import { perFileSet } from '../../import-resolvers/per-file-set.js';
|
||||
import { getWorkspaceFileIndex } from '../../import-resolvers/workspace-file-index.js';
|
||||
import type { ComposerConfig } from '../../language-config.js';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
mergeComposerConfigs,
|
||||
parseComposerConfig,
|
||||
type ComposerConfig,
|
||||
} from '../../language-config.js';
|
||||
import { readdirSync, readFileSync, type Dirent } from 'node:fs';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
|
||||
export interface PhpResolveContext {
|
||||
readonly fromFile: string;
|
||||
|
|
@ -48,19 +52,18 @@ function namespaceDirectories(
|
|||
|
||||
if (composerConfig === null) return [...directories];
|
||||
|
||||
const normalizedTarget = normalizePhpPath(targetRaw);
|
||||
const normalizedTarget = normalizePhpPath(targetRaw).replace(/^\/+/, '');
|
||||
const mappings = [...composerConfig.psr4.entries()].sort((left, right) => {
|
||||
const lengthDifference = right[0].length - left[0].length;
|
||||
return lengthDifference !== 0 ? lengthDifference : left[0].localeCompare(right[0]);
|
||||
});
|
||||
for (const [namespacePrefix, directoryPrefix] of mappings) {
|
||||
const normalizedPrefix = normalizePhpPath(namespacePrefix);
|
||||
if (
|
||||
normalizedTarget !== normalizedPrefix &&
|
||||
!normalizedTarget.startsWith(`${normalizedPrefix}/`)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const matchesNamespace =
|
||||
normalizedPrefix === '' ||
|
||||
normalizedTarget === normalizedPrefix ||
|
||||
normalizedTarget.startsWith(`${normalizedPrefix}/`);
|
||||
if (!matchesNamespace) continue;
|
||||
|
||||
const remainder = normalizedTarget.slice(normalizedPrefix.length).replace(/^\//, '');
|
||||
const separator = remainder.lastIndexOf('/');
|
||||
|
|
@ -82,21 +85,11 @@ function parentDirectory(filePath: string): string {
|
|||
}
|
||||
|
||||
function directoryAliases(filePath: string): string[] {
|
||||
const normalizedPath = normalizePhpPath(filePath);
|
||||
const separator = normalizedPath.lastIndexOf('/');
|
||||
if (separator < 0) return [''];
|
||||
|
||||
const parent = normalizedPath.slice(0, separator);
|
||||
const aliases = new Set([parent]);
|
||||
const segments = parent.split('/').filter(Boolean);
|
||||
for (let index = 0; index < segments.length; index++) {
|
||||
aliases.add(segments.slice(index).join('/'));
|
||||
}
|
||||
return [...aliases];
|
||||
return [parentDirectory(filePath)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Directory alias → the files under it, built once per pass.
|
||||
* Exact repository-relative directory → the files under it, built once per pass.
|
||||
*
|
||||
* A scope-resolution pass shares one stable `parsedFiles` array across imports,
|
||||
* so the array identity is the memo key — see `perFileSet`.
|
||||
|
|
@ -302,42 +295,67 @@ const getPhpWorkspaceIndex = perFileSet((allFilePaths: ReadonlySet<string>): Php
|
|||
// ─── loadResolutionConfig ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load and parse `composer.json` from the repo root. Returns a
|
||||
* `ComposerConfig` object (PSR-4 namespace → directory mappings) or
|
||||
* `null` when no `composer.json` is present or it cannot be parsed.
|
||||
* Load and parse repository and package-local `composer.json` manifests.
|
||||
* Package mappings are rebased to repository-relative paths before merging.
|
||||
*
|
||||
* The result is threaded into each `resolvePhpImportInternal` call as
|
||||
* the `composerConfig` argument.
|
||||
*/
|
||||
export function loadPhpComposerConfig(repoPath: string): ComposerConfig | null {
|
||||
try {
|
||||
const composerPath = join(repoPath, 'composer.json');
|
||||
const raw = readFileSync(composerPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (typeof parsed !== 'object' || parsed === null) return null;
|
||||
const skipDirectories = new Set([
|
||||
'.git',
|
||||
'.gitnexus',
|
||||
'node_modules',
|
||||
'vendor',
|
||||
'dist',
|
||||
'build',
|
||||
'coverage',
|
||||
]);
|
||||
const pending = [repoPath];
|
||||
const manifests: string[] = [];
|
||||
let incomplete = false;
|
||||
let visitedDirectories = 0;
|
||||
|
||||
const composer = parsed as Record<string, unknown>;
|
||||
const autoload = composer['autoload'] as Record<string, unknown> | undefined;
|
||||
if (autoload === undefined) return null;
|
||||
|
||||
const psr4Raw = (autoload['psr-4'] ?? {}) as Record<string, string | string[]>;
|
||||
const psr4 = new Map<string, string>();
|
||||
|
||||
for (const [ns, dirs] of Object.entries(psr4Raw)) {
|
||||
// namespace prefix ends with `\` — keep as-is; resolver strips it
|
||||
const normalizedNs = ns.replace(/\\$/, '');
|
||||
const dir = Array.isArray(dirs) ? dirs[0] : dirs;
|
||||
if (typeof dir === 'string') {
|
||||
// Normalize directory path (strip trailing slash)
|
||||
const normalizedDir = dir.replace(/\/+$/, '');
|
||||
psr4.set(normalizedNs, normalizedDir);
|
||||
while (pending.length > 0) {
|
||||
const directory = pending.pop();
|
||||
if (directory === undefined) break;
|
||||
if (++visitedDirectories > 20_000) {
|
||||
incomplete = true;
|
||||
break;
|
||||
}
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = readdirSync(directory, { withFileTypes: true }).sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
);
|
||||
} catch {
|
||||
incomplete = true;
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && entry.name === 'composer.json') {
|
||||
manifests.push(join(directory, entry.name));
|
||||
} else if (entry.isDirectory() && !skipDirectories.has(entry.name)) {
|
||||
pending.push(join(directory, entry.name));
|
||||
}
|
||||
}
|
||||
|
||||
return { psr4 };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const configs: ComposerConfig[] = [];
|
||||
for (const manifest of manifests.sort()) {
|
||||
try {
|
||||
const baseDir = normalizePhpPath(relative(repoPath, dirname(manifest)));
|
||||
const config = parseComposerConfig(JSON.parse(readFileSync(manifest, 'utf8')), baseDir);
|
||||
if (config !== null) configs.push(config);
|
||||
} catch {
|
||||
incomplete = true;
|
||||
}
|
||||
}
|
||||
|
||||
const merged = mergeComposerConfigs(configs);
|
||||
if (merged === null) return null;
|
||||
if (incomplete) merged.hasUnmodeledAutoload = true;
|
||||
return merged;
|
||||
}
|
||||
|
||||
// ─── resolvePhpImportTarget ────────────────────────────────────────────────
|
||||
|
|
@ -434,11 +452,7 @@ export function resolvePhpImportTargetInternal(
|
|||
...new Set(
|
||||
directories.flatMap((directory) => {
|
||||
const files = directoryIndex.get(normalizePhpPath(directory)) ?? [];
|
||||
// A suffix alias can match directories under different roots (for
|
||||
// example app/Models and vendor/pkg/app/Models). Picking either root
|
||||
// would be a guess, so fail closed to the composer resolution instead.
|
||||
const distinctParents = new Set(files.map((file) => parentDirectory(file.filePath)));
|
||||
return distinctParents.size > 1 ? [] : files;
|
||||
return files;
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import {
|
|||
} from './python/index.js';
|
||||
import { extractDjangoRoutes } from '../route-extractors/django.js';
|
||||
import { discoverDjangoRootUrls } from '../route-extractors/django-root-discovery.js';
|
||||
import { extractPythonModuleConstants } from '../route-extractors/python-const-resolver.js';
|
||||
|
||||
const BUILT_INS: ReadonlySet<string> = new Set([
|
||||
'print',
|
||||
|
|
@ -158,4 +159,17 @@ export const pythonProvider = defineLanguage({
|
|||
receiverBinding: pythonReceiverBinding,
|
||||
arityCompatibility: pythonArityCompatibility,
|
||||
resolveImportTarget: resolvePythonImportTarget,
|
||||
|
||||
// ── #2391 constant harvest, provider-hook form (#2980): module-level string
|
||||
// constants + from-imports for non-literal decorator route paths. Bare-name
|
||||
// refs fold through the shared resolver (no foldRoutePathOperands needed).
|
||||
// No `moduleConstantHeuristic`: Python harvests unconditionally, exactly as
|
||||
// #2391 shipped it. A content gate was tried here and removed on review — it
|
||||
// required `NAME` immediately followed by `=`, so it silently dropped the two
|
||||
// idiomatic typed-FastAPI shapes (`API: str = "/api"`,
|
||||
// `API: Final[str] = "/api"`) and every composed constant whose RHS starts
|
||||
// with an identifier (`USERS = BASE + "/users"`), i.e. it REGRESSED routes
|
||||
// that already resolve on main. The worker treats a missing heuristic as
|
||||
// default-open; only Java opts into a gate, where the cost actually bites.
|
||||
extractModuleConstants: extractPythonModuleConstants,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ import {
|
|||
import { extractDispatchGuardRoutes } from '../route-extractors/dispatch-guard.js';
|
||||
import { extractDataRouteTableRoutes } from '../route-extractors/data-route-table.js';
|
||||
import { extractNestRoutes } from '../route-extractors/nest.js';
|
||||
import { extractConvexEndpointProperties } from './typescript/convex-endpoint-metadata.js';
|
||||
|
||||
const extractJsTsRoutes = (...args: Parameters<typeof extractDispatchGuardRoutes>) => [
|
||||
...extractDispatchGuardRoutes(...args),
|
||||
|
|
@ -420,6 +421,7 @@ export const typescriptProvider = defineLanguage({
|
|||
extractFunctionName: tsExtractFunctionName,
|
||||
}),
|
||||
variableExtractor: createVariableExtractor(typescriptVariableConfig),
|
||||
definitionPropertiesExtractor: extractConvexEndpointProperties,
|
||||
classExtractor: createClassExtractor(typescriptClassConfig),
|
||||
// ── JSDoc → description (issue #2270). An exported decl is captured as the
|
||||
// inner declaration; its JSDoc precedes the wrapping `export_statement`. ──
|
||||
|
|
@ -507,6 +509,7 @@ export const javascriptProvider = defineLanguage({
|
|||
extractFunctionName: tsExtractFunctionName,
|
||||
}),
|
||||
variableExtractor: createVariableExtractor(javascriptVariableConfig),
|
||||
definitionPropertiesExtractor: extractConvexEndpointProperties,
|
||||
classExtractor: createClassExtractor(javascriptClassConfig),
|
||||
// ── JSDoc → description (issue #2270). An exported decl is captured as the
|
||||
// inner declaration; its JSDoc precedes the wrapping `export_statement`. ──
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
import type { ParsedImport } from 'gitnexus-shared';
|
||||
import type { DefinitionPropertiesContext } from '../../language-provider.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { assertCloneable } from '../../workers/clone-safety.js';
|
||||
|
||||
const GENERATED_ENDPOINT_FACTORIES: ReadonlySet<string> = new Set([
|
||||
'query',
|
||||
'mutation',
|
||||
'action',
|
||||
'internalQuery',
|
||||
'internalMutation',
|
||||
'internalAction',
|
||||
'httpAction',
|
||||
]);
|
||||
|
||||
const GENERIC_ENDPOINT_FACTORIES: ReadonlyMap<string, string> = new Map(
|
||||
[...GENERATED_ENDPOINT_FACTORIES].map((factory) => [`${factory}Generic`, factory]),
|
||||
);
|
||||
|
||||
const normalizeModuleTarget = (targetRaw: string): string =>
|
||||
targetRaw.replace(/\\/g, '/').replace(/\.(?:[cm]?[jt]s)$/, '');
|
||||
|
||||
const isGeneratedServerModule = (targetRaw: string): boolean =>
|
||||
/(?:^|\/)_generated\/server$/.test(normalizeModuleTarget(targetRaw));
|
||||
|
||||
function importedConvexFactory(
|
||||
imports: readonly ParsedImport[],
|
||||
localName: string,
|
||||
): string | undefined {
|
||||
for (const parsedImport of imports) {
|
||||
if (parsedImport.kind !== 'named' && parsedImport.kind !== 'alias') continue;
|
||||
if (parsedImport.localName !== localName) continue;
|
||||
|
||||
const target = normalizeModuleTarget(parsedImport.targetRaw);
|
||||
if (target === 'convex/server') {
|
||||
return GENERIC_ENDPOINT_FACTORIES.get(parsedImport.importedName);
|
||||
}
|
||||
if (isGeneratedServerModule(target)) {
|
||||
return GENERATED_ENDPOINT_FACTORIES.has(parsedImport.importedName)
|
||||
? parsedImport.importedName
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function matchingDeclarator(node: SyntaxNode, nodeName: string): SyntaxNode | undefined {
|
||||
if (node.type === 'variable_declarator' && node.childForFieldName('name')?.text === nodeName) {
|
||||
return node;
|
||||
}
|
||||
|
||||
if (node.type === 'export_statement') {
|
||||
const declaration = node.childForFieldName('declaration');
|
||||
return declaration ? matchingDeclarator(declaration, nodeName) : undefined;
|
||||
}
|
||||
if (node.type !== 'lexical_declaration' && node.type !== 'variable_declaration') {
|
||||
return undefined;
|
||||
}
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (
|
||||
child?.type === 'variable_declarator' &&
|
||||
child.childForFieldName('name')?.text === nodeName
|
||||
) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findDeclarator(node: SyntaxNode, nodeName: string): SyntaxNode | undefined {
|
||||
let current: SyntaxNode | null = node;
|
||||
while (current) {
|
||||
const declarator = matchingDeclarator(current, nodeName);
|
||||
if (declarator) return declarator;
|
||||
if (current.type === 'program' || current.type === 'statement_block') break;
|
||||
current = current.parent;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp Convex runtime-dispatch metadata only when both the declaration shape
|
||||
* and the factory import provenance are known. The MCP layer consumes the
|
||||
* resulting property without reparsing lossy FTS text.
|
||||
*/
|
||||
export function extractConvexEndpointProperties(
|
||||
context: DefinitionPropertiesContext,
|
||||
): Readonly<Record<string, unknown>> | undefined {
|
||||
if ((context.nodeLabel !== 'Const' && context.nodeLabel !== 'Function') || !context.isExported) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const declarator = findDeclarator(context.definitionNode, context.nodeName);
|
||||
const value = declarator?.childForFieldName('value');
|
||||
if (!value || value.type !== 'call_expression') return undefined;
|
||||
|
||||
const callee = value.childForFieldName('function');
|
||||
if (!callee || callee.type !== 'identifier') return undefined;
|
||||
const factory = importedConvexFactory(context.parsedImports, callee.text);
|
||||
if (factory === undefined) return undefined;
|
||||
|
||||
const args = value.childForFieldName('arguments');
|
||||
if (!args || args.namedChildCount !== 1) return undefined;
|
||||
const endpointDefinition = args.namedChild(0);
|
||||
if (
|
||||
!endpointDefinition ||
|
||||
!['object', 'arrow_function', 'function_expression'].includes(endpointDefinition.type)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return assertCloneable({ convexEndpointFactory: factory });
|
||||
}
|
||||
|
|
@ -60,7 +60,7 @@ import {
|
|||
createParserForLanguage,
|
||||
} from '../../tree-sitter/parser-loader.js';
|
||||
import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
|
||||
import { getProvider, providers } from '../languages/index.js';
|
||||
import { getProvider, getProviderForFile, providers } from '../languages/index.js';
|
||||
import { SCOPE_RESOLVERS } from '../scope-resolution/pipeline/registry.js';
|
||||
import { DATA_ROUTE_TABLE_SOURCE } from '../route-extractors/data-route-table.js';
|
||||
import type Parser from 'tree-sitter';
|
||||
|
|
@ -1303,8 +1303,15 @@ export async function runChunkedParseAndResolve(
|
|||
resolvedRoutes.push(dr);
|
||||
continue;
|
||||
}
|
||||
// Provider-driven fold (#2980): languages with qualified-ref semantics
|
||||
// (Java `ApiPaths.X` / `com.example.ApiPaths.X`) fold through their
|
||||
// provider hook; everything else uses the shared language-agnostic
|
||||
// operand fold. No language names in the shared layer.
|
||||
const fold = getProviderForFile(dr.filePath)?.foldRoutePathOperands;
|
||||
const value = dr.routePathOperands
|
||||
? resolveOperands(dr.filePath, dr.routePathOperands, repoConstants)
|
||||
? fold
|
||||
? fold(dr.filePath, dr.routePathOperands, repoConstants)
|
||||
: resolveOperands(dr.filePath, dr.routePathOperands, repoConstants)
|
||||
: null;
|
||||
if (value === null) {
|
||||
skipped++;
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ const MAX_RESOLVE_DEPTH = 8;
|
|||
* whose true value is genuinely huge — building it risks a `RangeError`/heap OOM,
|
||||
* so we floor to `null` (skip) instead (#2393). The depth cap bounds recursion but
|
||||
* NOT output size, which grows multiplicatively; this bounds the output. */
|
||||
const MAX_FOLD_LENGTH = 8192;
|
||||
export const MAX_FOLD_LENGTH = 8192;
|
||||
|
||||
/**
|
||||
* One term of a constant's right-hand side. A `+`-concatenation
|
||||
|
|
@ -175,10 +175,18 @@ function computeFold(
|
|||
return null;
|
||||
}
|
||||
|
||||
function newState(repo: RepoConstants, resolveImport: ImportResolver): ResolveState {
|
||||
function newState(
|
||||
repo: RepoConstants,
|
||||
resolveImport: ImportResolver,
|
||||
repoKeys?: ReadonlySet<string>,
|
||||
): ResolveState {
|
||||
return {
|
||||
repo,
|
||||
repoKeys: new Set(repo.keys()),
|
||||
// Materializing the key set here is O(files), and this runs once per fold —
|
||||
// which is once per import hop, not once per scan. A binding that already
|
||||
// holds the set (every one of them does; it is a projection of the same map
|
||||
// it builds `repo` from) passes it in and skips the copy entirely.
|
||||
repoKeys: repoKeys ?? new Set(repo.keys()),
|
||||
resolveImport,
|
||||
visited: new Set(),
|
||||
memo: new Map(),
|
||||
|
|
@ -195,8 +203,9 @@ export function resolveConstant(
|
|||
name: string,
|
||||
repo: RepoConstants,
|
||||
resolveImport: ImportResolver,
|
||||
repoKeys?: ReadonlySet<string>,
|
||||
): string | null {
|
||||
return foldName(fileKey, name, newState(repo, resolveImport), 0);
|
||||
return foldName(fileKey, name, newState(repo, resolveImport, repoKeys), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -209,6 +218,7 @@ export function resolveOperands(
|
|||
operands: readonly Operand[],
|
||||
repo: RepoConstants,
|
||||
resolveImport: ImportResolver,
|
||||
repoKeys?: ReadonlySet<string>,
|
||||
): string | null {
|
||||
return foldExpr(fileKey, operands, newState(repo, resolveImport), 0);
|
||||
return foldExpr(fileKey, operands, newState(repo, resolveImport, repoKeys), 0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,630 @@
|
|||
/**
|
||||
* Java binding for the language-agnostic constant resolver (#2391 core).
|
||||
*
|
||||
* Supplies the two Java-specific pieces the shared fold in
|
||||
* `constant-resolver.ts` needs — {@link resolveJavaImport} (import-specifier →
|
||||
* file, honoring JVM package/classpath rules) and
|
||||
* {@link extractJavaModuleConstants} (tree → {@link ModuleConstants}) — plus a
|
||||
* pre-bound {@link resolveJavaConstant} wrapper so callers stay
|
||||
* language-oblivious. The reusable fold, the cycle guard, and the depth cap
|
||||
* all live in the agnostic core.
|
||||
*
|
||||
* Java constant shape (one per type declaration; nested classes flatten into
|
||||
* the same file-level namespace, mirroring how `Outer.CONST` and a top-level
|
||||
* `CONST` are indistinguishable at the fold layer):
|
||||
*
|
||||
* public class ApiPathConstants {
|
||||
* public static final String DIAGNOSIS_SAVE_V1 = "/api/v1/diagnosis/add";
|
||||
* public static final String API_CIS_SAVE_SUMMARY = API_CIS_V1 + "summary/save";
|
||||
* }
|
||||
*
|
||||
* Reference shapes at annotation sites this binding resolves:
|
||||
* @PostMapping(ApiPathConstants.DIAGNOSIS_SAVE_V1) // qualified
|
||||
* @PostMapping(com.winning.opt.X.ApiPathConstants.Y) // FQN-qualified
|
||||
* @PostMapping(DIAGNOSIS_SAVE_V1) // static-imported
|
||||
* @PostMapping(API_CIS_V1 + "summary/save") // inline concat
|
||||
*
|
||||
* Which ANNOTATIONS count as routes is a separate question this module has no
|
||||
* say in: `spring-shared.ts` holds an exact-name map, so a vendor alias like
|
||||
* `@WinPostMapping` yields no route on this base regardless of how its value
|
||||
* folds (#2883). Folding and alias recognition compose; neither implies the
|
||||
* other.
|
||||
*
|
||||
* Import shapes consumed:
|
||||
* import com.winning.opt.diagnosis.api.constants.ApiPathConstants;
|
||||
* import static com.winning.opt.diagnosis.api.constants.ApiPathConstants.API_CIS_V1;
|
||||
*
|
||||
* Keying (KTD4 parity with the Python binding): the repo map is keyed by
|
||||
* unique POSIX file path. A Java import `com.a.b.CONSTS` resolves to the file
|
||||
* whose path ends with `com/a/b/CONSTS.java`; when 2+ files share that suffix
|
||||
* the import is ambiguous and returns null (skip floor), never a wrong path.
|
||||
*/
|
||||
|
||||
import type Parser from 'tree-sitter';
|
||||
import { unquoteSpringLiteral } from './spring-shared.js';
|
||||
import {
|
||||
MAX_FOLD_LENGTH,
|
||||
type ImportBinding,
|
||||
type ImportResolver,
|
||||
type ModuleConstants,
|
||||
type Operand,
|
||||
type RepoConstants,
|
||||
} from './constant-resolver.js';
|
||||
|
||||
export type {
|
||||
ImportBinding,
|
||||
ModuleConstants,
|
||||
Operand,
|
||||
RepoConstants,
|
||||
} from './constant-resolver.js';
|
||||
|
||||
/**
|
||||
* Cheap content gate: can this Java file DEFINE a string constant that a route
|
||||
* annotation might reference?
|
||||
*
|
||||
* Exported so BOTH sides of the pipeline use the same predicate and cannot
|
||||
* disagree about which files carry constants — the ingestion provider
|
||||
* (`languages/java.ts`, as `moduleConstantHeuristic`) and the group extractor's
|
||||
* `prepareRepo` pre-pass (`group/extractors/http-patterns/java.ts`). They used
|
||||
* to spell it differently, and the two spellings disagreed on a constant
|
||||
* INTERFACE: the group admitted it and published a provider contract at the
|
||||
* folded path, while ingestion rejected the file and emitted no Route node for
|
||||
* it — an R4 parity break in the losing direction, since ingestion is the side
|
||||
* that drives the graph and `api_impact`.
|
||||
*
|
||||
* Arms:
|
||||
* - a `static` … `String NAME =` declaration, with the modifier run matched as
|
||||
* a span so every legal order works (`static public final String`,
|
||||
* `public final static String`) and so `java.lang.String` — which the
|
||||
* extractor accepts — is admitted too.
|
||||
* - an `interface` declaration carrying a String assignment — interface fields
|
||||
* are implicitly `public static final` (JLS 9.3), so a pure constant
|
||||
* interface has neither keyword and no import. The assignment conjunct keeps
|
||||
* a file whose PROSE merely mentions "interface " from costing a parse.
|
||||
*/
|
||||
// `static` … `String NAME =` on one declaration. The modifier run is matched as
|
||||
// a span rather than as the adjacent pair `static final`, because the extractor
|
||||
// scans modifiers INDEPENDENTLY (`isStaticFinal`) and Java lets them appear in
|
||||
// any order — `static public final String`, `public final static String` — and
|
||||
// because the type may be written out as `java.lang.String`, which the
|
||||
// extractor also accepts. A gate narrower than the extractor it feeds is the
|
||||
// same defect class as the ingestion/group divergence this predicate exists to
|
||||
// prevent, just one layer down.
|
||||
//
|
||||
// The span excludes `;{}()` so it cannot jump a statement or block boundary: a
|
||||
// local `String s = "x"` inside `static void f() { … }` is not matched, because
|
||||
// reaching it from `static` crosses `(`, `)` and `{`. `final` is not required
|
||||
// even though the extractor requires it — the gate may be wider than the
|
||||
// extractor, never narrower.
|
||||
const STATIC_STRING_CONSTANT_RE = /\bstatic\b[^;{}()]{0,80}\bString\s+\w+\s*=/;
|
||||
const INTERFACE_DECL_RE = /\binterface\s+\w/;
|
||||
const STRING_ASSIGNMENT_RE = /\bString\s+\w+\s*=/;
|
||||
|
||||
export function isJavaConstantFile(source: string): boolean {
|
||||
if (STATIC_STRING_CONSTANT_RE.test(source)) return true;
|
||||
// The interface arm is a bare word match, so on its own it admits any file
|
||||
// whose PROSE mentions "interface " — and every admitted file costs the group
|
||||
// side a full extra parse. Requiring a String assignment as well keeps every
|
||||
// shape `extractJavaModuleConstants` accepts in an interface body (bare
|
||||
// `String`, `java.lang.String`, no space before `=`, multi-declarator) while
|
||||
// dropping the comment-only matches.
|
||||
return INTERFACE_DECL_RE.test(source) && STRING_ASSIGNMENT_RE.test(source);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Java {@link ImportResolver}: map a fully-qualified import specifier to
|
||||
* the unique file key it refers to, or null when it cannot be pinned to
|
||||
* exactly one file.
|
||||
*
|
||||
* `com.winning.opt.X.ApiPathConstants` → the file key ending in
|
||||
* `com/winning/opt/X/ApiPathConstants.java`. Because the repo map is
|
||||
* file-path-keyed and Maven multi-module trees repeat package roots across
|
||||
* modules (`winning-opt-a/.../api/constants/ApiPathConstants.java` and
|
||||
* `winning-opt-b/.../api/constants/ApiPathConstants.java`), suffix matching
|
||||
* stays UNIQUE-suffix: an import whose full package+class path matches N files
|
||||
* in N different modules cannot be pinned, so it returns null — the skip floor
|
||||
* this module promises, never a wrong path.
|
||||
*
|
||||
* A nearest-shared-directory tie-break was tried here and removed on review:
|
||||
* javac resolves duplicate FQNs by CLASSPATH ORDER, not directory proximity, so
|
||||
* a `src/test` fixture copy or a module that merely sits closer in the tree can
|
||||
* outrank the real dependency and yield a silently wrong literal. In a resolver
|
||||
* whose whole contract is skip-or-correct, a plausible guess is the one answer
|
||||
* that cannot be allowed.
|
||||
*/
|
||||
export const resolveJavaImport: ImportResolver = (_importingFileKey, moduleSpec, repoKeys) => {
|
||||
// A static import `a.b.C.CONST` names the class as all-but-last segment;
|
||||
// a plain import `a.b.C` names the class as last segment. Both resolve to
|
||||
// a file ending `a/b/C.java`; treating the whole spec as a path and
|
||||
// trimming the last segment when the direct hit fails covers both shapes.
|
||||
const asPath = moduleSpec.replace(/\./g, '/');
|
||||
const classFile = `${asPath}.java`;
|
||||
|
||||
// Exact package-path suffix match, unique or nothing.
|
||||
let hit: string | null = null;
|
||||
for (const key of repoKeys) {
|
||||
if (key === classFile || key.endsWith(`/${classFile}`)) {
|
||||
if (hit !== null) return null; // 2+ modules carry this FQN — unresolvable
|
||||
hit = key;
|
||||
}
|
||||
}
|
||||
return hit;
|
||||
};
|
||||
|
||||
/**
|
||||
* Is `node` a Java string literal (`"..."`), and if so what value does the
|
||||
* route layer give it?
|
||||
*
|
||||
* tree-sitter-java splits a `string_literal` AROUND its `escape_sequence`
|
||||
* children, so joining `string_fragment`s alone silently DELETES every escape:
|
||||
* `"/user/{id:\\d+}"` — the standard Spring path-variable regex constraint —
|
||||
* folded to `/user/{id:d+}`, and a pure-escape literal (`"\\t"`) folded to the
|
||||
* empty string. Slicing the quotes off the raw text keeps the source spelling,
|
||||
* which is precisely what the LITERAL path does
|
||||
* ({@link unquoteSpringLiteral}) — so `@GetMapping(ApiPaths.USER_REGEX)` and
|
||||
* `@GetMapping("/user/{id:\\d+}")` now emit the same path for the same Java
|
||||
* source instead of two spellings the graph cannot reconcile. Same
|
||||
* `string_fragment`-join trap as the NestJS one in #3017.
|
||||
*/
|
||||
function stringLiteralValue(node: Parser.SyntaxNode): string | null {
|
||||
if (node.type !== 'string_literal') return null;
|
||||
// A Java text block is also a `string_literal` here, and `unquoteSpringLiteral`
|
||||
// has a `"""` arm that would hand back the raw block — leading newline and
|
||||
// incidental indentation included, both of which Java strips. Nothing
|
||||
// downstream normalizes that, so it would publish a Route at a path like
|
||||
// "\n /api/v1/x\n ". The old fragment-join returned '' here, which
|
||||
// floored to skip; keep that floor rather than trade it for a wrong path.
|
||||
if (node.text.startsWith('"""')) return null;
|
||||
return unquoteSpringLiteral(node.text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a qualified-name expression (`ApiPaths`, `com.example.ApiPaths`) to
|
||||
* its dotted text, or null when any segment is not a plain identifier (calls,
|
||||
* `this`, array access, generics — not a static constant shape).
|
||||
*/
|
||||
function flattenQualifiedIdentifier(node: Parser.SyntaxNode): string | null {
|
||||
if (node.type === 'identifier') return node.text;
|
||||
if (node.type === 'field_access') {
|
||||
const object = node.childForFieldName('object');
|
||||
const field = node.childForFieldName('field');
|
||||
if (object && field) {
|
||||
const head = flattenQualifiedIdentifier(object);
|
||||
return head === null ? null : `${head}.${field.text}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Java constant initializer into an operand list, or null when it is
|
||||
* not a foldable string expression. Handles a bare string literal, a bare
|
||||
* identifier (`X = Y`), qualified/static-import-free references
|
||||
* (`X = CONSTS.Y` — recorded as ONE ref named `CONSTS.Y`), and
|
||||
* left-associative `+` chains of the three. Everything else — numbers, calls,
|
||||
* ternaries, method refs, `String.format`, enum constants — returns null,
|
||||
* which makes the constant unresolvable (→ skip floor), never a wrong value.
|
||||
*/
|
||||
export function parseJavaConstOperands(
|
||||
node: Parser.SyntaxNode | null | undefined,
|
||||
depth = 0,
|
||||
): Operand[] | null {
|
||||
if (!node) return null;
|
||||
if (depth > 64) return null;
|
||||
if (node.type === 'string_literal') {
|
||||
const value = stringLiteralValue(node);
|
||||
return value === null ? null : [{ kind: 'literal', value }];
|
||||
}
|
||||
if (node.type === 'identifier') {
|
||||
return [{ kind: 'ref', name: node.text }];
|
||||
}
|
||||
// `CONSTS.FIELD` — field_access in tree-sitter-java for expressions. The
|
||||
// object side may itself be a chain (`com.example.ApiPaths` parses as
|
||||
// nested field_access), so flatten recursively: every segment must be a
|
||||
// plain identifier/keyword to qualify (a call `f().X`, `this.X`, or an
|
||||
// array access object side is not a constant shape → null, skip floor).
|
||||
if (node.type === 'field_access') {
|
||||
const object = node.childForFieldName('object');
|
||||
const field = node.childForFieldName('field');
|
||||
if (object && field) {
|
||||
const objectName = flattenQualifiedIdentifier(object);
|
||||
if (objectName !== null) return [{ kind: 'ref', name: `${objectName}.${field.text}` }];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (node.type === 'binary_expression') {
|
||||
const isPlus = (node.children ?? []).some((c) => c.type === '+');
|
||||
if (!isPlus) return null;
|
||||
const left = parseJavaConstOperands(node.childForFieldName('left'), depth + 1);
|
||||
const right = parseJavaConstOperands(node.childForFieldName('right'), depth + 1);
|
||||
if (left === null || right === null) return null;
|
||||
return [...left, ...right];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the file-level string constants and import bindings of one parsed
|
||||
* Java file into the {@link ModuleConstants} shape the resolver consumes.
|
||||
*
|
||||
* Constants: every `static final String NAME = …` field of every type
|
||||
* declaration in the file (nested classes included — their simple names
|
||||
* would collide at the fold layer, but qualified refs carry the class name
|
||||
* so nesting only matters for same-name fields, which flatten last-wins).
|
||||
* Interface constants (`String NAME = "…"`) are implicitly static final and
|
||||
* are collected too.
|
||||
*
|
||||
* References to OTHER constants via qualified names (`ApiPathConstants.X`)
|
||||
* are stored as refs named `ApiPathConstants.X`; at the fold layer such a ref
|
||||
* resolves through the import map (`ApiPathConstants` → module) followed by
|
||||
* field lookup in the target file's OWN class-name-qualified namespace. To
|
||||
* support that, constant names are ALSO recorded under
|
||||
* `<DeclaringClass>.<FIELD>` (both spellings share one entry).
|
||||
*
|
||||
* Last-wins in source order; a non-foldable rebind (`X = compute()`) drops X
|
||||
* to unresolvable rather than keeping a stale literal.
|
||||
*/
|
||||
export function extractJavaModuleConstants(tree: Parser.Tree): ModuleConstants {
|
||||
const literals = new Map<string, string>();
|
||||
const exprs = new Map<string, readonly Operand[]>();
|
||||
const imports = new Map<string, ImportBinding>();
|
||||
|
||||
// Pass 1: imports (both shapes).
|
||||
const walkImports = (node: Parser.SyntaxNode): void => {
|
||||
if (node.type === 'import_declaration') {
|
||||
// import a.b.C; | import static a.b.C; | import static a.b.C.F;
|
||||
const isStatic = node.children.some((c) => c.type === 'static' && c.text === 'static');
|
||||
const scoped = node.children.find((c) => c.type === 'scoped_identifier');
|
||||
if (scoped) {
|
||||
const text = scoped.text;
|
||||
const lastDot = text.lastIndexOf('.');
|
||||
const fqn = text.slice(0, lastDot);
|
||||
const name = text.slice(lastDot + 1);
|
||||
if (isStatic) {
|
||||
// import static a.b.C.F → local F from module a.b.C, original F.
|
||||
imports.set(name, { module: fqn, originalName: name });
|
||||
} else {
|
||||
// import a.b.C → module IS the class FQN; originalName is the class
|
||||
// simple name. resolveJavaImport maps `a.b.C` → `a/b/C.java`.
|
||||
imports.set(name, { module: text, originalName: name });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const child of node.children ?? []) walkImports(child);
|
||||
};
|
||||
walkImports(tree.rootNode);
|
||||
|
||||
// Pass 2: constants. A field declaration is a constant when it is
|
||||
// `static final` (explicit) or inside an interface (implicit).
|
||||
const isStaticFinal = (modifiers: Parser.SyntaxNode | null | undefined): boolean => {
|
||||
if (!modifiers) return false;
|
||||
let sawStatic = false;
|
||||
let sawFinal = false;
|
||||
for (const m of modifiers.children ?? []) {
|
||||
if (m.type === 'static') sawStatic = true;
|
||||
if (m.type === 'final') sawFinal = true;
|
||||
}
|
||||
return sawStatic && sawFinal;
|
||||
};
|
||||
|
||||
const collectFieldConstants = (
|
||||
classBody: Parser.SyntaxNode,
|
||||
insideInterface: boolean,
|
||||
declaringClass: string | null,
|
||||
): void => {
|
||||
for (const member of classBody.children ?? []) {
|
||||
// tree-sitter-java: interface fields are `constant_declaration`, class
|
||||
// fields are `field_declaration`. Both carry `variable_declarator`s.
|
||||
if (member.type !== 'field_declaration' && member.type !== 'constant_declaration') continue;
|
||||
const mods = member.children.find((c) => c.type === 'modifiers');
|
||||
if (!insideInterface && !isStaticFinal(mods)) continue;
|
||||
// Type must be String (java.lang.String is implicit-imported).
|
||||
const typeNode = member.childForFieldName('type');
|
||||
if (!typeNode) continue;
|
||||
const typeText = typeNode.text;
|
||||
if (typeText !== 'String' && typeText !== 'java.lang.String') continue;
|
||||
|
||||
const declarators = member.children.filter((c) => c.type === 'variable_declarator');
|
||||
for (const decl of declarators) {
|
||||
const nameNode = decl.childForFieldName('name');
|
||||
const valueNode = decl.childForFieldName('value');
|
||||
if (!nameNode) continue;
|
||||
const name = nameNode.text;
|
||||
const operands = parseJavaConstOperands(valueNode);
|
||||
// Same-name shadowing across nested types (legal Java, unlike
|
||||
// same-class redeclaration): a later binding must REPLACE the earlier
|
||||
// flattened simple-name entry — including dropping it to unresolvable
|
||||
// when the new initializer is not foldable (`X = compute()`) — rather
|
||||
// than leave the stale outer literal resolvable. Skip floor, mirroring
|
||||
// Python #2391's rebind-drop. Qualified `Class.FIELD` aliases are
|
||||
// per-type-keyed but same-named nested types can still collide, so
|
||||
// they get the same replace/drop treatment.
|
||||
const qname = declaringClass ? `${declaringClass}.${name}` : null;
|
||||
if (operands === null) {
|
||||
literals.delete(name);
|
||||
exprs.delete(name);
|
||||
// …and the static IMPORT of the same simple name. A local
|
||||
// `static final String` shadows `import static a.b.C.PATH` inside
|
||||
// that class (JLS 6.4.1), so the correct answer for a non-foldable
|
||||
// rebind is "unresolvable" — leaving the import alive makes the fold
|
||||
// fall through it (computeFold: literals → exprs → imports) and
|
||||
// return the IMPORTED value, i.e. a wrong path where the skip floor
|
||||
// is owed. #2393's Python defect, reproduced for Java.
|
||||
//
|
||||
// The delete is file-scoped because these maps are (see the header:
|
||||
// nested types flatten into one file-level namespace). So a SIBLING
|
||||
// top-level class in the same file that legitimately uses the import
|
||||
// loses it too and floors to skip, where javac would resolve it.
|
||||
// That direction is the acceptable one — a missing route, not a wrong
|
||||
// one — and the shape (two top-level classes, one shadowing a static
|
||||
// import with a non-foldable initializer) is vanishingly rare next to
|
||||
// the wrong-value it prevents.
|
||||
imports.delete(name);
|
||||
if (qname) {
|
||||
literals.delete(qname);
|
||||
exprs.delete(qname);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const literalValue =
|
||||
operands.length === 1 && operands[0].kind === 'literal'
|
||||
? (operands[0] as { value: string }).value
|
||||
: null;
|
||||
if (literalValue !== null) {
|
||||
literals.set(name, literalValue);
|
||||
exprs.delete(name);
|
||||
} else {
|
||||
exprs.set(name, operands);
|
||||
literals.delete(name);
|
||||
}
|
||||
// Qualified alias: `CONSTS.X` refs (folded refs carry the class name).
|
||||
if (qname) {
|
||||
if (literalValue !== null) {
|
||||
literals.set(qname, literalValue);
|
||||
exprs.delete(qname);
|
||||
} else {
|
||||
exprs.set(qname, operands);
|
||||
literals.delete(qname);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const walkTypes = (node: Parser.SyntaxNode, insideInterface: boolean): void => {
|
||||
for (const child of node.children ?? []) {
|
||||
const isInterface = child.type === 'interface_declaration';
|
||||
// Enums and records are ordinary type declarations for constant
|
||||
// purposes — their fields need an explicit `static final` (JLS 8.9/8.10),
|
||||
// unlike an interface's implicitly-constant ones. They used to be only
|
||||
// RECURSED into, never collected, so a `static final String` declared
|
||||
// directly in an enum or record was silently absent from the map.
|
||||
const isTypeDecl =
|
||||
isInterface ||
|
||||
child.type === 'class_declaration' ||
|
||||
child.type === 'enum_declaration' ||
|
||||
child.type === 'record_declaration';
|
||||
if (!isTypeDecl) {
|
||||
walkTypes(child, insideInterface);
|
||||
continue;
|
||||
}
|
||||
const className = child.childForFieldName('name')?.text ?? null;
|
||||
const body = child.children.find(
|
||||
(c) => c.type === 'class_body' || c.type === 'interface_body' || c.type === 'enum_body',
|
||||
);
|
||||
if (!body) continue;
|
||||
// An enum's members hang one level deeper, under `enum_body_declarations`
|
||||
// (the `enum_body` itself holds only the enum constants).
|
||||
const memberBody = body.children.find((c) => c.type === 'enum_body_declarations') ?? body;
|
||||
// Recompute implicit interface semantics at each type boundary: a
|
||||
// class nested in an interface is a normal class whose fields need
|
||||
// explicit `static final` (JLS 9.5 — only the interface's own fields
|
||||
// are implicitly public static final). Propagating the outer
|
||||
// `insideInterface` flag in would harvest mutable nested fields as
|
||||
// constants and let a same-name nested field shadow a real interface
|
||||
// constant with a stale value.
|
||||
if (className) collectFieldConstants(memberBody, isInterface, className);
|
||||
// Recurse over the WHOLE body, not just `memberBody`: an enum's constants
|
||||
// are siblings of `enum_body_declarations`, so narrowing here dropped any
|
||||
// type nested inside an enum-constant body whenever the enum also had
|
||||
// member declarations. For a class/interface/record the two are the same
|
||||
// node; for an enum `body` is a strict superset, and the extra visit to
|
||||
// `enum_body_declarations` collects nothing twice (collectFieldConstants
|
||||
// is still called on `memberBody` alone).
|
||||
walkTypes(body, isInterface);
|
||||
}
|
||||
};
|
||||
walkTypes(tree.rootNode, false);
|
||||
|
||||
return { literals, exprs, imports: imports as Map<string, ImportBinding> };
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-fold state. Mirrors the guards the agnostic core carries in `foldName`,
|
||||
* which this binding stopped delegating to once it had to resolve qualified
|
||||
* operands itself:
|
||||
*
|
||||
* - `memo` caches SUCCESSES only and is never popped. Without it a
|
||||
* shared-descendant DAG (`X_k = X_{k+1} + X_{k+1}`) re-folds each child once
|
||||
* per reference — O(2^depth) — and {@link MAX_FOLD_LENGTH} cannot save it,
|
||||
* because a chain whose intermediate values are the empty string never
|
||||
* accumulates any output. Measured before this state existed: one route over
|
||||
* a 31-line constants file took 2.7 s at 26 levels and 11 s at 28, on the
|
||||
* main thread, per file. A `null` may be transient (a name that cycles on one
|
||||
* branch can resolve on another), so caching it would be unsound.
|
||||
* - `visited` is the ACTIVE resolution stack, popped on unwind, so diamonds
|
||||
* fold instead of false-cycling while true cycles still terminate.
|
||||
* - `constantKeys` is the candidate set import ambiguity is measured over:
|
||||
* files that actually DEFINE a constant. Handing `resolveJavaImport` every
|
||||
* repo key made the two subsystems disagree — ingestion's map also holds
|
||||
* import-only files (its gate has an import arm), so a duplicate FQN that
|
||||
* defines nothing was invisible to the group and made ingestion alone floor
|
||||
* to skip. Hoisting it also stops rebuilding the set on every qualified ref.
|
||||
*/
|
||||
interface JavaFoldState {
|
||||
readonly repo: RepoConstants;
|
||||
readonly constantKeys: ReadonlySet<string>;
|
||||
readonly visited: Set<string>;
|
||||
readonly memo: Map<string, string>;
|
||||
}
|
||||
|
||||
function newFoldState(repo: RepoConstants): JavaFoldState {
|
||||
const constantKeys = new Set<string>();
|
||||
for (const [key, mc] of repo) {
|
||||
if (mc.literals.size > 0 || mc.exprs.size > 0) constantKeys.add(key);
|
||||
}
|
||||
return { repo, constantKeys, visited: new Set(), memo: new Map() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a single Java constant referenced in `fileKey` to its literal string
|
||||
* value, folding `+` concatenation and following import chains via
|
||||
* {@link resolveJavaImport}, or null when it cannot be fully folded.
|
||||
*
|
||||
* `name` may be simple (`DIAGNOSIS_SAVE_V1`, resolved via static import or
|
||||
* same-file constant) or qualified (`ApiPathConstants.DIAGNOSIS_SAVE_V1`,
|
||||
* resolved via the class import + the target file's qualified alias).
|
||||
*/
|
||||
export function resolveJavaConstant(
|
||||
fileKey: string,
|
||||
name: string,
|
||||
repo: RepoConstants,
|
||||
depth = 0,
|
||||
): string | null {
|
||||
return resolveWithState(fileKey, name, newFoldState(repo), depth);
|
||||
}
|
||||
|
||||
function resolveWithState(
|
||||
fileKey: string,
|
||||
name: string,
|
||||
state: JavaFoldState,
|
||||
depth: number,
|
||||
): string | null {
|
||||
if (depth > 32) return null;
|
||||
const guard = `${fileKey}::${name}`;
|
||||
const memoized = state.memo.get(guard);
|
||||
if (memoized !== undefined) return memoized;
|
||||
if (state.visited.has(guard)) return null; // cycle: `name` is on the active stack
|
||||
state.visited.add(guard);
|
||||
try {
|
||||
const result = computeJavaFold(fileKey, name, state, depth);
|
||||
if (result !== null) state.memo.set(guard, result);
|
||||
return result;
|
||||
} finally {
|
||||
state.visited.delete(guard);
|
||||
}
|
||||
}
|
||||
|
||||
function computeJavaFold(
|
||||
fileKey: string,
|
||||
name: string,
|
||||
state: JavaFoldState,
|
||||
depth: number,
|
||||
): string | null {
|
||||
const { repo, constantKeys } = state;
|
||||
// Qualified ref (`ApiPathConstants.FIELD`): constants and imports are keyed by
|
||||
// their IN-FILE name, so a dotted name never hits directly. Split head.tail:
|
||||
// resolve the head through the importing file's class import, then look the
|
||||
// tail up in the target file — first as the class-qualified alias `Head.TAIL`
|
||||
// (what extractJavaModuleConstants records), then as a bare `TAIL` (same-file
|
||||
// nested/interface constant).
|
||||
const dot = name.indexOf('.');
|
||||
if (dot > 0) {
|
||||
const head = name.slice(0, dot);
|
||||
const tail = name.slice(dot + 1);
|
||||
const imp = repo.get(fileKey)?.imports.get(head);
|
||||
if (imp) {
|
||||
const targetFile = resolveJavaImport(fileKey, imp.module, constantKeys);
|
||||
if (targetFile !== null) {
|
||||
const qualified = resolveWithState(targetFile, `${head}.${tail}`, state, depth + 1);
|
||||
if (qualified !== null) return qualified;
|
||||
const bare = resolveWithState(targetFile, tail, state, depth + 1);
|
||||
if (bare !== null) return bare;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// Un-imported qualified name (FQN form `com.a.b.C.FIELD`): try resolving
|
||||
// the longest dotted prefix as a class import target.
|
||||
const parts = name.split('.');
|
||||
for (let cut = parts.length - 2; cut >= 1; cut--) {
|
||||
const fqn = parts.slice(0, cut + 1).join('.');
|
||||
const targetFile = resolveJavaImport(fileKey, fqn, constantKeys);
|
||||
if (targetFile !== null) {
|
||||
const field = parts.slice(cut + 1).join('.');
|
||||
const declaring = parts[cut];
|
||||
const qualified = resolveWithState(targetFile, `${declaring}.${field}`, state, depth + 1);
|
||||
if (qualified !== null) return qualified;
|
||||
return resolveWithState(targetFile, field, state, depth + 1);
|
||||
}
|
||||
}
|
||||
// No import bound the head and no FQN prefix resolved — fall through. A
|
||||
// dotted name is ALSO a valid key in this file's own maps:
|
||||
// `extractJavaModuleConstants` records every constant under
|
||||
// `<DeclaringClass>.<FIELD>` as well as its simple name, so a same-file
|
||||
// qualified reference (`ApiPaths.X` inside ApiPaths.java) resolves below.
|
||||
}
|
||||
|
||||
// Name lookup: literals, then same-file expressions, then the import chase.
|
||||
// Reached for a bare name and for a dotted name that named no import.
|
||||
// Expressions are folded HERE rather than handed to the agnostic core because
|
||||
// an operand of a Java initializer may itself be a QUALIFIED ref
|
||||
// (`X = BConsts.Y + "/tail"`) and the core only knows bare names: it looks
|
||||
// `BConsts.Y` up in maps keyed by simple name, misses, and floors the whole
|
||||
// chain to null. Recursing through this function gives every operand the same
|
||||
// qualified treatment the entry-point name got.
|
||||
const mc = repo.get(fileKey);
|
||||
if (!mc) return null;
|
||||
const literal = mc.literals.get(name);
|
||||
if (literal !== undefined) return literal;
|
||||
const expr = mc.exprs.get(name);
|
||||
if (expr !== undefined) return foldOperands(fileKey, expr, state, depth + 1);
|
||||
const imp = mc.imports.get(name);
|
||||
if (imp !== undefined) {
|
||||
const targetFile = resolveJavaImport(fileKey, imp.module, constantKeys);
|
||||
if (targetFile === null) return null;
|
||||
return resolveWithState(targetFile, imp.originalName, state, depth + 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenate an operand list, resolving each `ref` through the qualified-aware
|
||||
* walk so `Class.CONST` works at every position, not just at the entry point.
|
||||
*
|
||||
* Bounded by {@link MAX_FOLD_LENGTH}: the depth cap bounds RECURSION but not
|
||||
* OUTPUT, which grows multiplicatively (`X = A + A; A = B + B; …`), so a
|
||||
* pathological chain would build a gigabyte-scale string before any cap fired.
|
||||
* Overrun floors to null (#2393).
|
||||
*/
|
||||
function foldOperands(
|
||||
fileKey: string,
|
||||
operands: readonly Operand[],
|
||||
state: JavaFoldState,
|
||||
depth: number,
|
||||
): string | null {
|
||||
let out = '';
|
||||
for (const op of operands) {
|
||||
if (op.kind === 'literal') {
|
||||
out += op.value;
|
||||
} else {
|
||||
const piece = resolveWithState(fileKey, op.name, state, depth);
|
||||
if (piece === null) return null;
|
||||
out += piece;
|
||||
}
|
||||
if (out.length > MAX_FOLD_LENGTH) return null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold an inline operand list (e.g. `API_CIS_V1 + "summary/save"`) against
|
||||
* `fileKey`, or null when any piece is unresolvable (skip floor).
|
||||
*/
|
||||
export function foldJavaOperands(
|
||||
fileKey: string,
|
||||
operands: readonly Operand[],
|
||||
repo: RepoConstants,
|
||||
): string | null {
|
||||
const out = foldOperands(fileKey, operands, newFoldState(repo), 0);
|
||||
return out === '' ? null : out;
|
||||
}
|
||||
1213
gitnexus/src/core/ingestion/route-extractors/js-const-resolver.ts
Normal file
1213
gitnexus/src/core/ingestion/route-extractors/js-const-resolver.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -30,6 +30,7 @@ import {
|
|||
unquoteSpringLiteral,
|
||||
type SharedSpringType,
|
||||
} from './spring-shared.js';
|
||||
import { parseJavaConstOperands } from './java-const-resolver.js';
|
||||
|
||||
/**
|
||||
* Single predicate-free tree-sitter query that captures all route annotations
|
||||
|
|
@ -53,6 +54,13 @@ import {
|
|||
* suppresses that class's method-level array routes rather than emit them with a
|
||||
* dropped prefix (a wrong route). Full class-array cross-product support is left
|
||||
* to a follow-up (#2280).
|
||||
*
|
||||
* The class-level `@value_expr` branches exist for the same reason: a
|
||||
* CONSTANT-valued class prefix (`@RequestMapping(ApiPaths.BASE)`) cannot be
|
||||
* folded here — the repo-wide constant map only exists in the parse phase — so
|
||||
* they only DETECT it, and Phase 2 suppresses every method route under such a
|
||||
* class. Without them the prefix was invisible and the method route was emitted
|
||||
* unprefixed, i.e. at a path the application does not serve.
|
||||
*/
|
||||
const ROUTE_ANNOTATION_QUERY = new Parser.Query(
|
||||
Java,
|
||||
|
|
@ -90,6 +98,42 @@ const ROUTE_ANNOTATION_QUERY = new Parser.Query(
|
|||
key: (identifier) @key
|
||||
value: [(string_literal) @value
|
||||
(element_value_array_initializer (string_literal) @value)]))))) @node
|
||||
(class_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: [(identifier) (scoped_identifier)] @ann
|
||||
arguments: (annotation_argument_list
|
||||
[(identifier) @value_expr
|
||||
(field_access) @value_expr
|
||||
(binary_expression) @value_expr])))) @node
|
||||
(class_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: [(identifier) (scoped_identifier)] @ann
|
||||
arguments: (annotation_argument_list
|
||||
(element_value_pair
|
||||
key: (identifier) @key
|
||||
value: [(identifier) @value_expr
|
||||
(field_access) @value_expr
|
||||
(binary_expression) @value_expr]))))) @node
|
||||
(method_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: [(identifier) (scoped_identifier)] @ann
|
||||
arguments: (annotation_argument_list
|
||||
[(identifier) @value_expr
|
||||
(field_access) @value_expr
|
||||
(binary_expression) @value_expr])))) @node
|
||||
(method_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: [(identifier) (scoped_identifier)] @ann
|
||||
arguments: (annotation_argument_list
|
||||
(element_value_pair
|
||||
key: (identifier) @key
|
||||
value: [(identifier) @value_expr
|
||||
(field_access) @value_expr
|
||||
(binary_expression) @value_expr]))))) @node
|
||||
]
|
||||
`,
|
||||
);
|
||||
|
|
@ -122,6 +166,11 @@ export function extractSpringRoutes(
|
|||
// class-array cross-product support is out of scope here.
|
||||
const prefixByClassId = new Map<number, string>();
|
||||
const classesWithArrayPrefix = new Set<number>();
|
||||
// Classes whose `@RequestMapping` prefix is a constant reference or concat.
|
||||
// Same treatment as the array form, for the same reason: no single prefix
|
||||
// string is knowable at extraction time, so emitting the methods below would
|
||||
// publish them at a WRONG (unprefixed) path rather than not at all.
|
||||
const classesWithUnfoldablePrefix = new Set<number>();
|
||||
const classHttpMethodsById = new Map<number, readonly string[]>();
|
||||
for (const match of TYPE_DECLARATION_QUERY.matches(tree.rootNode)) {
|
||||
const typeNode = match.captures.find((capture) => capture.name === 'type')?.node;
|
||||
|
|
@ -139,11 +188,16 @@ export function extractSpringRoutes(
|
|||
const node = caps['node'];
|
||||
const valueNode = caps['value'];
|
||||
const keyNode = caps['key'];
|
||||
if (!annNode || !node || !valueNode) continue;
|
||||
const valueExprNode = caps['value_expr'];
|
||||
if (!annNode || !node || (!valueNode && !valueExprNode)) continue;
|
||||
|
||||
const capturedAnnotationName = annNode.text.split('.').pop() ?? annNode.text;
|
||||
if (node.type === 'class_declaration' && capturedAnnotationName === 'RequestMapping') {
|
||||
if (!isRouteMemberKey(keyNode)) continue;
|
||||
if (!valueNode) {
|
||||
classesWithUnfoldablePrefix.add(node.id);
|
||||
continue;
|
||||
}
|
||||
if (valueNode.parent?.type === 'element_value_array_initializer') {
|
||||
classesWithArrayPrefix.add(node.id);
|
||||
continue;
|
||||
|
|
@ -166,7 +220,11 @@ export function extractSpringRoutes(
|
|||
const node = caps['node'];
|
||||
const valueNode = caps['value'];
|
||||
const keyNode = caps['key'];
|
||||
if (!annNode || !node || !valueNode) continue;
|
||||
// A constant-referencing value arrives as @value_expr, not @value — the
|
||||
// match carries exactly one of the two. Require @value only when no
|
||||
// @value_expr is present; the operand branch below folds the expression.
|
||||
const valueExprCapture = match.captures.find((c) => c.name === 'value_expr')?.node ?? null;
|
||||
if (!annNode || !node || (!valueNode && !valueExprCapture)) continue;
|
||||
|
||||
if (node.type !== 'method_declaration') continue;
|
||||
|
||||
|
|
@ -181,8 +239,12 @@ export function extractSpringRoutes(
|
|||
if (methodMethods.length === 0) continue;
|
||||
if (!isRouteMemberKey(keyNode)) continue;
|
||||
|
||||
const routePath = unquoteSpringLiteral(valueNode.text);
|
||||
if (routePath === null) continue;
|
||||
// #2391-style non-literal path (constant ref or `+`-concat): emit with
|
||||
// operands for cross-file folding in the parse phase. The match carries
|
||||
// either @value (literal) or @value_expr (non-literal) — never both.
|
||||
const valueExprNode = valueExprCapture;
|
||||
const routePath = valueNode ? unquoteSpringLiteral(valueNode.text) : null;
|
||||
if (routePath === null && !valueExprNode) continue;
|
||||
const enclosingType = findEnclosingType(node);
|
||||
|
||||
// Interface-declared `@*Mapping`s are not concrete routes on their own — the
|
||||
|
|
@ -206,10 +268,20 @@ export function extractSpringRoutes(
|
|||
// scan — safe under routeCoverage:'partial'. Full class-array cross-product
|
||||
// support is tracked in #2280. (Scalar method paths under an array class
|
||||
// prefix are left unchanged: that pre-existing divergence is out of scope.)
|
||||
const isArrayElement = valueNode.parent?.type === 'element_value_array_initializer';
|
||||
const isArrayElement = valueNode?.parent?.type === 'element_value_array_initializer';
|
||||
if (isArrayElement && enclosingClass && classesWithArrayPrefix.has(enclosingClass.id)) {
|
||||
continue;
|
||||
}
|
||||
// Same rule for a CONSTANT-valued class prefix (`@RequestMapping(ApiPaths.BASE)`),
|
||||
// and for every method route under it — not just array-form ones. The prefix
|
||||
// needs the repo-wide constant map, which does not exist at extraction time,
|
||||
// so the prefix would simply be dropped and the route emitted at a path the
|
||||
// application never serves. On base such a route was not emitted at all;
|
||||
// turning a missing fact into a wrong one is the failure this module's skip
|
||||
// floor exists to prevent. Folding class prefixes cross-file is a follow-up.
|
||||
if (enclosingClass && classesWithUnfoldablePrefix.has(enclosingClass.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const classPrefix = enclosingClass ? (prefixByClassId.get(enclosingClass.id) ?? '') : '';
|
||||
// `node` is the annotated `method_declaration`; its name field is the
|
||||
|
|
@ -217,6 +289,25 @@ export function extractSpringRoutes(
|
|||
const handlerName = node.childForFieldName('name')?.text;
|
||||
|
||||
for (const httpMethod of httpMethods) {
|
||||
if (routePath === null && valueExprNode) {
|
||||
// Non-literal annotation value: parse operands now; the parse phase
|
||||
// folds them against the repo-wide Java constant map (KTD5 skip floor
|
||||
// on failure — never a phantom `POST /`).
|
||||
const operands = parseJavaConstOperands(valueExprNode);
|
||||
if (operands === null) continue;
|
||||
routes.push({
|
||||
filePath,
|
||||
routePath: '',
|
||||
routePathExpr: valueExprNode.text,
|
||||
routePathOperands: operands,
|
||||
httpMethod,
|
||||
decoratorName: ann,
|
||||
lineNumber: annNode.startPosition.row + lineOffset,
|
||||
...(classPrefix ? { prefix: classPrefix } : {}),
|
||||
...(handlerName ? { handlerName } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
routes.push({
|
||||
filePath,
|
||||
routePath,
|
||||
|
|
@ -233,6 +324,13 @@ export function extractSpringRoutes(
|
|||
for (const match of TYPE_DECLARATION_QUERY.matches(tree.rootNode)) {
|
||||
const typeNode = match.captures.find((capture) => capture.name === 'type')?.node;
|
||||
if (typeNode?.type !== 'class_declaration') continue;
|
||||
// A no-argument `@GetMapping` IS the class prefix, so a class prefix that
|
||||
// cannot be folded here leaves nothing to emit — the route would ship with
|
||||
// `routePath: ''` and no prefix, i.e. an empty-path Route. The Phase 2 loop
|
||||
// above already suppresses these classes; this loop needs the same guard, or
|
||||
// the suppression is one-sided and the group side (which routes both shapes
|
||||
// through `methodRoutes`) disagrees with ingestion.
|
||||
if (classesWithUnfoldablePrefix.has(typeNode.id)) continue;
|
||||
const classPrefix = prefixByClassId.get(typeNode.id) ?? '';
|
||||
const classMethods = classHttpMethodsById.get(typeNode.id) ?? ['*'];
|
||||
for (const methodNode of directMethods(typeNode)) {
|
||||
|
|
|
|||
|
|
@ -787,7 +787,7 @@ export function pickUniqueGlobalCallable(
|
|||
// because the list would then depend on the caller's scope, not just its file.
|
||||
const cacheKey =
|
||||
scopeDefsCache !== undefined && isCallerVisible === undefined
|
||||
? `${name} | ||||