From 2c0fb7753ccf7c2eca580b7d90bdd2226ca7d3c5 Mon Sep 17 00:00:00 2001 From: DuduPhudu <34869259+ReidenXerx@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:37:16 +0300 Subject: [PATCH 01/17] fix(group): stop reporting what could not be measured as a measurement of zero (#3012) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: surface unreadable group indexes and escape raw NUL bytes in source Two independent diagnostics failures, both of which turn a real error into a confident, benign-looking answer. **Unreadable member repos (#3011).** `syncGroup` wrapped `initLbug` plus all contract extraction for each member in a bare `catch {}` that pushed the repo onto `missingRepos` and discarded the error. A LadybugDB storage-version mismatch therefore surfaced as "repo not found", `group sync` printed `0 contracts, 0 cross-links` and exited 0, and the existing contracts.json was overwritten with an empty registry. The two states need different answers from the operator — a missing repo must be indexed, an unreadable one is usually version skew or a lock — so they are now separate: - the caught error is logged with the repo, group path and lbug path - `unreadableRepos` is tracked alongside `missingRepos` on `SyncResult`, persisted (optionally, so older registries still parse) on `ContractRegistry`, and threaded through `GroupService` sync/status - `group sync` reports both before the cascade counts, since an unread repo is the likely explanation for a small or empty count - `group status` reports unreadable repos separately; calling them "missing" actively misdescribed them - when EVERY configured repo fails to open, the write is skipped: an extraction that read nothing is not evidence the group has no contracts, and replacing a good registry with an empty one loses data while reporting success **Raw NUL bytes (#3010).** `sync.ts` and `free-call-fallback.ts` each used a NUL as a join delimiter, written as a literal 0x00 instead of `\0`. Identical at runtime, but it makes the file test as binary: `file(1)` reports `data`, ugrep returns empty with exit 1 — indistinguishable from "no match", with no message — and BSD grep replaces matching lines with "Binary file ... matches". A search that should hit comes back as a confident "not present". Both now use the escape, and a unit test fails on any raw control byte in src/ so it cannot silently return. Co-Authored-By: Claude Opus 5 (1M context) * test(hygiene): guard every tracked source file against a raw NUL, not just src/ The guard added with the NUL escapes only scanned gitnexus/src for .ts/.tsx. Neither prior recurrence of this defect in this repo was in that scope: b620773b1 was gitnexus/bench/cpp-qualified-ns/measure.mjs and 38d737bb5 was a fixture under gitnexus/test. A guard that cannot see where the bug has actually landed twice is not a guard. Drive the file list from `git ls-files` at the repository root over .ts/.tsx/.js/.jsx/.mjs/.cjs/.mts/.cts — 2483 files instead of 828 — and split the byte class, which is the part that matters: - 0x00 is a hard failure repo-wide. It is the byte git's binary heuristic keys on, so it is the one that costs a file its diff (and, on the base side of a PR, its inline-comment anchors and its three-way merge). - The wider C0 class stays scoped to gitnexus/src. A repo-wide scan finds exactly one hit, test/unit/logger.test.ts:146, and that 0x1b is a legitimate ANSI-escape fixture that is the subject of the test. Widening this half would go red on day one. Read Buffers and scan bytes instead of decoding each file to latin1, through a bounded read pool: 1.5 s for 2483 files, against 8-21 s previously for 828. Add a negative fixture — a planted 0x00 and 0x1b run through the same scanning helper — so a future refactor of the collector cannot leave a permanently green guard, plus an assertion that the collected set still reaches bench/, test/ and .mjs, which goes red if the scope is ever narrowed back. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV * fix(group): report a cross-repo impact built from an incomplete bridge as truncated When a sync cannot read a member repo, that repo's contracts and every cross-link touching them are simply absent from bridge.lbug. Nothing in the impact walk could notice: the only incompleteness channel on a GroupImpactResult is truncationFields(), which is driven by fan-out state (truncatedRepos / localPartial / fanoutTimedOut), and a repo missing from the bridge sets none of them. So `group impact` on a symbol whose one downstream consumer lives in an unreadable repo returned `{ cross: [], truncated: false }` — "complete: nothing in another repo depends on this". That is a wrong answer, not an empty one, for a tool an agent uses to license a delete or a rename. BridgeMeta now records unreadableRepos alongside missingRepos, writeBridge persists it when non-empty, and runGroupImpact folds a non-empty unreadableRepos ∪ missingRepos into truncated / riskEpistemic: 'lower-bound', naming the repos in truncatedRepos. The reason is a new 'incomplete-sync' rather than the existing 'partial' because the remedy differs: 'timeout' and 'partial' are runtime limits the same query can clear on a retry, while this one clears only when `gitnexus group sync` succeeds. Runtime limits still take precedence when both apply, since those are what the caller can act on immediately. The risk VALUE is never clamped down — mergeRisk is monotone in the traversed crossing count, so an incomplete bridge can only under-report. Marking the floor is what makes that legible. Both shape changes are additive and optional, so a bridge written before this still reads. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV * fix(group): say truthfully what a sync did to contracts.json Review follow-ups to the unreadable-repo diagnostics. Every item below is a place where the code still answered a question it could not answer. 1. The CLI announced a write it did not perform. `group sync` printed "Wrote contracts.json (0 contracts, 0 cross-links)" unconditionally, including on the path that deliberately left the file alone. SyncResult now carries registryOutcome ('written' | 'preserved' | 'not-attempted'), the CLI prints from it, and group_sync returns it so an agent that calls group_sync then group_contracts can tell why the counts disagree. 2. Refusing to write anything on total failure threw away the diagnostic describing the run that just happened. `group status` reads contracts.json from disk, so the operator who saw the sync fail and ran status to find out why read the PREVIOUS sync's file: no unreadable list, an old lastSync, a healthy-looking group — or worse, the previous run's unreadable list presented as this one's. The skip is now targeted: contracts, crossLinks, repoSnapshots and generatedAt carry forward verbatim, only missingRepos and unreadableRepos are refreshed. generatedAt stays put because it dates the contracts, which are still the previous run's. With no prior file, or an unparseable one, nothing is written at all. 3. Per-repo extraction is now all-or-nothing. Extractors run in sequence and any one can throw; appending each one's results straight to autoContracts meant a repo whose HTTP extractor succeeded and whose gRPC extractor then failed contributed a partial set to the registry, while the same run told the operator that repo's "contracts are omitted from this sync". 4. readRegistry gains an opt-in strict mode, and syncGroup uses it. The lenient `catch { return []; }` converted "I could not read the registry" into "no repo is registered": every configured repo then resolved to MISSING, the total-failure guard stayed off (it needs a load error), and a good contracts.json was replaced by an empty one at exit 0. That is an unreadable condition reported as missing, one frame above the code this branch fixes. The default stays lenient for the other nine callers; ENOENT stays lenient in both modes. 5. Absence of unreadableRepos keeps meaning "not recorded". The loader spreads the key in only when present instead of defaulting to [], and getStatus passes undefined through, so a legacy registry no longer reads as "the last sync found none unreadable". getStatus also gates both list fields on Array.isArray: it reads through readContractRegistry, which is a bare JSON.parse cast, so a corrupt string in either slot used to reach cli/group.ts and die in .join(', ') — the command whose job is explaining an unreadable thing, crashing on one. 6. Smaller, same theme: the per-repo warning passes the Error itself rather than err.message, so pino keeps the stack; the total-failure warning no longer fires on a dry run, where it described a file the call was never going to touch and which need not exist; the status table's MISSING legend stops re-conflating the two states; the sync warning drops its GITNEXUS_LOG_LEVEL=warn hint, which would only have suppressed output (pino emits warn at the default info level, so the reason was already printed); and the group_sync tool description and its idempotency comment now describe what the tool actually does. Testing. The original four cases could not see the change they were named after. Mutation testing showed two survivors: dropping the === configuredRepoCount conjunct, which turns "every repo failed" into "any repo failed" and would silently freeze contracts.json for a group where one of five repos is skewed; and deleting both logger.warn calls, the stated purpose of the change. Both survived because every case configured exactly one repo and nothing read the log. There is now a two-repo case running the real per-repo loop, an all-missing case, a _captureLogger assertion on the level 40 record, partial-extraction cases, and strict-read cases. All five mutants are killed, each by exactly one test. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV * fix(group): tighten the registry list gates and stop naming a truncation reason on complete results Three follow-ups from the check bot's pass over the previous commits. 1. `detect.includes` was missing from both group-sync test fixtures, so they did not satisfy the `GroupConfig` they claim to construct. It went unnoticed because `tsconfig.json` is src-only; `tsconfig.test.json` reports it. The older of the two fixtures carried the gap in from the original commit. 2. `runGroupImpact` named its truncation reason in a variable computed before the truncated check, so on a fully complete result the variable read 'incomplete-sync'. `truncationFields` discards the reason when `truncated` is false, so nothing surfaced — but a value that is wrong whenever it is unused is a trap for the next reader. Computed inline at the one call site that can consult it, which is also how the neighbouring call sites are written. 3. `Array.isArray` alone let a corrupt registry through. `['app/backend']` and `[{repo:'x'}]` are both arrays, and only the second reaches `cli/group.ts`'s `.join(', ')` — as `[object Object]`, a measurement the operator can read but cannot act on. Both readers now go through one `recordedRepoList` helper that requires an array of strings; anything else is "not recorded", the same as absent. Two more rows in the corrupt-value table cover it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV * fix(group): keep readRegistry's signature, and stop describing unreadableRepos as index-only Two items from the check bot's blocking pass. 1. `readRegistry` gained an optional `opts` parameter last commit. That is source-compatible — every zero-argument call still compiles and behaves identically — but the contract check treats any parameter-list change on a symbol with outside callers as a break, and it is right that the safest version of this change touches that signature not at all. The strict read is now its own export, `readRegistryStrict()`, over a shared private body. `readRegistry()` is byte-identical to what it was; `syncGroup` is the only caller of the strict one, and the mode is legible at the call site instead of hiding in an options bag. 2. `unreadableRepos` is described everywhere as "the index could not be opened". That was accurate before this branch and is not now: making per-repo extraction all-or-nothing means a repo also lands there when an extractor throws partway with the index open fine. The two belong in one bucket because the consequence is one thing — none of that repo's contracts are in this sync — but the docs have to say so, or an operator reads `unreadableRepos` as a storage diagnosis and goes looking at LadybugDB for an extractor bug. Corrected on `ContractRegistry`, `BridgeMeta`, `SyncResult`, the `group_sync` tool description, and the `group sync` console output, which now says "Could not extract contracts from" rather than "Could not read the index for". Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV * fix(cli): stop calling an unreadable registry an old one in group status `getStatus` reports `unreadableRepos` as `undefined` for two different reasons: the field is genuinely absent, or it held something that was not a list of repo paths and the shape gate declined to guess. The status line named only the first — "registry predates this field" — so a corrupt value read as a merely old registry. That is the same shape of wrong answer this command exists to stop giving: a condition we could not read, presented as a benign one we understand. The line now names both, and asks for a sync either way, which is the fix in both cases. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV * fix(group): close the three fail-open paths left on the safety boundaries Follow-ups from the re-review of 31c2b6e81. All three of its blocking findings reproduce; each is a place where unknown state still resolved to a confident benign answer, which is the one thing this branch exists to stop. 1. Strict registry reading accepted malformed rows. `[{}]` is a JSON array, so it passed the shape check: every configured repo then failed to resolve into `missingRepos`, none produced a load ERROR, the total-failure guard stayed off, and a good contracts.json was replaced with an empty one at exit 0 — the same fail-open the strict mode was added to close, one level down from the file to the rows inside it. Strict mode now requires `name`, `path` and `storagePath` on every row and rejects the WHOLE registry if any row fails. Rejecting rather than filtering is the point: dropping bad rows would report the repos they name as unregistered, which is the same wrong answer again. `indexedAt` / `lastCommit` are deliberately not required — callers already default them, so demanding them would trade a fail-open for a fail-shut on a legitimate legacy registry. 2. A failed bridge publication could make impact look complete. `writeBridge` swaps `bridge.lbug` and writes `meta.json` as two operations, and this branch made that meta load-bearing: `runGroupImpact` derives its truncation fields from it. A sync interrupted between the two steps therefore left a NEW bridge beside the PREVIOUS sync's metadata, and an impact query read that as "complete". Fixed from both ends. The write path removes the old meta before the swap, so the window leaves metadata ABSENT rather than stale. The read path treats absent-or-unparseable meta (`version: 0`) as unknown provenance and reports a floor, which also covers the caught `writeBridge` failure in `syncGroup`. Over-reporting truncation on a bridge that is actually fine is the safe direction, and the next successful sync clears it. 3. `preserved` was returned when there was nothing to preserve. On a group's first all-unreadable sync the outcome was set before the prior registry was read, so the CLI told an operator "the contracts from the previous sync are preserved" about a file that had never existed. Split out as `no-prior-registry`, with its own console message. Also widened the NUL guard to the source languages it claimed to cover. The commit that added it said "every tracked source file" while the collector stopped at the JS/TS family, so a raw NUL in tracked Python, Java, Go, Rust, C/C++, Ruby, PHP, Kotlin, Swift, C# or shell would still have turned those files binary unnoticed. Measured before widening: 2315 non-JS tracked source files, zero hits, so this was an unforced gap rather than a tradeoff. A planted `.py` fixture and a collector-coverage assertion keep it honest. Every fix is mutation-verified: reverting each one individually turns its own tests red (3, 2, 2, 1 and 1 failures respectively), and all pass together. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV * fix(group): record the empty unreadable measurement instead of dropping it Both writers omitted `unreadableRepos` when it was empty, which made the tri-state this branch introduced unreachable in its most common case. `ContractRegistry.unreadableRepos` is optional on the TYPE so a registry written before the field existed still parses, and absence there means "not recorded". But a sync that read every repo successfully HAS measured it, and `[]` is that measurement. Dropping it collapsed "measured, none" into "never recorded", so after every clean sync `gitnexus group status` printed Last sync unreadable repos: not recorded (the registry predates this field, or its value could not be read) Re-run `gitnexus group sync` to record it. about the sync that had just succeeded. The distinction is only worth having if the writer commits to it, so both `contracts.json` and the bridge's `meta.json` now record the field whenever the sync supplied it, `[]` included. The check bot found this on the bridge writer and attributed the consequence to `group status`. The consequence is real but it is not the bridge's: `getStatus` reads `contracts.json` and never touches `BridgeMeta`, whose only consumer is `runGroupImpact` — where absent and empty are already equivalent. So the user-visible half was in the registry writer, one file over from where it was reported, and both are fixed. Also fills in `DetectConfig.includes` (and `workspace_deps`) across the group test fixtures that predate those fields. These are pre-existing on main and are a no-op at runtime — `undefined` and `false` are both falsy at the gate — but they are the same defect the bot flagged as an error in the new fixtures, and `tsconfig.test.json` reported eleven of them. That file is not in CI, which is why they survived; the group tree is now clean of them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV * test(group): stop two bridge-metadata tests claiming coverage they do not have Both were named for the swap window and neither injects a swap failure. "drops the previous meta.json before swapping the database file" runs two successful writeBridge calls. Its assertions hold with the removal in either position, because writeBridge overwrites meta.json at the end regardless — so it cannot pin the ordering it is named for. Renamed to what it does cover, the successful-rebuild replacement, with the limit stated in the body rather than left for the next reader to discover. "leaves NO meta.json when the swap fails partway" removes the file by hand after a successful write, so it exercises readBridgeMeta's missing-file contract, not writeBridge. That contract is worth pinning on its own — version 0 is the signal runGroupImpact fails closed on — so the test stays, under a name that says so. The ordering itself is pinned in bridge-meta-swap-window.test.ts, which mocks retryRename to throw on the bridge.lbug swap and asserts the previous sync's metadata cannot survive it. Both renamed tests now point there, so the coverage is findable from the place someone would look for it. No production code changes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV * fix(group): pair bridge metadata to its database instead of deleting it The previous commit closed the swap/metadata window by removing meta.json before the database swap, so the window would fail to "absent" rather than "stale". That was the wrong trade, and it destroyed recoverable state. The old database's move to `.bak` sits inside a catch that swallows failures, not just "no existing db". When that rename fails — a held read-only handle does this on Windows, and a long-lived MCP server holds one — the failure is swallowed, the following `tmp -> bridge.lbug` throws, and writeBridge exits with the OLD database still in place and perfectly valid. Its metadata was already deleted. Cross-repo impact then answers "we cannot say" for that group until some future sync succeeds, and if the cause is a held handle or permissions there is no such sync. A working feature, destroyed permanently to close a narrow window. Deleting also only chose which way the window failed; it never closed it. So destroy nothing, and make the pair self-describing instead: writeBridge stamps the database's size and mtime into the metadata it writes, and `bridgeMetaMatchesFile` lets a reader ask whether the two still belong together. `runGroupImpact` treats a mismatch the same as absent metadata — provenance unknown, report a floor. A metadata file left over from an earlier sync cannot match a freshly renamed database, and a sync that fails before the swap leaves a matching pair untouched. Metadata written before the stamp existed is unverifiable rather than stale, and is accepted: failing those closed would mark every pre-existing bridge incomplete, trading a narrow window for a repo-wide regression. The swap-window test now distinguishes the two failure shapes, because they want different answers. When every rename fails the old database never moves, so the surviving metadata still matches it and impact keeps answering from it. When only the final rename fails the old database has already reached `.bak` and no database is in place, so the metadata correctly matches nothing — and `ensureBridgeReady` fails loudly on the absent file, which beats a silent floor. Mutation-verified: reinstating the delete, neutering the pairing check, and dropping the stamp each turn 2, 3 and 3 tests red respectively. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014g48u4WcRZy543Wqp5NhpV * chore: keep TypeScript diffs readable after a NUL leaves the tree Git decides a pair is binary when EITHER blob carries a NUL, and it only sniffs the first 8000 bytes. `gitnexus/src/core/group/sync.ts` carried one at byte 5132 on main. This branch removes it, but the base side still has it, so the file renders as "Binary files differ" in the pull request: no hunks, no inline comments, and no three-way merge — however clean the head side is. A head-side byte guard cannot detect that, by construction, since it only ever sees the working tree. Setting the `diff` attribute stops the heuristic from hiding the change. It does not mark the files binary, does not imply `text`, and does not change how blobs are stored, normalized, or checked out — the root `* text=auto eol=lf` still governs all of that. It affects diff generation and rendering only. Locally this turns the branch's own sync.ts diff from `Bin 17612 -> 25346 bytes` into 154 insertions and 16 deletions. Co-Authored-By: Claude Opus 5 (1M context) * fix(group): answer "provenance unknown" for malformed bridge metadata `readBridgeMeta` guarded the read and the parse but not the SHAPE of what it parsed, then cast the result. `runGroupImpact` spread both repo lists straight into a Set, so a `meta.json` whose `missingRepos` held an object threw a TypeError out of the entire cross-repo query — and threw it from a point after `ensureBridgeReady` had taken the bridge lease and before the `try` whose `finally` releases it, so every such query also leaked a refcount the cached handle could never get back. A malformed file is a reason to answer "we cannot say", never a reason to crash the question. The shape gate now lives where the metadata is read, mirroring the one `service.ts` already applies to the registry's copies of these same two lists. Each list is judged independently: a garbage `unreadableRepos` no longer discards a `missingRepos` that was genuinely measured. A list that was present but unusable is dropped rather than normalized to `[]`, because an unreadable value is not a measurement of zero — the new reader-side `repoListsUnreadable` carries that distinction, and `runGroupImpact` folds it into the same provenance-unknown verdict it already reaches for `version: 0` and for metadata that does not pair with the database beside it. A root that is not an object is closed too. `JSON.parse` succeeds on `null`, `7` and `[]`; the first threw on `.version`, and the other two read `undefined` and sailed through the version gate as if the bridge had been vouched for. Both provenance values moved inside the protected region and are initialized fail-closed, so a future throw between the lease and the walk releases rather than wedges. `repoListsUnreadable` is reader-side only: the sole `writeBridgeMeta` call site builds a fresh literal, so nothing persists it and no schema version moves. Mutation-verified: reverting the shape gate alone turns 4 tests red — the three malformed-list scenarios plus the handle-release regression. Co-Authored-By: Claude Opus 5 (1M context) * fix(storage): reject registry rows that cannot identify a repo The strict read's row gate gave `typeof v === 'string'`, and `typeof '' === 'string'`. A row whose `name` was blank therefore passed as resolvable, then matched nothing in `defaultResolveHandle` — putting every configured repo in `missingRepos` and presenting an unusable registry as a clean answer about an empty one. That is the same unreadable-as-missing fail-open the strict mode exists to close, one level further in. A blank `storagePath` is worse than useless: it joins to a relative `lbug` under the current directory, so the sync opens an index that is not the repo's. Both now have to be non-blank after trimming. `path` stays at the bare string check, on the same reasoning that already exempts `indexedAt`/`lastCommit`: require only what resolution depends on to IDENTIFY the repo. This gate rejects the whole registry and the registry is machine-wide, so a field tightened past what identification needs would let one blank value in one row break every group sync on the machine — including groups whose repos all resolve. A blank `path` still yields a working handle; `defaultResolveHandle` does read it, but only for the pool id and `repoPath`, neither of which decides whether the row names a repo. The error now says what is actually wrong instead of naming three fields that are all present. Mutation-verified in both directions: dropping the trim turns the three rejection tests red, and applying the wider fix that was considered and declined — tightening `path` too — turns exactly the counter-case red, so that test genuinely pins the narrow reading rather than passing either way. Co-Authored-By: Claude Opus 5 (1M context) * fix(group): bound the per-repo contract staging append `autoContracts.push(...repoContracts)` passes every staged contract as a separate argument, and the engine caps how many arguments one call may take. That cap is a function of the host's available stack, so it is a different number on every machine — this one accepts a 125k-element spread and dies at 150k. The spread itself is not new; what it carries is. Before staging, this line appended a single extractor's output as it came back. Staging made it carry the whole repo's, which is enough for a large repo to raise `RangeError: Maximum call stack size exceeded` on the one line whose job is to commit work that just succeeded. The throw lands in the catch below, so the sync reports a repo whose extractors all ran cleanly as one whose index could not be read — a crash wearing the costume of a diagnostic. A bounded loop replaces it: the count a repo can stage is now bounded by memory rather than by how much stack the process happened to get. The guard is structural, not size-based, and deliberately so. A "make the fixture big enough to crash" test passes against unfixed code on any host with a larger stack, which is exactly the guarantee a regression gate cannot give up. It walks the AST and locates the region by role — the `const` staging buffer typed `StoredContract[]`, then the extractor `try` that is a direct statement of the block declaring it — so renaming either identifier keeps it pointed at the same code. `.apply()` is rejected alongside spread, being the same hazard in different syntax. Direct statements only, because `syncGroup` wraps this whole section in its own try/finally for the lease sweep, and that ancestor reads the buffer too. Matching any enclosing `try` pulls in the entire function body — including the two windowed-manifest spreads, which are bounded by the window size and are not what this fixes. Mutation-verified in both directions: restoring the spread turns the gate red naming that line alone; deleting a manifest-window spread, and separately adding a third one, both leave it green. Co-Authored-By: Claude Opus 5 (1M context) * fix(group): keep unreadable repos out of manifest contracts too Per-repo staging closed one door: a repo whose extractor threw contributes nothing through the direct path. Deferred manifest resolution was a second door, still open. It derives its known-repo set from the resolved-handle map, which kept an entry for a repo the same run had already declared unreadable — so the sync re-opened that index and resolved symbols against a database it had just told the operator it could not read. Deleting the handle in the catch stops the re-open, but it does not satisfy R2 on its own: `ManifestExtractor` resolves both endpoints of a link and emits a contract for each, and for an endpoint with no executor that contract is still emitted with a synthetic UID. The registry ended up naming a repo the same run reported unreadable. So the emitted output is filtered by ENDPOINT, not by link. Dropping the whole link would delete the healthy partner's contract as well — a repo losing its own output because a neighbour's index would not open, which is wider than the requirement and destroys good data to suppress bad. A cross-link is different: it asserts something about a pair, so if either end is unreadable there is nothing left to anchor it to, and a half-anchored link is exactly the confident-about-what-it-could-not-read answer the registry must not give. Deleting the handle also changed what the operator gets told, so the warning is split. An unreadable repo IS configured; letting it fall into the "references repos not in config.repos" branch states something false and sends the reader to edit group.yaml for a problem only re-indexing fixes. It now gets its own message naming what was actually omitted. Mutation-verified four ways: reverting the endpoint filter turns three scenarios red; the over-broad whole-link variant turns the healthy-partner scenario red and nothing else; removing the handle delete turns the no-re-open scenario red; and reverting the warning split turns the operator- message scenario red. Every assertion reads the written contracts.json rather than the in-memory result. Co-Authored-By: Claude Opus 5 (1M context) * refactor(group): keep readBridgeMeta's signature stable across the shape gate The shape gate landed by widening the return type to a reader-only `ReadBridgeMeta extends BridgeMeta`. That is source-compatible — a covariant return, one added optional field, every existing caller unaffected, typecheck and suite clean — but the contract check reads it as a changed signature with a caller left behind, and blocks the merge on it. This branch already hit the same wall on `readRegistry` and settled it the same way: leave the signature alone and make the difference legible some other way. So the flag moves onto `BridgeMeta` itself as an optional, documented, never-persisted field, and `readBridgeMeta` goes back to the exact signature its callers already compile against. That is the better shape here anyway. The reader-only subtype would have split the validation two ways: `openBridgeDbReadOnly` and `bridgeExists` both gate on `meta.version`, and the normalization that comes with the gate is what stops a `version: null` in a hand-edited meta.json from reading as `undefined` and sailing through `version > 0` as though the bridge had been vouched for. One type keeps all three callers behind the same guard. Nothing persists the flag: `writeBridgeMeta`'s only caller builds a fresh literal, so it cannot round-trip to disk. No behavior change — pure type restructuring. 927 tests pass, typecheck clean. Co-Authored-By: Claude Opus 5 (1M context) * fix(group): stop treating a half-written bridge stamp as a verified match `bridgeMetaMatchesFile` joined its two `undefined` checks with `||`, so metadata carrying a size and no mtime — or the reverse — returned `true`, the same answer it gives a fully verified pair. A stamp is a PAIR. Both halves absent is the legacy shape: metadata written before stamping existed, which cannot be verified either way and is accepted deliberately, because failing it closed would mark every pre-existing bridge incomplete until re-synced. Exactly one half present is not that. Something wrote a stamp and did not finish, which is precisely the condition stamping was added to detect — so the check handed back "verified" for the one shape that most deserves suspicion, and a cross-repo impact query built on it would report a confident answer about a database its metadata cannot vouch for. The two states are now separated: neither half present accepts, exactly one rejects as provenance-unknown, both compare against the file as before. Found by the repository's own contract check, not by the plan. Mutation-verified: restoring the `||` form turns both half-stamp cases red while the legacy and fully-stamped controls stay green, so the pair genuinely pins the distinction rather than passing either way. Co-Authored-By: Claude Opus 5 (1M context) * fix(group): pair unstamped bridge metadata by write order, before any open Unstamped metadata was waved through: `bridgeMetaMatchesFile` returned "matches" for any pair with no stamp to check, so the stale-meta-beside-a-new-database window stayed open for every bridge written before stamping existed, and `runGroupImpact` spent that metadata's completeness as fact. `writeBridge` renames the database into place and writes the metadata after, so `meta.mtime >= db.mtime` holds for any pair written together — including by builds that predate the stamp. A database strictly newer than the metadata beside it can only come from a swap whose metadata write did not land. That is the fallback now. It is a heuristic on write order, not proof of provenance, and it is wrong in two directions: a stale metadata file touched after the swap still reads as paired, and a pair whose clock stepped backwards between the two writes reads as unpaired. Both are recorded at the code; the second is the safe direction. Equality counts as paired, or a coarse-granularity filesystem would reject every legacy bridge for a reason that is about the filesystem. The verdict is now taken in `ensureBridgeReady` BEFORE the database is opened, and carried on the metadata rather than recomputed afterwards. That ordering is load-bearing, not tidiness. Impact and trace both open the bridge and only then ask about provenance, so on any platform or LadybugDB build where a read-only open advances the file's mtime, every pre-stamp bridge would report provenance-unknown from its first query onward — the exact repo-wide regression this rule was chosen to avoid, arriving as a silent downgrade rather than an error. It does not happen on Linux, which was measured. It cannot be measured on Windows: pinning it by really opening the database needs an in-process write→read reopen of the same bridge.lbug, which is a documented limitation there. Rather than ship a Windows-skipped test and leave the assumption unverified on the platform whose file semantics are most likely to differ, the check moved ahead of the open so no platform has to be trusted. The new guard forces the hostile case on every platform: the open is stubbed to advance the database's mtime, and the verdict must still be "paired". It is registered in the cross-platform list so the Windows and macOS shards run it, and it has a control so it cannot pass vacuously. Two existing fixtures mocked `readBridgeMeta` to return a stamped-era version while never writing a meta.json — a state production cannot reach, since a non-zero version can only come from a file that exists. They now write the metadata their own mock claims to have read, rather than the helper being loosened to accept metadata it cannot stat. Mutation-verified twice: reverting the write-order branch turns both rejection cases red while all four legacy-accept cases stay green, and moving the pairing call back after the open turns the ordering guard red on its own. Co-Authored-By: Claude Opus 5 (1M context) * refactor(group): compute cross-repo completeness in one place Three surfaces can return a partial cross-repo answer — impact, trace, and the contract listing — and each decided for itself whether it was complete. Impact carried the structured triple; trace said it in prose, if at all. An agent reading a not-found trace had no machine-readable way to tell "there is no path" from "there may be a path in a repo this sync could not read", which is the difference between an answer and a floor. `crossRepoCompleteness` is now the one computation, and its input deliberately does not name where any of it came from. `BridgeMeta` is not in the signature and must not be: `groupContracts` answers the same question from contracts.json and never opens a bridge, so `version`, `repoListsUnreadable` and `pairedWithDatabase` do not exist on that path. Each caller derives its own `provenanceUnknown` — the bridge callers through `bridgeProvenanceUnknown`, which stays separate for exactly that reason — and passes the boolean in. Scope arrives as a predicate rather than a repo list or a subgroup, so narrowing a query's scope stays a change to one argument at the call site. The trace results now carry `truncated` / `truncationReason` / `riskEpistemic` like impact does. `notes` is untouched; it remains an addition to the machine channel, never the channel. One correction to the approach as written: it said to pass the trace's two endpoint repos as the predicate, but a destination trace declares no `to`. It asks where a call lands, so any member may hold the answer — and an unreadable provider repo is precisely how "no outgoing ContractLink leaves this repo" becomes a wrong answer rather than an empty one. Filtering that path to the `from` repo would have reintroduced the bug this unit exists to close, so it passes every repo and a test pins it. Two pre-existing paths become consistent with the vocabulary as a result: a crossing-capped result now reports `truncationReason: 'partial'` alongside the `truncated` flag it already set, and the destination path's `ambiguous` returns now report the cap its `ok` and `not_found` siblings already reported. Both are additive — no field is removed, and no `truncated` flips from true to false. `truncationFields` returns a discriminated union now, so `truncationReason` reads without a fallback on the branch where it cannot be absent. Mutation-verified: reverting the provenance fold alone — one line in the shared helper — turns 8 tests red across both surfaces, 2 new trace scenarios and 6 existing impact ones, which is the point of there being one helper. Co-Authored-By: Claude Opus 5 (1M context) * fix(group): narrow the incomplete-repo set to the query's declared scope A subgroup-scoped impact query was marked a lower bound by repos it had explicitly excluded. The fan-out already drops every neighbour outside the subgroup, so those repos could not have contributed a crossing to the answer — and a completeness marker that fires on results it does not describe is how a caller learns to ignore the marker. The scope is the query's DECLARED one, not the one the walk reached. An incomplete repo's contracts are absent from the bridge by definition, so it is never in the traversed set; filtering on what was traversed would empty the intersection on every query and silently restore the fail-open this channel exists to close. Declared scope here is the subgroup PLUS the query's own repo, which the approach did not account for. The walk starts from that repo's contracts in the bridge, so when it is the repo the sync could not read there are no crossings to find under any scope — and a subgroup excluding it would have turned that vacuum into a confident "nothing depends on this", for a tool an agent uses to license a delete. That case reported a floor before this change, so narrowing to the subgroup alone would have been a regression. The union only ever widens the in-scope set, so it cannot re-mark a repo the query excluded. Membership goes through the existing `repoInSubgroup` in both clauses, `exact` for the origin equality, rather than growing a second notion of what it means for a repo path to be in scope. Sound only while `MAX_SUPPORTED_CROSS_DEPTH` is 1 — at depth 2 an out-of-scope repo can sit between two in-scope ones — and that constraint is recorded at the intersection. Unscoped queries are byte-for-byte unchanged: `repoInSubgroup` answers true for an absent subgroup, so the intersection is the whole set. Mutation-verified: restoring the unfiltered predicate turns exactly the two scoped cases red while the unscoped control and both in-scope guards stay green. Co-Authored-By: Claude Opus 5 (1M context) * fix(group): keep the preserved registry and the bridge from disagreeing A total-failure sync refreshed contracts.json's diagnostic lists and left meta.json alone. But meta.json, not contracts.json, is where runGroupImpact reads completeness from — so the registry said "this sync could not read app/backend" while a cross-repo query answered `{ cross: [], truncated: false }`. Two surfaces describing the same run, one of them wrong, and the wrong one is the machine-readable one an agent uses to license a delete. The preserve path now refreshes the same two fields in the metadata. The database stays untouched: it still holds the contracts being preserved, and rebuilding it here would be the one write that could lose them. Refreshing metadata is not free, though, and the obvious version of it is a fail-open. The rewrite moves meta.json's mtime to now while bridge.lbug's stays old, so an unstamped pair whose database is NEWER than its metadata — the shape the write-order rule exists to reject — would come out of a preserve sync passing the check. Writing "no stamp" does not help; the write-order comparison is exactly what the moved mtime defeats. The verdict has to be recorded in the metadata, because the refresh cannot avoid moving the mtime. So `provenanceUnknown` is persisted whenever the existing pair does not already check out, the existing stamp fields are carried through verbatim rather than dropped, and `bridgeMetaMatchesFile` rejects the marker ahead of both the stamp and the write-order heuristic. A pair that already matched is re-stamped instead, which also upgrades a legacy unstamped-but-paired bridge to an exact stamp. No preserve run can increase the number of pairs that pass the check. The marker self-clears: `writeBridge` builds fresh metadata and never sets it. `BridgeMeta` carries two reader-side fields documented as never persisted, and this is the first code in the repo that reads metadata and writes it back. Both are stripped explicitly before every write. `pairedWithDatabase` is the dangerous one — persisted, it would tell every future reader the pair had been verified — and a test seeds both on disk to pin that neither survives. The write is not wrapped in a catch, unlike writeBridge on the success path. There contracts.json is canonical and already written, so a stale bridge is a recoverable degradation; here the write IS the guard against a confident wrong answer, and swallowing its failure would reinstate the fail-open it closes. `writeContractRegistry` above is unguarded into the same directory for the same reason. A group with neither file writes nothing: `readBridgeMeta` already answers `version: 0` for an absent file, so a written one would say what the absence already says while inventing state for a bridge that has never existed. Mutation-verified three ways: dropping the marker write turns 6 red including both laundering scenarios; moving the marker check below the stamp branches turns the unstamped-laundering case red; removing the field stripping turns the never-persisted test red. Each restored byte-exactly and re-verified. Co-Authored-By: Claude Opus 5 (1M context) * fix(group): report group_contracts' completeness in the shared vocabulary `group_contracts` returned contracts and cross-links and said nothing about whether that listing was the whole story. An agent reading it after a sync that could not open half the group got a confident-looking list with no way to tell it was a floor — the same fail-open the impact path already closed, on a surface that had no channel for the answer at all. It now returns the registry's two diagnostic lists and the structured triple, folded through the same helper the impact and trace surfaces use, so the three cannot drift. The helper takes no `BridgeMeta` precisely so this path — which reads contracts.json and never opens a bridge — can share it. The three registry states stay distinguishable, which is the point: - key absent: the registry predates the field and has no opinion about which indexes opened, so the key is omitted rather than invented as `[]`, and the listing reports a floor. It cannot say which repos the sync failed to read, so it cannot claim to be complete. - key present and empty: measured, clean, not truncated. - key present and populated: the repos, and a floor. `incompleteRepos` is dropped on this surface alone: both lists it derives from are returned verbatim beside it, and a third name for the same repos is drift waiting to happen. The import is lazy, matching `groupImpact` and `groupTrace` in this same class. `cross-impact.js` statically pulls the native LadybugDB binding through `bridge-db.js`, and `service.ts` is loaded by every `gitnexus group` subcommand including ones that touch no database. One fix inside the same file that this unit forced: the registry loader gated `missingRepos` with a bare `Array.isArray`, which admits `[{repo:'x'}]`. That was inert while nothing read the list, but this change both returns it and folds it into the completeness answer — so an unreadable value would have been printed as a repo name and would have flipped `truncated` on garbage. It now uses the same `recordedRepoList` gate `group status` already applies to the same field. `missingRepos` has always been required, so unlike `unreadableRepos` it has no "not recorded" state to preserve and an unreadable value degrades to empty. Mutation-verified: reverting the fold alone turns 14 tests red and leaves the control — the contract and cross-link payload this tool has always returned — green. Co-Authored-By: Claude Opus 5 (1M context) * fix(cli): stop dropping group contracts' completeness fields on the way out `group contracts --json` destructured `{ contracts, crossLinks }` from the service payload and rebuilt an object from just those two. Everything else the service returned was discarded on the way to stdout — so the completeness fields the MCP tool now carries were invisible at the CLI, and the two surfaces disagreed about the same registry. It prints the payload whole now. A field added to the service reaches `--json` without a matching edit here, which is the point: the re-serialized subset was a second place that had to be remembered, and it was not. The human-readable path gains the same signal in words. A listing built from a sync that could not read part of the group shows counts that are a floor, not a census, and it named neither fact. It now says so and names the repos when the registry recorded them — and says the sync did not record which repos it could read when it did not, because a listing that cannot say what it is missing is still incomplete. Mutation-verified: restoring the re-serialized subset turns the `--json` case and the control red. Co-Authored-By: Claude Opus 5 (1M context) * fix(group): tell a missing registry entry apart from an unreadable one `group status` printed MISSING for both "this repo has no row in the registry" and "the registry itself could not be read", so an operator whose registry.json was corrupt was told every repo was unregistered — and sent to re-register them instead of to the one file that was actually broken. The two are now separate. `missing` keeps its old meaning and still flags every unusable repo, so an older consumer is unaffected; `unresolvable` is additive, always present, and carries the reason that produced it. This is the one caller that has to make that distinction, so it takes the strict global-registry read. `readRegistry`'s `catch { return [] }` collapses a malformed registry into an empty one, which is indistinguishable from a genuine absence and is exactly what produced the wrong label. The cost is accepted knowingly and recorded at the call site: the strict read rejects the whole registry when any row fails to identify a repo, so one malformed row renders every member unresolvable — including members whose own rows are fine. That is the honest verdict, and it is reported as an unresolved state rather than a clean one. Choosing between the two labels needs to know whether a row exists at all, which `registryIdentifies` answers by mirroring the two tiers the resolver matches a bare group-config value on — registry name, case-insensitively, and repo path. It deliberately stops short of the hashed-id and partial-name tiers: those exist to be generous about what an operator typed, while this only picks a label, and a looser match would relabel a genuine registry miss as an unresolvable row — the same conflation this change removes, pointed the other way. The plan's third failure mode — a row that resolves but whose storage path cannot be opened — turns out to be unreachable: `loadMeta` returns null on every error and `checkStaleness` catches everything, so nothing after `resolveRepo` inside the try can throw. The reachable per-repo case is `resolveRepo` itself throwing, as it does for two registered clones sharing a name, and that is what the tests drive end to end through the real CLI. The code still handles the plan's case correctly if those helpers ever start throwing. Mutation-verified: reverting the split turns 6 unit and 2 CLI cases red while both controls — a genuine miss, and a healthy group — stay green. Co-Authored-By: Claude Opus 5 (1M context) * fix(cli): say what the preserve path actually does to contracts.json The sync summary announced "Did NOT write contracts.json" on the branch that writes it. The preserve path rewrites the file — keeping the previous sync's contracts and cross-links, replacing only the two diagnostic lists — so an operator who checked the mtime and found it moved was told the opposite of what had happened, on the command this PR exists to make legible. It now says the previous contracts were kept and names what changed. The no-prior-registry branch is narrowed for the same reason. It claimed nothing at all was written, and that is no longer true either: this path still records the run against an existing bridge's metadata. The claim is now scoped to contracts.json, which is the file it can actually speak for. Co-Authored-By: Claude Opus 5 (1M context) * fix(group): stop the total-failure log promising a preservation that did not happen The warning fired before the prior registry was read, so it could only ever promise one of the two things that might be true — and it promised the wrong one to every group that has never synced: "keeping the contracts from the previous sync" about a file that does not exist. The console line for that same run, driven by `registryOutcome`, said the opposite. It now lives inside the branch, after the read, with one message per outcome chosen at the point the outcome is decided. The log and the console cannot disagree, because the same fact selects both. Both messages keep the warn level and the two repo lists. Mutation-verified: reverting the split turns the no-prior-registry case red while the preserved case — whose claim was already true — stays green. The dry-run test's log filter was also widened to the sentence both messages share, or the new wording would have made that assertion match nothing and pass regardless. Co-Authored-By: Claude Opus 5 (1M context) * fix(group): make the bridge-failure warning describe what the code guarantees The warning after a failed `writeBridge` promised that cross-repo impact would report `truncated` until a sync succeeded. Nothing on that path produces that signal. The swap is the last step: `writeBridge` builds the new database in a staging directory and only then moves the old one aside. A failure during the build therefore leaves the previous sync's `bridge.lbug` exactly where it was, beside the `meta.json` stamped for it — a pair that passes `bridgeMetaMatchesFile` with the previous run's `unreadableRepos`. The next cross-repo query answers `truncated: false` from superseded contracts, which is the opposite of what the operator was told to expect, and worse than being told nothing. The warning now says what is actually true: contracts.json is intact and canonical, the bridge was not replaced, cross-repo queries may still answer from the previous sync's contracts, and nothing marks them as superseded. The metadata is deliberately NOT re-stamped to make the original promise true. That would recreate exactly the metadata/database mis-pairing the stamping on the preserve path exists to prevent, and the comment at the warning records it. The claim is asserted against captured log output rather than left to the state tests. Those check which pairs match and what the preserve path writes; every one of them stays green while this sentence reverts to promising a truncation. An unasserted user-facing branch is the defect class this change is closing, so it does not get to close it while remaining one. No filesystem shape makes the real `writeBridge` fail while `writeContractRegistry` succeeds — they write into the same directory one line apart — so the failure is armed through a pass-through wrapper on the file's existing mock. It delegates byte-for-byte unless a test arms it, and is reset around the new suite. Mutation-verified: restoring the original wording turns its own assertion red and nothing else. Co-Authored-By: Claude Opus 5 (1M context) * docs(mcp): name every registry outcome group_sync can actually return The tool's description told agents `registryOutcome` is 'written' or 'preserved'. It has a third reachable value: 'no-prior-registry', returned when nothing could be read AND there was no previous contracts.json to carry forward. An agent calling this tool against a group that has never synced got a value its own tool description said did not exist, and no way to tell it apart from the case where the previous contracts survive. The distinction is the whole point of the value. After 'preserved' there is a registry to read — stale, but real. After 'no-prior-registry' there is nothing on disk at all, so a following group_contracts or group_impact has no registry rather than an old one. Those need different responses from the caller. 'not-attempted' stays undocumented because it is unreachable through this tool, and a guard asserts it stays that way. The code comment above the annotations claimed the preserve path does NOT write contracts.json. It does — it rewrites the file, keeping the previous contracts and cross-links and refreshing only the two diagnostic lists, which the CLI's own summary was corrected to say a few commits ago. Left alone it would have re-seeded the same wrong claim next to the text that now states it correctly. Mutation-verified: deleting the 'no-prior-registry' sentence turns the guard red. Co-Authored-By: Claude Opus 5 (1M context) * docs(mcp): explain structural incompleteness on the impact tool and status resource The impact tool's GROUP MODE paragraph described one cause of truncation — the fan-out running out of room — and left an agent to assume that was the only one. So a `truncated: true` carrying `truncationReason: 'incomplete-sync'` read as "retry with a smaller scope", when retrying returns the identical floor forever: the repos are absent from the bridge itself, and only a re-sync puts them back. The old text also said the response carries the truncation fields "when it stops early", which is wrong for that case — `truncatedRepos` names repos even when ZERO crossings to them were attempted, because their contracts were never in the bridge to cross to. The paragraph now branches on the reason and gives each its remedy: 'timeout' and 'partial' are runtime limits where a retry or a larger budget can help; 'incomplete-sync' is structural and the remedy is `group_sync`. The reason union is now derived from an exported `as const` array rather than written as a bare type. A type-only union gives a guard nothing to enumerate, so the guard has to hand-list the members — and then it passes forever the moment a fourth is added, which is the exact regression it exists to catch. The guard iterates the runtime array instead. Verified by appending a probe member and watching it go red, then removing it. The resolved type is unchanged; every importer uses `import type` and none needed an edit. The status resource said "Group index / contract staleness" and nothing about the distinctions its payload now carries. It explains all of them: a repo absent from the registry versus one whose entry could not be resolved, and the `unreadableRepos` tri-state where an ABSENT key is not an empty one — absent means the last sync never recorded what it could read, so cross-repo answers for that group are a floor. The description an MCP client actually receives lives in `getResourceTemplates`, not in the context resource's inventory line the plan pointed at. Both now carry the vocabulary, so the two surfaces cannot disagree about the same payload. Co-Authored-By: Claude Opus 5 (1M context) * feat(group): serialize group syncs behind a fail-closed per-group lock Two concurrent syncs of one group could lose one another's writes. Both read the prior registry, both built contracts, both wrote — last writer won, and the loser's work was gone with nothing reporting it. A group sync is long and expensive and is exactly the operation whose lost update destroys contracts. `syncGroup` now takes a lock for the whole persist section, acquired exactly once. `acquireIndexLock` is not reentrant, so a second acquisition anywhere below would deadlock the happy path rather than an edge case; `withGroupSyncLock` has one call site and nothing inside it re-acquires. The lock lives on a dedicated `sync-lock` directory inside the group directory, mirroring the registry lock's dedicated directory rather than reusing the resource's own — a lock directory that could collide with a per-repo index slot repeats a bug the registry lock's comment already warns about. It fails CLOSED, which is the opposite of `withRegistryLock` and deliberately so. That one degrades to unlocked because it guards a sub-second JSON merge on a latency-critical path; here running unprotected is the outcome the lock exists to prevent. Three exits are covered: a timeout, an unwritable lock directory, and the lock-free degradation the primitive performs silently. That third exit needed a change in `index-lock.ts`, and it is the one declared exception to keeping this work inside core/group/. `acquireIndexLock` answers a read-only or permission-denied filesystem with a no-op handle that is byte-identical in shape to a real one, so a caller for whom lock-free is not an acceptable outcome could not tell the difference. It now carries an optional `lockFree` marker. The change is additive by construction: no signature moves, no control flow changes, nothing about when or how a lock is taken changes, and every caller that ignores the field behaves exactly as before. A filesystem probe inside the group module was considered and rejected on evidence: `selectBackend` returns `socket` on Linux and Windows, where `acquireViaSocket` never touches the filesystem and this branch cannot occur — so a probe would refuse syncs on the two platforms that never degrade while missing the one that does. The timeout ceiling is a named 600s constant passed explicitly. The magnitude matches the primitive's own analyze-sized default because a group sync is analyze-shaped and a legitimately queued second sync must be able to wait out a full first one. Passing it explicitly is about the override, not the magnitude: `resolveTimeoutMs` resolves `GITNEXUS_INDEX_LOCK_TIMEOUT_MS <= 0` to Infinity, which would turn fail-closed into a hang. Cross-process exclusion is proved with a real spawned holder, not an in-process mock, which cannot demonstrate the property this exists for. The lock-free scenario pins `GITNEXUS_INDEX_LOCK_BACKEND=file` — unpinned it would pass on two of three platforms while measuring nothing — and produces the failure by injecting EACCES on one syscall rather than by chmod, so it runs identically on Windows instead of being skipped there. The CLI reports the failure through pino rather than a bare stderr write, which this package lints as an error to keep that migration moving, and the test reads the `msg` field rather than a raw substring — matching on the raw text would have passed only by accident of quoting and would go green again if the line were downgraded. Nothing is skipped on any platform, and the test is registered for the cross-platform shards. Mutation-verified: removing the lock acquisition turns 6 scenarios red; removing the lock-free rejection turns the degradation scenario red on its own. Co-Authored-By: Claude Opus 5 (1M context) * fix(group): make the sync-lock timeout name a cause it can establish The fail-closed lock surfaced the primitive's own timeout message to users for the first time, and that message says the wait was on "another gitnexus analyze" — a cause its detection path cannot establish. It is the same confident-about-what-it-could-not-determine claim this PR exists to remove, inherited rather than written. The wrapper now throws its own. It names the group, the lock directory, the operation, and the elapsed wait, and it says plainly that nothing was written. The holder clause branches on `holderKnown`. The socket backend exposes no owner metadata and reports a placeholder pid of -1, so on that backend — and on the file backend's malformed or vanished-lock timeouts — the message says the lock stayed held but the backend cannot identify who held it, rather than printing a pid that means nothing. The elapsed wait is measured by the wrapper. `IndexLockTimeoutError` carries only `holder` and `holderKnown`; the figure exists solely inside the string being replaced, so it had to be taken rather than read. One pre-existing assertion changed with it: the timeout case asserted `'Timed out after 600000ms'` from the inherited text, which is precisely the message this replaces. Mutation-verified: restoring the inherited message turns the three assertion cases red and leaves the control — a real acquisition that succeeds — green. Co-Authored-By: Claude Opus 5 (1M context) * fix(group): stop a losing sync from downgrading the one that beat it to the lock Serializing is not ordering. Both syncs run extraction outside the critical section, so a total-failure sync that acquires second reads the winner's fresh registry as `prior` and rewrites it with all-unreadable lists. The lock alone does not prevent that — it only decides who goes second, and the loser then overwrites a healthy registry with a description of its own failure. Deterministically, not as a rare interleave. The guard is a compare-and-swap on the registry file's own identity: stat before acquiring, re-stat after, and write nothing when they differ. Identity is presence plus size, mtime and inode — `writeContractRegistry` publishes through write-then-rename, so a real replacement always changes the inode even if size and mtime happen to collide. Deliberately NOT keyed on `generatedAt`, for two independent reasons. It is stamped when the registry object is built, before the lock is acquired, so a winner that waited would write a value older than the loser's start. And the preserve path carries it forward verbatim by design — it dates the contracts, not the write — so after any preserve sync it does not date the write at all, leaving the comparison blind on exactly the pairing this guards. A file-identity compare also needs no cross-process clock agreement. The skip reports the existing `preserved` outcome. Nothing was written and a prior registry was kept, which is what that value already means; a new one would falsify the guard asserting the sync tool's description names every reachable outcome, and would fall through the CLI's outcome chain, which has no fallback. The bridge metadata refresh is skipped too, which the plan did not specify. `refreshPreservedBridgeMeta` stamps THIS run's repo lists into meta.json, and meta.json is where cross-repo impact reads completeness — so writing it would report as unaccounted-for exactly the repos the winning sync had just accounted for. That is the same downgrade being refused, one file over. Skipping both is what makes `preserved` an honest answer here. Mutation-verified: removing the after-stat and the skip turns the three decisive cases red while both non-misfire controls stay green. Co-Authored-By: Claude Opus 5 (1M context) * refactor(group): run the bridge swap inside the caller's critical section The bridge swap needed the group lock, and could not take it: `syncGroup` already holds it when it calls `writeBridge`, and `acquireIndexLock` is not reentrant. Acquiring inside the swap would deadlock every sync on the happy path rather than on an edge case. So the body splits the way this repo already splits this shape — a lock-free `writeBridgeUnlocked` whose precondition is that the caller holds the lock, and a thin `writeBridge` wrapper that acquires it for direct callers, mirroring `registerRepoUnlocked` / `withRegistryLock`. `syncGroup` calls the inner one; everything else keeps calling `writeBridge` and is now serialized by it. `writeBridge`'s exported signature is byte-identical to before, so no caller changed and nothing about the exported surface moved. The precondition is enforced by a comment naming the single production call site, which is what the existing precedent does. A type could carry it, but the repo's own answer to this question is a comment, and diverging here would make this the odd one out for no additional guarantee. `refreshPreservedBridgeMeta` is deliberately left unsplit. Its one caller is already inside the critical section and it has no test callers, so an acquiring wrapper would be dead code standing in for a guarantee the caller already provides — and moving the lock inside it would be the second acquisition this change exists to avoid. Scope: this delivers writer-writer exclusion only. The reader-side promotion of a leftover `.bak` into place runs on ordinary reads, outside any lock, and is not claimed here — the pairing check remains the reader's defense. Confirmed as live behavior while writing the crash-recovery test, which asserts on file state rather than through `bridgeExists` for exactly that reason. One test file beyond the two the unit named had to change: a suite mocks `bridge-db` to inject a `writeBridge` failure and exercise the bridge-write warning. Once the sync calls `writeBridgeUnlocked`, that fault was being injected into a function the path no longer calls, and the test went red. The mock is repointed. Mutation-verified three ways. Pointing the sync back at the acquiring wrapper deadlocks a single UNCONTENDED sync — the evidence that the nesting defect is real and that this split is what prevents it. Removing the wrapper's acquisition turns the direct-write exclusion case red. Making the lock-free half acquire for itself turns the held-lock case red. Co-Authored-By: Claude Opus 5 (1M context) * test(hygiene): reach every tracked text file with the raw-byte guard The guard claimed to protect tracked source from a raw NUL — the byte that makes git classify a file binary and costs it its diff, its inline comments and its three-way merge on GitHub. It matched on an end-anchored extension regex covering the JavaScript family, so most of what this repo tracks was never looked at: JSON, YAML, TOML, Markdown, snapshots, SQL, protobuf, the .NET project files, the shell and batch scripts. Worse, an extension regex cannot reach a file that has none. `Dockerfile`, `CODEOWNERS`, `LICENSE`, the husky hook and every bare dotfile were unreachable by construction — no amount of widening the pattern would have covered them — so a second basename filter had to exist for the claim to be true. It stays an allowlist rather than becoming "everything git tracks", because the repo legitimately tracks binaries whose extensions must stay out. The two filters together now collect every one of the 5000 tracked files except 31 — the 30 native prebuilds and one PNG — and those 31 are exactly the files that carry a NUL. The allowlist no longer has a gap that is not a genuine binary. The planted-fixture cases route through the collector's own predicate rather than straight into the scanner. The pre-existing fixture test bypassed the filter entirely, so it could only ever prove the byte locator worked, never that the collector would hand it the file — which is precisely how the gap survived. Mutation-verified both ways: removing the basename filter drops `.gitignore` and `Dockerfile` from the planted results, and reverting the extension regex drops `.json` and `.md`. One added case is a preservation pin rather than proof — that tracked binary formats stay out passes either way, and guards the allowlist from becoming a denylist later. Co-Authored-By: Claude Opus 5 (1M context) * test(hygiene): stop the byte guard reading the vendored grammar tree Widening the guard to every tracked text format also pulled in the vendored tree-sitter grammars, and those are where the bytes are: four generated `parser.c` files come to 62 MB between them, Kotlin's alone 33.7 MB. Excluding that root drops 76 files but 66% of the bytes the scan reads — 97 MB down to 33 MB. The exclusion is a single anchored prefix, matched case-sensitively with `startsWith`, and both halves of that matter. A `vendor` path-SEGMENT match would also drop first-party fixtures this repo tracks under directories named `vendor` and `Vendor` — a Kotlin one, a PHP one, and three files under gitnexus-web — silently narrowing coverage while the assertion pinned the loss in place. Case-insensitivity would do the same to a `Vendor` directory at the excluded root's own level. The root is named in the guard itself, so the claim that it covers every tracked text file stays honest about the one place it deliberately does not look. The cost comment was wrong and is now measured rather than estimated. It said "the scan is ~10 ms" — ambiguous between locating the byte and reading the files, and stale in its byte basis. Locating is ~14 ms; the reads dominate it by two orders of magnitude, which is the actual reason for the concurrency pool and the actual reason this exclusion is worth having. Every figure was re-derived from the finished file rather than carried over from a draft. The header's claim that `git ls-files` "never descends into vendor" was already false — vendored code is tracked, so all 106 of its files were being reported and read. Corrected here, where the distinction becomes load-bearing. Registered in the cross-platform list first and given a shard weight second. The weight table is only consulted for files already in that list, so a weight entry alone is inert and the shard test filters unregistered keys without complaining. The three-way split stays within 1.01x of ideal. Mutation-verified three ways: a case-insensitive segment match, a case-sensitive segment match, and a case-insensitive anchored prefix each turn an assertion red. The casing half was initially unfalsifiable — nothing tracked is named `gitnexus/Vendor/`, so a tracked-set assertion could not distinguish it. Rather than leave the claim unpinned or invent a fixture, it is pinned on the predicate with a synthetic path; the tracked-set assertions pin the anchoring. Co-Authored-By: Claude Opus 5 (1M context) * test(group): make the strict-read test able to see which read ran The file bound both registry exports to one mock: readRegistry: (...args) => readRegistryMock(...args), readRegistryStrict: (...args) => readRegistryMock(...args), so the case named for the strict read asserted a behavior it could not attribute. Point the production call at the lenient export and every assertion still holds, because the mock answers the same way whichever one is called. That is not a hypothetical. With this file as it was, and `syncGroup` mutated to call `readRegistry` instead of `readRegistryStrict`, all 32 tests passed — the suite was blind to the exact substitution it exists to prevent, and the fix it guards could have been reverted without a single red. The exports now have separate mocks: the lenient one always resolves an empty list, which is its real contract, and only the strict one is armed by the cases that need a failure. The named case also asserts directly that the strict read was called and the lenient one was not, so the attribution is explicit rather than implied by an outcome. No tests added — the unit is about what the existing ones can see. Mutation-verified: the same substitution now turns 24 cases red, including the named one, and everything stays green unmutated. Co-Authored-By: Claude Opus 5 (1M context) * test(group): pin the CLI output branches this PR introduced The three sync outcomes and the status table's new labels had no assertions. Every one of them is a sentence about what happened on disk, and this PR corrected several that were false — a preserve branch that announced it had not written the file it rewrites, a status table that called an unreadable registry a missing entry. Text that describes state, with nothing pinning it, is how those got wrong in the first place. Six cases drive the real CLI end to end, through the two shapes that need no indexed repo: members absent from the registry, and members registered at a storage path with no index file, which makes every repo unreadable. The file header claimed no LadybugDB-backed command was driven end to end; that is no longer true and it now says so. Each branch was suppressed in turn and its assertion goes red — all five that the plan named. One of those mutations first reported PASS, and the cause is worth recording: the string being suppressed also appears inside a neighbouring branch's comment, so the harness silenced the wrong line. That is a bad mutation, not a weak test. The harness now asserts the marker it suppresses is unique before trusting the result, and the redone check goes red. The plan's sixth scenario is already covered by an existing case that asserts both labels in one table, so it is not duplicated. A seventh case was added beyond the plan: without a populated-list case, "prints neither line" would pass just as well against a CLI that never printed that line at all. Adds about 15s of measured spawn time locally; CI runs these against the built dist, which is materially faster per spawn. Co-Authored-By: Claude Opus 5 (1M context) * test(group): assert the MCP payloads by exact shape, not by partial match Nothing asserted what the group tools actually return. The sync response's unreadable list and registry outcome, and the contract listing's incompleteness fields, are documented in the tool descriptions an agent reads — and could have been dropped in a refactor without a single test noticing. The assertions are exact-shape rather than partial. A `toMatchObject` would let a dropped key pass, which is precisely the regression these exist to catch: the failure mode is an absent field, and a partial match is defined not to see one. Absences are additionally asserted explicitly. The tri-state has to survive the response boundary, and it is the reason exact shape matters here more than usual. An absent `unreadableRepos` means the sync never recorded what it could read, so the listing is a floor; an empty list means it measured none; a populated list names them. Collapsing absent into empty turns "we do not know" into "we checked, it is fine" — so a mutation that replaces the conditional spread with `?? []` is covered specifically, not just the outright deletion. Mutation-verified per field: removing either sync forwarding line, deleting the conditional spread, replacing it with the invent-empty form, dropping the truncation triple, or hardcoding the provenance flag each turns an assertion red. Co-Authored-By: Claude Opus 5 (1M context) * docs(group): stop the bridge input narrowing what unreadableRepos means The same field had three definitions. The registry and the bridge metadata both say it covers a repo this sync could not extract from — an index that would not open, or an extractor that threw partway through, one bucket because the consequence is one thing. The bridge input said only "whose index could not be opened", which describes one cause and silently excludes the other. It now points at the registry's definition instead of restating it a third time. A definition written once and referenced cannot drift; three copies of it already had. Co-Authored-By: Claude Opus 5 (1M context) * docs(group): record what the mtime pairing does and does not prove The write-order fallback is a heuristic standing in for provenance, and a future reader deciding whether to lean on it needs to know where it breaks before they do. Both directions are now stated where the function is read rather than only in the plan that introduced it. The false-accept direction is a non-monotonic wall clock — mtime is realtime, so an NTP step back, a snapshot restore, or container skew between the two writes can leave a mis-paired set reading as ordered. Coarse filesystem granularity is explicitly called out as NOT being that hazard, because it looks like it: it collapses a pair written together to equal times, and equal is accepted, which is the right answer for that pair. The false-reject direction is any copy or restore that rewrites the database's mtime after the metadata's. An intact legacy pair is demoted to a lower bound and stays there until a sync re-stamps it, because nothing on the read path can tell it apart from the swap window it imitates. That second direction corrects a claim made while planning this work: that the rule could only ever demote pairs already broken. It cannot. `cp -r` and `rsync` without timestamp preservation both produce it on a healthy group, and saying otherwise where the code is read would leave a future reader to discover it the hard way. Co-Authored-By: Claude Opus 5 (1M context) * fix(storage): stop a corrupt registry quoting its own bytes into errors `JSON.parse`'s SyntaxError embeds a window of the source around the failure — V8 gives exactly ten characters either side — and the strict read rethrew it untouched. The registry persists HTTPS remote URLs with their userinfo, so a file that breaks next to one puts the credential into the error: Unexpected token 'L', ..."end.git"},LEAKCAN4RY"... is not valid JSON The parse now has its own guarded region and reports the path and the failure class, matching the two corrupt-registry errors already in this function. The original error is discarded — not logged, not attached as `cause`. This codebase's convention elsewhere is to hand the logger the Error so it captures stack and cause, and following that convention here is precisely what would put the byte window into the log. Under MCP stdio that log is written to the client's log file on disk, so the thrown-error channel was never the only one that mattered. The `catch` takes no binding, so the error cannot be reused by accident later. That was not theoretical: a sibling commit routes this message into `unresolvableReason`, which `group status` returns to MCP clients and prints in the CLI table. Every channel was traced — throw, cause, inspect with the full chain, the logger, and both downstream consumers. The leaking shape is narrower than it first appears, and worth recording. The windowed message only fires when the parser fails at a value-start or trailing position; a break inside a quoted string yields an unterminated-string error carrying no window. So a plain mid-URL truncation does not leak — a short write landing over a longer one does, leaving a URL fragment where a value was expected. That is a reachable shape for the one machine-wide file every gitnexus process writes. The test asserts the message still names the path and the corruption class, not only that the secret is absent. Asserting absence alone would stay green if the message became empty. Mutation-verified: restoring the raw rethrow brings the token back verbatim. Co-Authored-By: Claude Opus 5 (1M context) * docs(storage): drop the stale lenient call-site count The docstring said keeping `readRegistry`'s signature untouched leaves "its nine other call sites" unaffected. There were thirteen when the discrepancy was noticed and fourteen by the time it was fixed. The same figure appeared in the test file's header. Replaced rather than corrected. A count in prose next to code that moves is a claim that goes stale without anything failing — which is the defect class this change set exists to remove, so re-seeding a fresh number would be repeating it with a longer fuse. The argument was never about the quantity: leaving the signature alone keeps every lenient caller provably unaffected whether there is one or fifty. Also withdrawn while here: the claim that the bridge schema-version guards diverge between call sites. They do not — the two forms are complements for every value a writer can produce, there are three sites rather than the two claimed, and all three agree. Recording a divergence that does not exist would leave a future reader chasing it. Co-Authored-By: Claude Opus 5 (1M context) * docs(group): add an auditable finding-to-commit map The Definition of Done claims every review finding has exactly one commit and that reverting it reintroduces that finding and no other. Without a map that claim is only checkable by whoever holds the review report, which is one person for a short time. The map lists all 28 primary findings against their commits, the three findings whose suggested fix was deliberately not implemented and what shipped instead, and the four defects found while executing that no reviewer raised. It also records the revert contract honestly. Revertability is dependency-aware, not absolute: the shared completeness helper has three consumers, so reverting it alone does not build. That coupled set is named rather than left for someone to discover mid-revert. Two sections exist because the work produced them, not because the plan asked. Six claims in the plan turned out to be contradicted by the code — among them a scope predicate that would have reintroduced the bug its unit was closing, and an assertion about the mtime rule that was simply wrong. Recording only the findings would leave the impression the plan was followed as written. Five residual risks are listed for the same reason, including that R14 is not met on this PR: the diff attribute works locally but GitHub reads it from the base side, so this PR's own sync.ts stays binary in the web view and every PR after it renders as text. Not under docs/ — that path is gitignored, so a map written there would never reach the PR and the audit it exists for could not be performed by anyone else. Co-Authored-By: Claude Opus 5 (1M context) * fix(group): read a version that is not a version as no provenance Raised by the check bot on this PR, and real — the bot found one symptom of it; the field splits four gates apart, not one. `readBridgeMeta` accepted any numeric `version`, and `0` is this file's word for "no provenance". A parseable but impossible value — negative, fractional — is not a schema version, and each gate that reads the field disagreed about it: ensureBridgeReady `> 0 && !== CURRENT` → opens the bridge openBridgeDbReadOnly `> 0 && !== CURRENT` → opens the bridge bridgeExists `=== 0 || === CURRENT` → says it is not there bridgeProvenanceUnknown `=== 0` → reports the answer complete Four verdicts about one file, and the last one is a fail-open of exactly the class this PR exists to close: a bridge nothing can vouch for, reported as fully accounted for. The suggested fix was to widen the provenance check to `<= 0`. That closes the reported symptom and leaves `bridgeExists` still disagreeing with both openers, so it is fixed at the reader instead: a version that is not a positive integer normalizes to the sentinel the gates were all written against. One change, four gates agreeing by construction, rather than teaching each of them the same new case and hoping the fifth reader remembers. Infinity is covered too, though by the pre-existing type check rather than the range one — JSON cannot carry it, so it arrives as `null`. Recorded at the test so the case is not mistaken for proof of the range check. Mutation-verified: restoring the loose numeric check turns the negative and fractional cases red. Co-Authored-By: Claude Opus 5 (1M context) * fix(group): stop a malformed contracts.json reading as an unresolvable registry entry Raised by the check bot on this PR. Its stated mechanism was wrong — `loadMeta` returns null on every error and `checkStaleness` catches everything, so neither can throw — but its conclusion was right, and there is a concrete path it did not name. `readContractRegistry` is a bare `JSON.parse(content) as ContractRegistry` with no shape check, and the snapshot lookup guarded only the registry object: registry?.repoSnapshots[repoPath] The `?.` covers `registry` being null, not `repoSnapshots` being absent. A contracts.json without that field — a legacy file, a hand-edit, a truncated write — throws `TypeError: Cannot read properties of undefined`, which lands in the catch that labels failures as unresolvable GLOBAL-registry entries. So a group whose own contracts file is malformed reported every repo as a broken registry row, sending the operator to repair a file that was fine. An error from one cause presented as another, which is the defect this PR has been removing everywhere else. The optional chain closes the crash. The try is also narrowed to the call that earns the label: only `resolveRepo` sits inside it now, so "did not resolve" describes something that actually failed to resolve rather than whatever else happened to throw nearby. The comment records why the other two calls in that block cannot throw, so the next reader does not have to re-derive it. Co-Authored-By: Claude Opus 5 (1M context) * refactor(group): give the completeness fold a module no native binding reaches The shared fold ended up in `cross-impact.ts`, which statically imports `bridge-db.ts` and through it the native LadybugDB binding. `groupContracts` therefore reached it through `await import('./cross-impact.js')` — loading that whole module graph to run a Set union and a ternary. Measured: 44-51ms and 8.4MB of RSS on first call, paid once per MCP server and once per `gitnexus group contracts` invocation. `completeness.ts` holds the vocabulary and the fold and imports nothing but types. `service.ts` imports it statically; the lazy import and the comment justifying it both go. `cross-impact.ts` re-exports so the three surfaces still have one import site for the vocabulary. Three other duplications collapse into the same move. `traceCompleteness` was hand-writing `{truncated, truncationReason, riskEpistemic}` — a third writer of the pair `truncationFields` exists to keep mechanically linked (#2787), in the file the consolidation had just touched. It calls the helper now. `recordedRepoList` existed twice, byte-identical, one copy's docblock saying it mirrored the other. That gate is the predicate the whole absent-vs-empty-vs-populated distinction rests on, applied to the same two lists on both the registry and the bridge — tightening one copy would have fixed one surface silently. One definition now. The trace's scope predicate compared repo paths with `===` while its sibling in `cross-impact.ts`, added in the same change, went through `repoInSubgroup` with a comment about not growing a second notion of membership. It had grown one: the helper normalizes separators and strips trailing slashes, so the same group.yaml spelling could be in scope for impact and out of scope for trace. Also here: `registryIdentifies` was a third, weaker copy of the registry's path rule — it skipped `realpath`, so a symlinked row would not match where the real resolver would. It uses `canonicalizePath`/`registryPathEquals` now. `contracts.json` is no longer respelled as a literal in `sync.ts`; `storage.ts` owns the name it reads and writes. And the runtime-truncation predicate is bound once instead of written out at both the flag and the reason, where forgetting the second would label a retry-able answer `incomplete-sync`. Co-Authored-By: Claude Opus 5 (1M context) * fix(group): give the lost-the-race sync its own outcome instead of overloading preserved A sync that finds contracts.json replaced while it waited for the lock reported `registryOutcome: 'preserved'`. That value already meant something else, and the two differ in exactly the thing the value is for: `preserved` rewrites the file with this run's diagnostics; this path does not touch it and deliberately does not record them. So both surfaces stated something false about disk. The tool description told agents `preserved` means "contracts.json was rewritten ... refreshing only missingRepos/unreadableRepos to describe THIS run (the file changed)". The CLI said "only the unreadable/missing repo lists were refreshed to describe THIS run". On the lost-race branch nothing was written and the log line beside it says so outright. That is the defect class this whole change set removes, reintroduced by the change set itself — and the reasoning recorded at the time makes it worse, not better: a new value was rejected because it "would fall through cli/group.ts's outcome chain, which has no fallback branch". A renderer limitation decided a domain value, and the description then had to cover two states with one sentence that fits one of them. `superseded` is its own outcome now, described in its own words to agents and rendered in its own words at the CLI. The registry on disk is FRESHER than this response's diagnostics, which is the opposite of every other non-written outcome and is why an agent needs to tell them apart. The CLI renders from a `Record` keyed on the union, so the next outcome fails the build here rather than printing nothing — the gap that made folding the state in look like the cheap option. The description guard is scoped per clause rather than over the whole string. It forbade "untouched" anywhere, which was right when one clause could only lie in that direction and wrong now that another clause is accurately untouched. It also asserts the superseded clause says so, or the two collapse back into one word for two states. Found by the quality pass over this branch, not by review. Co-Authored-By: Claude Opus 5 (1M context) * test(group): read bytes and stat through one handle, not two path lookups CodeQL flagged both sites as `js/file-system-race`, high severity, and it is right about the shape. `stat(path)` followed by `readFile(path)` is two independent path resolutions with a window between them — the classic check-then-use race. It also made the assertions weaker than they read. These two tests exist to prove a specific file was left untouched, and two lookups can land on different inodes, so "the bytes and the mtime are both unchanged" was not actually a statement about one file. The distinction is the whole point here rather than a technicality. `snapshotFile` opens the path once and takes both answers from that handle. The race is gone because there is no second lookup, and the assertion now genuinely concerns one inode. I had previously triaged these as below the ruleset's threshold and left them for the repository owner. That was wrong: they carry `security_severity_level: high`, and the branch ruleset gates on `high_or_higher`, so they were blocking the merge rather than sitting under it. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Gergő Magyar Co-authored-by: Gergo Magyar --- .gitattributes | 12 + gitnexus/scripts/cross-platform-shard.ts | 10 + gitnexus/scripts/cross-platform-tests.ts | 34 + gitnexus/src/cli/group.ts | 158 ++- .../src/core/group/REVIEW-FINDINGS-MAP.md | 107 ++ gitnexus/src/core/group/bridge-db.ts | 390 +++++- gitnexus/src/core/group/completeness.ts | 137 ++ gitnexus/src/core/group/cross-impact.ts | 135 +- gitnexus/src/core/group/cross-trace.ts | 155 ++- gitnexus/src/core/group/group-lock.ts | 201 +++ gitnexus/src/core/group/service.ts | 218 +++- gitnexus/src/core/group/storage.ts | 7 +- gitnexus/src/core/group/sync.ts | Bin 17612 -> 39812 bytes gitnexus/src/core/group/types.ts | 138 +- .../passes/free-call-fallback.ts | 2 +- gitnexus/src/mcp/resources.ts | 24 +- gitnexus/src/mcp/tools.ts | 14 +- gitnexus/src/storage/index-lock.ts | 29 +- gitnexus/src/storage/repo-manager.ts | 132 +- .../test/fixtures/group-sync-lock-child.mjs | 62 + .../test/integration/group/group-cli.test.ts | 478 ++++++- .../group/group-sync-lock-concurrency.test.ts | 609 +++++++++ gitnexus/test/unit/group/bridge-db.test.ts | 294 +++++ .../group/bridge-meta-swap-window.test.ts | 506 ++++++++ .../bridge-pairing-precedes-open.test.ts | 107 ++ .../group/cross-impact-fanout-cap.test.ts | 9 + .../cross-impact-incomplete-bridge.test.ts | 449 +++++++ .../cross-trace-incomplete-bridge.test.ts | 305 +++++ .../group/manifest-synthetic-impact.test.ts | 9 + .../group/registry-unreadable-repos.test.ts | 519 ++++++++ .../group/service-group-sync-payload.test.ts | 304 +++++ .../group/sync-partial-extraction.test.ts | 587 +++++++++ .../unit/group/sync-unreadable-repos.test.ts | 1152 +++++++++++++++++ .../group/sync-windowed-resolution.test.ts | 4 +- gitnexus/test/unit/group/sync.test.ts | 8 + gitnexus/test/unit/group/types.test.ts | 4 + .../repo-manager-registry-strict-read.test.ts | 315 +++++ .../test/unit/source-control-bytes.test.ts | 505 ++++++++ gitnexus/test/unit/tools.test.ts | 91 ++ 39 files changed, 8129 insertions(+), 91 deletions(-) create mode 100644 gitnexus/src/core/group/REVIEW-FINDINGS-MAP.md create mode 100644 gitnexus/src/core/group/completeness.ts create mode 100644 gitnexus/src/core/group/group-lock.ts create mode 100644 gitnexus/test/fixtures/group-sync-lock-child.mjs create mode 100644 gitnexus/test/integration/group/group-sync-lock-concurrency.test.ts create mode 100644 gitnexus/test/unit/group/bridge-meta-swap-window.test.ts create mode 100644 gitnexus/test/unit/group/bridge-pairing-precedes-open.test.ts create mode 100644 gitnexus/test/unit/group/cross-impact-incomplete-bridge.test.ts create mode 100644 gitnexus/test/unit/group/cross-trace-incomplete-bridge.test.ts create mode 100644 gitnexus/test/unit/group/registry-unreadable-repos.test.ts create mode 100644 gitnexus/test/unit/group/service-group-sync-payload.test.ts create mode 100644 gitnexus/test/unit/group/sync-partial-extraction.test.ts create mode 100644 gitnexus/test/unit/group/sync-unreadable-repos.test.ts create mode 100644 gitnexus/test/unit/repo-manager-registry-strict-read.test.ts create mode 100644 gitnexus/test/unit/source-control-bytes.test.ts diff --git a/.gitattributes b/.gitattributes index 5110ebb5d..eeb1a0976 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/gitnexus/scripts/cross-platform-shard.ts b/gitnexus/scripts/cross-platform-shard.ts index 1a026a5e0..8728d8e31 100644 --- a/gitnexus/scripts/cross-platform-shard.ts +++ b/gitnexus/scripts/cross-platform-shard.ts @@ -65,6 +65,16 @@ export const WINDOWS_WEIGHTS_SEC: Readonly> = { '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, }; /** diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts index 1994abf6b..f5feeeed5 100644 --- a/gitnexus/scripts/cross-platform-tests.ts +++ b/gitnexus/scripts/cross-platform-tests.ts @@ -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 = [ diff --git a/gitnexus/src/cli/group.ts b/gitnexus/src/cli/group.ts index 3111f69d0..c68f7142d 100644 --- a/gitnexus/src/cli/group.ts +++ b/gitnexus/src/cli/group.ts @@ -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>; + 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 = { + 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(() => {}); diff --git a/gitnexus/src/core/group/REVIEW-FINDINGS-MAP.md b/gitnexus/src/core/group/REVIEW-FINDINGS-MAP.md new file mode 100644 index 000000000..aebe73303 --- /dev/null +++ b/gitnexus/src/core/group/REVIEW-FINDINGS-MAP.md @@ -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. diff --git a/gitnexus/src/core/group/bridge-db.ts b/gitnexus/src/core/group/bridge-db.ts index 5fbe711aa..17eb24b76 100644 --- a/gitnexus/src/core/group/bridge-db.ts +++ b/gitnexus/src/core/group/bridge-db.ts @@ -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 { + 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 { + 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 { + 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; + 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 { + 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 { + 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; 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 { @@ -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 { + return withGroupSyncLock(groupDir, () => writeBridgeUnlocked(groupDir, input)); +} + /* ------------------------------------------------------------------ */ /* openBridgeDbReadOnly */ /* ------------------------------------------------------------------ */ diff --git a/gitnexus/src/core/group/completeness.ts b/gitnexus/src/core/group/completeness.ts new file mode 100644 index 000000000..326d10b2d --- /dev/null +++ b/gitnexus/src/core/group/completeness.ts @@ -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` 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; +} diff --git a/gitnexus/src/core/group/cross-impact.ts b/gitnexus/src/core/group/cross-impact.ts index 06c16d3fa..21cbbe055 100644 --- a/gitnexus/src/core/group/cross-impact.ts +++ b/gitnexus/src/core/group/cross-impact.ts @@ -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 { - 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): 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 })?.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, diff --git a/gitnexus/src/core/group/cross-trace.ts b/gitnexus/src/core/group/cross-trace.ts index 7c115de01..ddb771703 100644 --- a/gitnexus/src/core/group/cross-trace.ts +++ b/gitnexus/src/core/group/cross-trace.ts @@ -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.', }; diff --git a/gitnexus/src/core/group/group-lock.ts b/gitnexus/src/core/group/group-lock.ts new file mode 100644 index 000000000..7d25753af --- /dev/null +++ b/gitnexus/src/core/group/group-lock.ts @@ -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. `/sync-lock` is a namespace nothing + * else claims: group directories live under `~/.gitnexus/groups/` (or + * `$GITNEXUS_HOME`), never under a repo's `.gitnexus[/branches/]`. + * + * 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: `/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 ( + groupDir: string, + operation: () => Promise, +): Promise => { + 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 `/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(); + } +}; diff --git a/gitnexus/src/core/group/service.ts b/gitnexus/src/core/group/service.ts index f1356dcec..79f67abe6 100644 --- a/gitnexus/src/core/group/service.ts +++ b/gitnexus/src/core/group/service.ts @@ -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) : {}, - 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>; + 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 = { 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 = { + 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> = @@ -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, }; } diff --git a/gitnexus/src/core/group/storage.ts b/gitnexus/src/core/group/storage.ts index 6e568cd6e..ab12599d5 100644 --- a/gitnexus/src/core/group/storage.ts +++ b/gitnexus/src/core/group/storage.ts @@ -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, diff --git a/gitnexus/src/core/group/sync.ts b/gitnexus/src/core/group/sync.ts index a329500be5a0edb4a4588be0960b53d4c79b71aa..9923782367eea53e66d99e569b1c2b0a534be084 100644 GIT binary patch literal 39812 zcmeI5-*P0!mEQ096vbJ^q9Fl2q+}~r;F2^r1Cj6!XC{E*auqaVyU<+#TGQRt>Z%?L zJemk!Il@QFG)935?>Y`fL`t_!2zb@ZiRLy)= zty+EB9DNWUG9Eu(Ky(s2?U$&e1wAH8W z{kkgRWoIk9HhTnHW95OGxo&Q2d+;S>s~K0fo6?8!WK%cIv-#?^E;m|Vm#g`8)wKR$8rEYyvFT5^_^NH! z7d2cxdutTyZcWCItN2;-_?GLA8E9J_r%wl@zTB**KSJe(OZMLX>UJ}~ZhwSAYZvXO zQLpDeHi%Z&?4@wNnl84pA7jVdui8r`F2Rq?2Dz}6+Dm6PZ>IH|YV&QqdEKnbX{9x7 zlG^l^=q_Hfm)g~KzL>q-)Njy^&G~9py*(;6+tt}yMCt3Yo!;7?-*VBk+|2Bg>`_Zy zF<;#bmVJgQPTR|B^JYG+zNojWS-F{4%~4S{&HQE+zR2h_e(CX*UweP-6zW~-Q9QX{ z-&UJ5)BK)nPJ;^Uoh+sxs!3GTmlz?Yh=vcpIV$cp^R^m(oR8Y|c`*4+b-k&Y+n4;q ztg6`;oB8af`nqb%K+!+=X0@oNud7)`@5-(`o?Q(n-p$)v)8fnf)$|#6W%u03QXGp^ z?_~7x+fBr%7 z^WxjvvMp}Q^}1RiLTz14ra|~+&Fj^qs8>Z<+|1uptD<3<#@wYFkBf=Kx~;gxfBy8J z{(I5hRz)HZml48O_k1;PWD(%*myrzm=>n%h=4(A+MdQkjAs z6-{k7bHkf?y*082O*Zri{_5vpSpEM@rqQJ}lLAjzOe9ay3-Z}rwW*5N)w(Uj>av|L zb=f|0HB7gm%;x3Is&3l(lqpqf`lhUT==0oKA|pj4Mu`Kh5!n{QxD5Us?7 zqJ(Wtc@6JwtJPj=48;^0rGmp1LS$;gDl#I(g0U_!kM(Ok6HcOB(t-W-V9%_sv|23+ zMeG?5XWn%pg_YfS~4h4R29RvpuzK|X)+>Z|kTU)ohcJoc|!uqP;fr+oY#6$PUu zmxfI(Tf}2QSi#fq><(zYaE(ykiw zNU#Pg3+xy+7a`IrL?ds@GFOzXkC)>5Y*Vl8kFj`J6uI|_k%f=OTz`RVJa{Jh(H5r{ zXW=32_s?r?&Mao#`U#7Ts%S3(-N39hAETu$pj%{6q@?Lq4sCr^Y;oQr}bv`_c5-M zvyRb#1#O907AbW+koxQhm&U~tQCvwdwW~X zu#^h|3Y=zz88(|-E?{}b#wct-{lqH|g>$9+cjfmzwJND@32P| zA97I~QlyHxum=+*+%imutj5xye3VR3xX|imbfC%q`rm9fi-W^)vmhNFeRNd(+UB7M zcT=_74eIc-ce?QX@z36g^81OHQWWp`SN{)J=@MJFv|zL9y2Q+3?34g`Vm0b`@o;&J zIe}bhM&TzXN+&5>ld}1amIimH;dz!R<8 zht*-6v$8N}hEo<$cP+BaHqi_20*GXqvQmG=g;I&vrOKz8QHZxe~M*D-+%XgW*%&8pAvK56_-^TEc$1; zUBCwRZ~nA!kC<5kJy2f9EoV;{Ut_U9m5S**(^V|Xx_*Chl7H0PFR$vwH}l#1KZcLn zdT<>d?6?X9HQi#oBtSl&MHQAfYldU=dc;T$4{e#|t1YT53A8WE+04H5kC^Xxy=`ud zizdmL5#Y;QzRm?|&)a*=eG(rZF#w_hIpaB|vP9Yx6CyA3mdxASC$8!Ai_5d;PtHO> zE3qKPEMxS2Z5P*fs}nwp%IX3f<50_ONby+<&*(zhfukb*5kbF#K>!HJiA{;-Z+P_Y zIDgp$Z> z8q9jR=0QsQOiR)X>Z{)aP=?tG0&R~TOhJ5@{$V>Om@?!7rYMNAm@1@U7D#iM2=zvi z* zSOSZ(@Ymag)AOh4rcNIz1ft~CAGYN}`T7;n79&7_qwt4-&a!ZYp^jCEl%G-dLr@{UoN zcC@FEGK!Bp9K>H8_uI`jF&|7!tB@@LpM>QIYrzMsi57&o3N@$P6vh@pyMX}MbsuQ` zoI{GM^2sujQMp@6Sfkxvz_+0AaENh~<6sCpjYxlyOO!UD@VVwz*oR?G;q&;HeWlr1 zHllgD*cGSbCYmTNeGq)PV+3JFAxTYf9IIo8%M{Tl+H;Rj_-C?1a=Mz;cLpsysNTea zu{DHkUkZVXUq2&iN&rN0-hZI4xcRB((2c%7lBC~=A<$v4pP0%OfBG-~i&>?5 zd_X;H_@FFZ0GJ0kFY%sp%TNdWz-R+!+4!+#fG#C$^S`?XqmnIQL}UJbl0AEZaST{t zpO51rP97?xR$F_vY_CirT?XZXwKbn*7@x%~HVUTgD-$;3YhXP*9PSJso2dHq&VvstcmL4;_RPJpS*hZ zI}6nVyGO_lh7!FBf{zVCkMVez{G|p)`Jk6^g%60iIsnNqKpSodIq*Y@`h0-}P2L&g z8~N+NU$s>Dw*}$F+~#FLC|OaD0IS|yH8Cc?Fw3LI2-kbPVwm<3G0TiVUrSLuHf+z) zox_4`2c!$ljp7xwM)SJbQY`mWMVvKJv`~J&SQMcEIbsrw@!kt|I6JPSf_MDQ`BM`c zwZSw5Msw46OEcsRNUg1SmXyz3=@0} zqnM))Sk(SAdU>(5p?PsFkG0tGNN#rUlbg&f6~%W8%WjW~1pw6dpe_q+6_s~`UAn

cU ze)))Qh$|bI?&jg6@^DP>Pv!iHWepXp>|b0RYOFBPNd{@%!Mg|xED)gSb~C&pfK?18 zf=WB^13i?=MlS%uGBn(kW{wbq_(s`1Pl}hvAtK=Bt_X8=8&Ok#Ek5L)4-H&s;$n;! z)#|3bRs5o#Ksa1u-ryI~n@IMvT%x+i7tvNaexRX6ii~Z$$>y%72v&&y8t!Iu@#|;? zabf@Nd-ca4iwN!zqw&DZNc-vzaEro{vH;d@6wrtpKp@*qn3|9eAEXA8_!$|X+A1!&Ds-=)OffXprx#W{a!AvKZg*7% zc8194F(A7Fupg|%+{v1%AwtG7^vXB{7^`(yn(8flc>Fjk&;NufOv<0MP*_vk9SDmP zVggp|!Tl+XQ@sgLWL*4%nkAYFb0;%z&6|VPhpb-eu0rEdww@)G<*I>fX#SMNjs<-o zMuBT4K3-U%6T;>Qje6tb+f)YebXcfGKmw!~E{TvK2?k>|#kL`J6i%crkH$(CuaQYW ztX)k@OiA|?W{gk^=5{RJ-Wd}k?a|2L0z zQyMFKt7!&3X|_{=IzR?2siG#2tC7v824MIKxVp2Bap;gpG5J+Rzm=N3j&*5-A&!PY9d|*k#mq~{JLDHf+T>0 zRVt#F{-jW!u@?^9Zx`x0J4#7MigrL7oFXerL?s1L-T*B72Cb!wmmZw?%G3*$uGUI1oMj>QRkp<1N!u0c zv#RuU)2st_N#X1%E0z&Bcd zs5SaRf*HbkrAts^-HxNVHJO=95&|{8q~2TK)RP zcCD<)ej=xWtw{@$LtM_|xCT+67`{r;+%cdLBcEtqE&WImWJV-!8ZAEmTnh29o*EvI zA}Ob*?)S5|kdx*=Y?&X9Qf=<(@?Kl~@JerIPuC7w;2&J=-R)r3a9_n| z5zHEM6PrC6{LpXA3HEzrJVZ=m%x-72-N{%%896bomjXdqHl-+?cyL)gG43=Z+<4FC&md^yE{yEwG?)vH%8 zV`!?NNgi6Qie_f~lADW{PqIq{TwAD_=4>`$PXCdkI+jn92{rf~a(?-psHntvDhKH! zhj!hax464}g!(>)l_d3Gi2FIs~?Kv?#-I|^nEGkHanN6Bc z1GJH~^;zqmMf0Er4U33v@K#;{AK@ojmi12x!2G7psrlco4-qpOh4zFLE%fA>^?y$D zFY7ONagwZWTn+`q&O+L2X{`3$QT2wlz0^jU25){w+_Bmm3VGX)!feSG_#F2!sy`4E zLN=@QuJFTsf>zlOT$%%6CXk!C?JY<_&Il<5cD-Jh1|ZIAmy1qIykWLhADrG++9>gY zRr?G#B8hKrfdwO_o^mMKLxEv6D*m3#HI>jM_O!?-o49rGP{s9xdK?W?#c_p^ls&JLqq>0gke@Qg;YW zoMb+rPr6q>erJD6UJ~oxt@60zu3pK~GnE(8p8`GsLVNy5#)QzZ(6M6iQj| zN9RD}Wguu%VTt`8-MXWLsk`|mGbJg0G&ZB`?QQE_B{Y6PB7+iu4u)#ipR!AlZ| z^kJ-N#4*XiCS0~lh6R=OH5|_;_+G7uC&iL}j_^nEM>fA44Bct>WL@0-wd-obdgFNd zZ7}iEU|twhNXT*O`3PDw!oENflOM z$fl-{rK}3!$y!v-wrYN4l@bfsWw(Q>UMPr%VCdwq;F~=xwAztDD}`Mv>(4@;$J(nB zf1q`0Ikr(?vq)9&UV=H zb!BN!$!F}pBVG3%4(@E)gF)s~z9H||N~qQ}eQgl17_HIy)=i!enjm>jn9LGZGrB%E zi!Z!+STlv+N4}U~@fsYhjBu72^9>J6t4@09nY>W#!E^&5?VbVJUNSFhp7mX@;t@aw zw6q2Xv_y1$tZew9`4V~RvyX-ec;+_h8 z$4>?;Y-n%silLa4S<{R4f@pLZ6cYM1Ot2k&NrF2VB8!;v3))j?#w44klfwIvtRn}> z(Skc2vrb4=EJt1qc+PS zQg6_S%s54RH=>T$5310;S86ln6bud^h!6^0B7g&u>h$Qq8NnlNEPO51FFu&I*9t)d z3@oQwNzFqCU+_2bOfUH2-qu9xU>`b37%K1_TN`r9`fodoMjEl>yAJSl3M5DQq?R%+R9lFP4 zSGKWQTsty3VgBO)cum+VLr)@#7Ep2Uq|Ig^$Rw9uwA;vNsJj~2Nj;*|*I}Rj*!Fs> zxPU-f@j`$jc%VH!_lgWn*iLLSvI>iul&q878X{=`DA`9~gRjApRIW0UrB%gjVQOy% z3+mGDYe%E?Mg=Tcsm8b??qOT*Vu6GS)|;ca=z+KWMR4T~7?@7S&{bGI)zV7TO^mh8 zJGnlDQ#9y5Td9qx`+2A8wD(t>z?LBxo})B5tBT^$pZ>@H{J;O_zf#JM8Z3=_F`ymb61e?q2!4>o505aiAx{TeAU2wG>e-A1z~hVC~Ba@_l%7R&w!%`ICYHg|V#eJj7XAGxcjhkBOw zQ{?iiiGO8l*#M*o2JSG$Oj05w6#I&75UgN2s>n1;hZm{wmA?3{rY{I8bdg|kUl-P4 z52$*K=rs2@7`h($fmjLw;3Q{vsHNrVEF1=;u0L-Mv`r=05-Y>Y33@|x*V!A%WuoKI z`2)z;mYwZ!si%PM&nn@HB9_j46YcBjafQanMt4j=%VA_K9YM5@6jp;{S zn@f6v1{BGL);I6qQ;i}IQwGuy|2!(l0{R5L@96B1V8DL%uCb}%V^%F;m~|HG!=#h_o!sxe zWzbElu4C7h_SuE|dw0I4SKPjAbm&j^lQ^u;qCx3?>zNj@DKa#+d-T|kx#;>{O#)N% zkt|;5H?s3}9z@SaI@ZMVmM}o51!a~#lAxKBP@37bx+x#Eim2%<4$xYTgC8`bX5oED z(Ose&RS(dy@zG&o&g435Z(0K<&CI*Y2t$#rkM4-^c?-9E7kNrF>hcV!QLC5Ex_b0j z6nZ?V%y%YV<r$J)Vn)9m8bUKefTob5*z# z2y`sXj%{IlR&u6Ain)5qkBIxxLM&Sx4{I5=T(co$vFK=pCUb(soo(h?pWM+a!3wv8 zI5xUbXLS}6q%F^*?!yboRnmaW2&*$qnjWP+w+Bu+PCzc=J6#@o!SyTc;GJ=S=WH$` zSGP?T@=Yerk4#u$7e+*TKuAA$8iK?|gmH1&o2G6EJTh@OzYO9?0x%;Z4`(xJNwdPe z;%+@SJ5hTbF=$CQTtbA>^@^A?(Q1vkkJTy&(g?v8%?b;nRK@hKahy^9O90p z7z8)5*CDlaeH{*LIEx5r7uLCUcqf4jh4tpBTO;1%Ev8NKsz;gsq)F@HQM%?9I&(>! zrISb;XaZwWi%mpCmEiWc4CZ+NC5E0)SHO@C4| zt#uAGiuspeP9Kr0|8we-s3gA!Ss0lrbM z*f_z?&$jsb$xFzUI?zMdt@hBxtQoHD-8k439~XaH{3^AbWFE$sz@kZ8w_X^T%S~+E zJtf@+Kh%}INEe7gpEmAkZwAw!kpnb%1N<^ zmi=UU06mMI9OETS^uv*eGCN-#G9u5f%teTAcFK|zdsx;=&nIuYBZ~DT10b+mw6_AS zv_rYJokJS1*=TPljcLrF0hO}~YI;kGOp4pu;rx*#6y=H8v0W~%kNqkT2wPsr02p&d zkWL8F>{+C1+f#IGLqAr&cRX+HT$Df%dd5lH87@Z&d2Lj)`y&q3?aI(O$vOXez1VJp z1lO3RxUP+(q;z=&EpF_O9cBOCIRf7m?8DfV<7l_Ae>*Te`Jb6t;OzlnpHSZakU80C?S)K*37Glqg%n~& zxUlc7@iVUFpEed+!a&vkibMR zZ1l5tCa(bjPT!_7!7q5y;9gGE-hEHP=BPAUF2TLdVvsu9K>R+ieAFCfc|=;h2%nMI z4OS72QTw!S2eS=e+QI3><3f4uqT6dG2tj5r$b%b?l}RP!z~-0K=dQIwFWLcg`BtZM z#R52g8y*JsPs!5)Bm-dlD%Q}=Db&J9ygm`iZ`rdc%CFSR;WTg70OCMXvt9 zOp{}Z2;HZiBo2G4GbPn50M*&vc_Jen*br%VZpz9QX=ThO`WkYrNy_(`_+ZtZ#D&s|!zu&_u++L0kzSQW|Xz%@H2tSc8h zaF8EsC%{a23BaUMxrS{e344MLO;%+-l1iJ}eC?2pW7W+BTA?9U4^wh1BDPyrr2};l zS}h@V({#aZCNxN|n;l$dCi^Z?ox167S~~d`PI*_OWK#R{w3XFbk1S^ON|FsThSNH> zn59F!ylu@U9rxx!&Q?sX8<3bLPRqdfH#R;+{b@m>YmNy__}fS4=}1ULLh6^jy+Omq zrwM(+#dl_xz3LnQuOZ0MJ1O?han39X#R>mAwhl|qMUImfyoa;#(~2;gpw)gpd3f!G z*p{ZIYK8FwpRd;)2ypeTMxuQVSEV0muvQ$nSg*GWR@{2l+~n|YILBS<`9u{@Y)#nX zYV|L_BA`Vf+EQ)6?>B+ht-tl^-=}=@uyLj<5fh02&=tRkeOw|LV>{c*zGI+Kl)fO@ zaTtMnaoR_{YVUyg6lc;|pfpEJ(^^Ft-UL2;s^i zdw~F+$=)+y3Ma@ih@ewq%&r$}!9~3<4tT)a%xi?$j1EYy;_%e>La0Z$28Xs2M(}nm!x3(FpIPx3J z5zFf^Aom?wO69kv7b@spwSq(`W$&VJ=k9z#sYmyI1idwH@KOV_4Hv&T@$ACVX;j^G z7!fD4(yw7)h`FB|VKyD%m+mp|^f`QWzUd~}NF~IPhTcMx2Fd~U{l((d5K)T!^;BGH zA*JF7^zap!rA#dx$$D58!dtpZxi#sR9B%Ic3f$uZkeKG-naEf{`C(23)by|fc z6s(*n#U0Fqb>YZuYZn%yEP}HGWjKxv7T0>8kmhU;YT&V~u=U@~V5nWmC%RQ{X5vs? z^vZ1}A4@4X+6+aZn}ews*PNa)i%3($#MEM$_4+jV0kxnDH?js>aTxDrnk`MixH(+x zum5Td0W#!=F`hER)o~`d@{3eU!V3xzTz~0Dro+6yNK%{Z~+CiOm>6cb%|d+m=#~0U7T_5!?snu#2BPIbP_~t$z=HyK7Xl$Cod7FXJP&9 zVySS(DWTBr8{nzoKsx{0K^|`v)Qb}EGYfr2zq_4R$M}R3$(D(z)3YF<1l==r&{2cp zy&Nv`5VYx$Cz~pdFA#CpJhYhrtvW`Oj%goV$Kf%%nzw)PHX}dI^$LmO{6*FQ!vw@@ zEoNN^9__<W^5?t&ap(?&@oZWT!f9hNK2C#n?jW5=dCFe@1Qjci!KK)Y3C*#1(wra1e@vy0!^38<#SeYLPv(RrO_-F`&2 zpFd9-Tqhz~W09K$#w0gl7Bf1Nc+^yI0z^8++)ZUX^O|sPq41Vf|ABDt%_vc#`PoABV ztt^)%9d|Tno9J2Os-*KuC)fLwmFctQWf2^LmMy2{@BRuwN!FvSaeznJ>A*A=>d-06 zRsEcI3zaSJ5$}8SY=@I?h`^*O{Lv|U;83q*5ztc)TyD`Q(Iqiq?0)#Xp2@tj(iqGO z5~4j8z-YF81)5-t8(6kr9}1pc@sM8T5RB%mUg}zti2g1u=s6ukw0e8X%K&vI7;1DE zW1Za8=kuD74kDeq-ma=}&)(IkS3c;KNWe4!k#jXQ6O|LorG*L?jcKy2O~K3TE+0yt zY!Km7+RN&ZaZZovn+}+O+HVTF++K(_35|uZ3jSqgVe~SrgDXtLJAw(?uF5G1xLzT3 zth`1GL-^ulNDq%e#VsB*gBpPj0@QF@J~~m5K8lKkw#f~IBe|4tDP;FFEF{u_-!K$+ zY9f@=Ow7Q@+Og)Qg#t~{h}ZTgy%0h+0Lm+3q3WBJHQvEJ2*PtN&M6uOGpo!!$?@uL zjaUhqRBi}Jm}%nPl}k7xA~8Pqz{Lu$LqK%9=y4a^t*V? zdgFWFrDyj}!&1@9X@s1Ryz?M?_gy!FA7~|fm(^M?yJ}x77iax!3jpdx1tcW zBNAwgM)B|gk9uZOm=Kq`o9M!y$MG0Eo2$n{7R7Y-Uj>av4~HKSFyzf%tKx5N-S!fC|8dXB9Rh0JG9WyM2nF76vas|DcSMsJJK<9d=e^slUV=P8vrvp{@_lA zQ#ICDaLOKIZ4J{L8J9;JoOG^?$t~lBz&*N}QIzN4Zl0 z%K&gDg0bc5)r=9f@*ryzc^E;H|J-K~(1erQCmnEYe`?UFd@@l?+J7a{AWDi3B=vfF zG&y+fGF#)b3XZvQuXWkGZuNkC;zVZVuk|7y_k&3cV(g@(fvAEG9<5ZLQx5_G{F;PL ztTH&q`ahjxl92?JLu%QHd!Kvf0g^K~&CG(#n7xY#+=U}<4|Pm&_C_LYaTC159*FV{eFaOj7u6uJRL$Oz>_nG1dC(GBI*TP z!t*Sw+H4J8?oC)+iYFriOvU_wa=+f)W^b4>qr(%zTYymqOAfs1;_G4LgHXl2d0NaT z!Kz<`SScY-hioUO&OCDD$<(UzHj3FyKy%nBwym00?4^6e`7l(o?e2asrPhate_SZm zvh(tyJnanUQvS@_iMtE;ZVdVV7tMXr`AsRB%HrKXbQLYrq+{zu8N(y#e{D9qH9?z@c%&SVJgIx1c{_M~ z0E3i&Q`ZFwtyiCLyW4WvJqfL#11_TQyf40ty>Av|y+Wx&rRM4BtJ5z|FV8fnJl92{ zL&Ys*y>gS@zwrQ%OA}!A01hmaNb((mO{)h52Wm`kFG?xHEKJtBjxloYWW?ND3kKAM zd%cxFs;|#pooYIk8-xvfI1vlEELq4;ok^%yo~_G{CT(OK0+!R{ye=tk3#;tK0~pQi z>PwX)&H)vt?Zli6-D=?&)Ro@YB!?*;3U|(~H$AqK5 zyWq{-*Uqp|Y}-^_Hp{w+p<1XITo^9`uIK;-ekmR|mZK6blqgrVxg#>jd9f(9Jyu7E zba;cZk7Ivj?q&-gGeA#!nzg7SY@Roj767|>;A%QkF9J-v4CD+Mx zfYY@NOx6L)_F7<#+QzbP`JW?{cN?7+WC)1PuQ_OiiI=>cRjqBfnDpt4 zgN~+{qAa)flHh)gP7#_SsT_>@q4+7m1BSL)#H|p^TWIniE)*oClf&s|503Fb2ED|O zGO#U(MHVzfujIm*)@5YKx(ps7gs3k|f0YdRc(t)98s5zd6nJUOGB%hgXbJ9RS<;at zrp8g2#TYmpxq0if3FC@7V1_oA1Bo_kDteu~H|#9&^QSP`98;&h0tcG0W}bXL6!`$J zqBqGi*iIxJw;aSE1u*h5KpQT`ydc9+3Bl@pQ{oQ^Xbt{DJ3X9RdQGY%UgM6)oYG^Z zy?Psu=9H;lK4ZhvDGP8kgXLV^nN}he)d&93ryjCdY+P)fHM5YD!;6!X(p(f;Q83MG zbXbkS8Ia7{+4jkgKmNDBMs4)wjN|Q^A?lQu6So6FQ0%lQp{Zh$-u0R&_G&S5rJO6* zjt{fZWWV-I;WsM*q}2?6E3fVRglcXLR!w-3n5`n8*Gw<;Uai3<856%?Zm=w=Dg5zFzw9Npz zUNPz1!5Z6TIIZSJnlbAQ+{5TCh{lif**Ml2=I1_ESc1+fQ@2`gmGG#cJH-U@hGLet zDXG0fR+q(9TaIBC;djPm9*IO5iOi?mt1QbLcV2S1D>k!jz9LZsGRyWZ(Uy5h7GzhO zsV>k@huU@FlRodkcm1z=X1n*qK?pSbHYg2d3IqsH=*(Wv z^|?NdN6!`N7PppdKFqASS=LK`kr#1@g?^!+Do00i7yX{W*K)2hhCvsVgi5b47}2I> zGn3^f#*+-Qq|Bcc_0*)ZjC7`9Ir??FNeYmOWF@$LnnW8|1h?e&_6$Xee8_oKAl>M- zfpsQ2JXU?t?69Nd(OX9|@&f1NoO#rpY2JPI9eF2&*c%8iozuM z^nmw?Vlj2G1-=fLT2zO6stpsLqi_3JPk8U9p{p^u?-SKMq!7+aFWT_YQX6zUUUiSa z@+elfLEopsWAC}e*td z_D6w}uq)0ijm4y1#|m3b(l~dS+o73F`<8IlVS5uT<7$LJl3ev3n%SM^b%&vjab$>Q LMUH(0??3o|XH}P& delta 730 zcmYjPOKTHR6gFuS+gPsy=nwGDP=j}K&iVMx;e6jc4_*bg9|o#B zC&8jbo}>!19jh#XQ#L95D=+E-1AaWP+<@O2&+RK&%%SHkCMy&NLovP{`WnFd2g*2p zFp6qx4}abEF2pBKY=#4SczgVJkY6~p)qt0JyKs9n!WVm=1@YMRRz7$()QDf^I&d?y zja_}~^}N=jM!c<)SlPzXtJnj2(k^j z7rY(`M5wo5ejkEPshT1Y362F>@Hz~ zbmNb?HY}26{+;{^V@vULk`*9fn2tS=QY^zYhnNNNIK(_CWfz$_pCbrd^S#;~w5JR%gxRI!LmZD8}JZ~<<+)MBD^450I-Qx<}3S95F$N-E2%z-)o! z4WXOlGzy09D@&(wc6At^uXf|-)sd{2>*{&pDyox8`ULM37I#e`CBhu|?;Ly`bs3Vk zb8#(+kJmc!Lv}yDRl~TY9u5OklKl~!9gbpreF(cRAHr;{v!f4uadj6elHk-b(VXi* o&Of)5mU9(vbK4;rO#$BBNH>X5-Y-`0(?$ueJ!uP7`XiOdU%fpDvj6}9 diff --git a/gitnexus/src/core/group/types.ts b/gitnexus/src/core/group/types.ts index db9d1989f..ee6b7cda5 100644 --- a/gitnexus/src/core/group/types.ts +++ b/gitnexus/src/core/group/types.ts @@ -100,7 +100,20 @@ export interface ContractRegistry { version: number; generatedAt: string; repoSnapshots: Record; + /** 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[]; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index efbaba71d..eabea20d8 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -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}${callerFilePath}` + ? `${name}\0${callerFilePath}` : undefined; let scopeDefs: readonly SymbolDefinition[] | undefined = cacheKey !== undefined ? scopeDefsCache!.get(cacheKey) : undefined; diff --git a/gitnexus/src/mcp/resources.ts b/gitnexus/src/mcp/resources.ts index f9b55ce77..d58a13a79 100644 --- a/gitnexus/src/mcp/resources.ts +++ b/gitnexus/src/mcp/resources.ts @@ -97,7 +97,22 @@ export function getResourceTemplates(): ResourceTemplate[] { { uriTemplate: 'gitnexus://group/{name}/status', name: 'Group Index Status', - description: 'Per-repo index and contract-registry staleness for a repository group', + // The payload is a bare serialization, so nothing in it says which of + // three states a reader is looking at. Both distinctions below are + // additive fields whose meaning is invisible without this: `missing` + // alone cannot separate "not registered" from "registry unreadable", and + // an omitted `unreadableRepos` key looks exactly like a measured zero. + description: + 'Per-repo index and contract-registry staleness for a repository group. ' + + 'Every configured repo carries both `missing` and `unresolvable`: a repo genuinely absent ' + + 'from the global registry is missing:true with unresolvable:false; a repo whose registry ' + + 'entry could not be read or resolved is unresolvable:true with an unresolvableReason ' + + '(missing stays true there too, so a consumer written before the split still sees every ' + + 'unusable repo); a healthy repo is neither. The group-level unreadableRepos list is ' + + 'three-state, and an ABSENT key is not an empty one: absent means the last sync never ' + + 'recorded which repos it could read (provenance unknown — treat cross-repo answers for ' + + 'this group as a floor), an empty list means the sync measured none, and a populated list ' + + 'names the repos whose contracts are missing from the registry.', mimeType: 'text/yaml', }, ]; @@ -389,7 +404,12 @@ async function getContextResource(backend: LocalBackend, repoName?: string): Pro lines.push( ' - gitnexus://group/{name}/contracts: Group contract registry (optional ?type=&repo=&unmatchedOnly=)', ); - lines.push(' - gitnexus://group/{name}/status: Group index / contract staleness'); + lines.push( + ' - gitnexus://group/{name}/status: Group index / contract staleness — separates a repo absent ' + + 'from the registry (missing, not unresolvable) from one whose entry could not be read ' + + '(unresolvable + unresolvableReason), and carries unreadableRepos as absent=never recorded / ' + + 'empty=measured none / populated=named', + ); return lines.join('\n'); } diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index e81d70d34..4d59f3ffd 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -504,7 +504,7 @@ Handles disambiguation: when multiple symbols share the target name, returns ran EdgeType: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, METHOD_OVERRIDES, METHOD_IMPLEMENTS, ACCESSES Confidence: 1.0 = certain, <0.8 = fuzzy match -GROUP MODE: set "repo" to "@" for cross-repo impact anchored at the default member (lexicographically first key in group.yaml "repos"), or "@/" to choose the member (same path keys as in group.yaml). Phase-1 walk runs in that member; cross-boundary fan-out uses the group bridge. A cross entry with fanout_status:"not_attempted" proves the declared repository boundary, but its far endpoint has no graph symbol; do not interpret empty by_depth or affected_processes on that entry as a completed zero-impact walk. The fan-out attempts at most 50 neighbour crossings, strongest-confidence first; when it stops early the response carries truncated:true, truncatedRepos, and riskEpistemic:"lower-bound" — dropping a crossing can only move risk DOWN, so treat that risk as a floor, never as a verdict. +GROUP MODE: set "repo" to "@" for cross-repo impact anchored at the default member (lexicographically first key in group.yaml "repos"), or "@/" to choose the member (same path keys as in group.yaml). Phase-1 walk runs in that member; cross-boundary fan-out uses the group bridge. A cross entry with fanout_status:"not_attempted" proves the declared repository boundary, but its far endpoint has no graph symbol; do not interpret empty by_depth or affected_processes on that entry as a completed zero-impact walk. The fan-out attempts at most 50 neighbour crossings, strongest-confidence first. Any short answer carries truncated:true, truncatedRepos, riskEpistemic:"lower-bound" AND a truncationReason — dropping a crossing can only move risk DOWN, so treat that risk as a floor, never as a verdict. truncated:true does NOT always mean the fan-out ran out of room, so branch on truncationReason: the remedy differs. 'timeout' (the fan-out's wall-clock budget expired) and 'partial' (a neighbour crossing, or the local walk, was cut short) are runtime limits — the same query can return more on a retry or with a larger timeoutMs. 'incomplete-sync' is structural: the group bridge was built by a sync that could not say which repos it read, or that could not read an in-scope repo, so those repos' contracts are absent from EVERY query against this bridge, and truncatedRepos names them even when ZERO crossings to them were attempted. Retrying returns the same floor — run group_sync (\`gitnexus group sync\`) and query again. SERVICE: optional monorepo path prefix (case-sensitive path segments). When "repo" starts with "@", scopes the local impact walk and cross-repo symbol paths to files under that prefix; ignored for a normal indexed repo name.`, annotations: READ_ONLY_TOOL_ANNOTATIONS, @@ -851,9 +851,15 @@ WHEN TO USE: Discover groups before group_sync. Optional "name" returns a single name: 'group_sync', description: `Rebuild the Contract Registry (contracts.json) for a group: extract HTTP contracts, apply manifest links, exact-match cross-links. -WHEN TO USE: After changing group.yaml or re-indexing member repos.`, - // Writes contracts.json on every call; conservatively non-idempotent - // even though output is deterministic for identical input. +WHEN TO USE: After changing group.yaml or re-indexing member repos. + +READ THE RESULT: \`missingRepos\` are configured repos with no entry in the registry (index them, or drop them from group.yaml); \`unreadableRepos\` ARE registered but this sync could not extract from them — the index would not open (version skew, lock, corruption), or an extractor failed partway — so NONE of their contracts are in this sync and a following group_impact / group_contracts is a lower bound, not a verdict. \`registryOutcome\` says what happened to the file, and the three values a call here can return each need a different response: 'written' — this run's contracts replaced contracts.json; 'preserved' — nothing could be read, so contracts.json was rewritten keeping the previous sync's contracts and cross-links verbatim and refreshing only \`missingRepos\`/\`unreadableRepos\` to describe THIS run (the file changed, the contracts in it did not, and they are as old as the last sync that succeeded); 'superseded' — nothing could be read, and another sync replaced contracts.json while this one waited for the group lock; that file was left untouched and this run's lists were NOT recorded, because they describe an older group state than what is on disk (so the registry is fresher than this response's diagnostics, not staler); 'no-prior-registry' — nothing could be read AND there was no previous contracts.json to carry forward, so none was written and this group has no contract registry on disk. Only 'no-prior-registry' means there is nothing to read: after it, group_contracts / group_impact have no registry at all rather than a stale one, so fix the repos above and re-run before trusting either.`, + // Usually writes contracts.json, so conservatively non-idempotent even + // though output is deterministic for identical input. When no configured + // repo could be read it still rewrites the file, keeping the previous + // registry's contracts and refreshing only its diagnostic fields + // (`registryOutcome: 'preserved'`); it writes nothing when there was no + // previous registry to carry forward (`'no-prior-registry'`). annotations: DESTRUCTIVE_TOOL_ANNOTATIONS, inputSchema: { type: 'object', diff --git a/gitnexus/src/storage/index-lock.ts b/gitnexus/src/storage/index-lock.ts index c67750266..ba8e57225 100644 --- a/gitnexus/src/storage/index-lock.ts +++ b/gitnexus/src/storage/index-lock.ts @@ -105,6 +105,25 @@ export interface LockRecord { export interface IndexLockHandle { /** Our own record — `invocationId` is shown to waiters as the holder id. */ readonly record: LockRecord; + /** + * `true` ONLY on the no-op handle returned when the filesystem refused to + * create the lock file (see {@link LOCK_UNWRITABLE_CODES}); absent on every + * handle that owns a real lock. Purely descriptive — it surfaces a fact this + * module already had, and changes nothing about when or how a lock is taken. + * + * It exists because that degradation is otherwise INVISIBLE at the API + * boundary: the no-op handle is byte-identical in shape to a real one, so a + * caller for whom "lock-free" is not an acceptable outcome (a long, expensive + * critical section whose lost update destroys data — e.g. a group sync) has no + * way to tell it apart and fail closed. A filesystem probe is not a substitute: + * {@link selectBackend} returns `socket` on Linux and Windows, where this + * branch cannot occur at all, so a probe would refuse on the two platforms + * that never degrade. + * + * Additive by construction: every caller that ignores this field behaves + * exactly as it did before it existed. + */ + readonly lockFree?: true; /** Idempotent; only removes the lock file if it still carries our token. */ release(): void; } @@ -317,8 +336,14 @@ export const isLockUnwritableCode = (code: string | undefined): boolean => code !== undefined && LOCK_UNWRITABLE_CODES.has(code); /** A lock handle that owns nothing — returned when the filesystem refuses to - * create the lock file (see {@link LOCK_UNWRITABLE_CODES}). Release is a no-op. */ -const noopHandle = (record: LockRecord): IndexLockHandle => ({ record, release: () => {} }); + * create the lock file (see {@link LOCK_UNWRITABLE_CODES}). Release is a no-op. + * Carries {@link IndexLockHandle.lockFree} so a caller that must not run + * unprotected can tell this apart from a handle that owns a real lock. */ +const noopHandle = (record: LockRecord): IndexLockHandle => ({ + record, + lockFree: true, + release: () => {}, +}); /** * Delete orphaned build/staging artifacts left in the lock directory by a diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index c40d4a4ba..ce2e594cf 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -616,18 +616,138 @@ const sanitizeEntries = (entries: RegistryEntry[]): RegistryEntry[] => }); /** - * Read the global registry. Returns empty array if not found. + * A registry row we can actually resolve a repo from. + * + * `Array.isArray` is not enough on the strict path: `[{}]` is a JSON array, so + * a malformed registry passed the shape check, every configured repo failed to + * resolve, and — because none of them produced a load ERROR — the total-failure + * guard stayed off and a good contracts.json was replaced by an empty one. That + * is the same fail-open the strict mode exists to close, one level down from + * the file to the rows inside it. + * + * Only the three fields the resolution path actually depends on are required. + * `indexedAt` / `lastCommit` are deliberately NOT: callers already default them + * (`e?.indexedAt || ''`), so demanding them would reject a legacy row that + * resolves perfectly well — trading a fail-open for a fail-shut on real data. + * + * Two of the three must also be non-blank, because `typeof '' === 'string'` + * passes a row that cannot identify anything. `name` is what + * `defaultResolveHandle` matches a configured repo against, so a blank one + * matches nothing and puts every repo in `missingRepos` — the same fail-open, + * dressed as a clean answer. `storagePath` is what the resolved handle carries + * to `path.join(storagePath, 'lbug')`; blank, that joins to a relative `lbug` + * under the CWD, so the sync opens an index that is not the repo's. + * + * `path` stays at the bare string check, on the same reasoning that exempts + * `indexedAt` / `lastCommit`: require only what the resolution path depends on + * to IDENTIFY the repo. This check rejects the WHOLE registry, which is + * machine-wide, so a field tightened past what resolution needs would let one + * blank value in one row break every group sync on the machine — including + * groups whose repos all resolve. */ -export const readRegistry = async (): Promise => { +const isResolvableEntry = (value: unknown): value is RegistryEntry => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const e = value as Record; + const identifies = (v: unknown): boolean => typeof v === 'string' && v.trim() !== ''; + return identifies(e.name) && identifies(e.storagePath) && typeof e.path === 'string'; +}; + +/** + * Shared body for the two read modes below. + * + * `strict` distinguishes "the registry says nothing is registered" from "the + * registry could not be read". Lenient collapses both into `[]`. + * + * ENOENT is lenient in BOTH modes: no file genuinely means nothing has been + * registered yet, and every first-run path depends on that. + */ +const readRegistryFile = async (strict: boolean): Promise => { + let raw: string; try { - const raw = await fs.readFile(getGlobalRegistryPath(), 'utf-8'); - const data = JSON.parse(raw); - return Array.isArray(data) ? sanitizeEntries(data) : []; - } catch { + raw = await fs.readFile(getGlobalRegistryPath(), 'utf-8'); + } catch (err) { + if (strict && (err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + return []; + } + try { + // The parse gets its OWN guarded region, narrower than the checks below, + // and the parser's error is DISCARDED rather than rethrown. + // + // `JSON.parse`'s SyntaxError quotes a ten-character window of the source + // either side of the break — `Unexpected token 'L', ..."end.git"},"...`. + // Registry rows carry remote URLs with their HTTPS userinfo verbatim, so a + // registry that breaks on one of those URLs puts the credential into that + // window, and the thrown message is not the only place it goes from there: + // `groupStatus` interpolates it into `unresolvableReason` for an MCP + // client, and `gitnexus group sync` prints it. + // + // Not logged and not attached as `cause` either, deliberately against this + // file's own convention of handing the `Error` object to the logger so it + // captures stack and cause: under MCP stdio the client writes those records + // to a log file on disk, so following the convention here would move the + // byte window from one channel to a more durable one. The parser's position + // offset is not worth a credential — the path and the failure class are + // what an operator acts on, and they are what the two errors below say too. + let data: unknown; + try { + data = JSON.parse(raw); + } catch { + throw new Error(`${getGlobalRegistryPath()} is not valid JSON (registry is corrupt)`); + } + if (!Array.isArray(data)) { + if (strict) { + throw new Error(`${getGlobalRegistryPath()} is not a JSON array (registry is corrupt)`); + } + return []; + } + if (strict) { + // Reject the WHOLE registry, never filter the bad rows out. Dropping them + // would report the repos they name as unregistered, which is precisely + // the unreadable-as-missing answer this mode refuses to give. + const bad = data.findIndex((entry) => !isResolvableEntry(entry)); + if (bad !== -1) { + throw new Error( + `${getGlobalRegistryPath()} entry ${bad} does not identify a repo — name and storagePath must be non-empty strings and path must be a string (registry is corrupt)`, + ); + } + } + return sanitizeEntries(data as RegistryEntry[]); + } catch (err) { + if (strict) throw err; return []; } }; +/** + * Read the global registry. Returns empty array if not found — and, note, also + * when the file exists but cannot be read or parsed. That is fine for a + * read-only listing, where an unreadable registry and an empty one print the + * same nothing. It is not fine for a caller that ACTS on emptiness; see + * `readRegistryStrict`. + */ +export const readRegistry = async (): Promise => readRegistryFile(false); + +/** + * Read the global registry, refusing to report an unreadable one as empty. + * + * An EACCES after a `sudo gitnexus analyze`, a truncated registry.json, or an + * $HOME-on-NFS blip otherwise presents as "no repo is registered" — an + * unreadable condition reported as missing, which is exactly the conflation + * #3011 removes one frame further down. `syncGroup` is the caller that acts on + * that answer, by replacing a good contracts.json with an empty one. + * + * Deliberately a separate export rather than an option on `readRegistry`: + * leaving that signature untouched keeps every existing lenient call site + * provably unaffected, and the mode is legible at the call site. + * + * No count here on purpose. This comment carried one, it said nine, and the + * real figure was thirteen by the time anyone checked and fourteen shortly + * after — a number in prose beside code that moves is a claim that rots + * silently, which is the defect class this whole change set is about. The + * argument does not need the figure: it holds for one call site or fifty. + */ +export const readRegistryStrict = async (): Promise => readRegistryFile(true); + /** * Write the global registry to disk. * diff --git a/gitnexus/test/fixtures/group-sync-lock-child.mjs b/gitnexus/test/fixtures/group-sync-lock-child.mjs new file mode 100644 index 000000000..552ce8007 --- /dev/null +++ b/gitnexus/test/fixtures/group-sync-lock-child.mjs @@ -0,0 +1,62 @@ +/** + * Child process for the cross-process group sync-lock tests (R9). Holds the + * REAL `withGroupSyncLock` on GROUP_DIR so the parent's `syncGroup` contends + * with a genuinely separate process — the only way to observe the property this + * lock exists for. An in-process mock cannot: the socket backend's exclusion is + * a kernel binding, and the file backend's is an O_EXCL create. + * + * Env: + * GROUP_LOCK_MODULE file:// URL or path of the group-lock module to import. + * GROUP_DIR the group directory to lock. + * MARKER written (with our pid) once the lock is HELD. + * HOLD_MS how long to hold before releasing. 0/unset = hold until + * killed (used by the holder-death and no-contention cases). + * CONTRACTS optional: path to write just before releasing, standing in + * for the first sync's persist. Written LAST on purpose — if + * the lock were absent the waiting sync would have written + * first and this would overwrite it, which is exactly the + * lost update the test hunts. + * RELEASED optional: path stamped with Date.now() just before release. + */ +import { writeFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; + +// On Windows `import('C:\\…')` throws ERR_UNSUPPORTED_ESM_URL_SCHEME (a bare +// drive path reads as a URL scheme), so address the module as a file:// URL. +const spec = process.env.GROUP_LOCK_MODULE.startsWith('file:') + ? process.env.GROUP_LOCK_MODULE + : pathToFileURL(process.env.GROUP_LOCK_MODULE).href; +const { withGroupSyncLock } = await import(spec); + +const holdMs = Number(process.env.HOLD_MS ?? 0); +// Nothing else keeps this process alive: the socket backend's server is unref'd +// and the file backend holds no open handle. +const keepalive = setInterval(() => {}, 1000); + +await withGroupSyncLock(process.env.GROUP_DIR, async () => { + writeFileSync(process.env.MARKER, String(process.pid)); + if (holdMs <= 0) { + await new Promise(() => {}); // hold until the parent kills us + return; + } + await new Promise((r) => setTimeout(r, holdMs)); + if (process.env.CONTRACTS) { + writeFileSync( + process.env.CONTRACTS, + JSON.stringify({ + version: 1, + generatedAt: new Date().toISOString(), + writtenBy: 'child', + contracts: [], + crossLinks: [], + repoSnapshots: {}, + missingRepos: [], + unreadableRepos: [], + }), + ); + } + if (process.env.RELEASED) writeFileSync(process.env.RELEASED, String(Date.now())); +}); + +clearInterval(keepalive); +process.exit(0); diff --git a/gitnexus/test/integration/group/group-cli.test.ts b/gitnexus/test/integration/group/group-cli.test.ts index 622d6b627..8e51ce1c5 100644 --- a/gitnexus/test/integration/group/group-cli.test.ts +++ b/gitnexus/test/integration/group/group-cli.test.ts @@ -1,15 +1,20 @@ /** * Smoke-test `gitnexus group` CLI (same spawn pattern as cli-e2e.test.ts, via * CLI_SPAWN_PREFIX: built dist in CI, tsx-on-source locally). - * Does not exercise LadybugDB-backed commands end-to-end (needs indexed fixtures). + * Does not exercise LadybugDB-backed QUERY commands end-to-end (needs indexed + * fixtures). `group sync` IS driven end-to-end below, but only through the two + * shapes that need no indexed repo: a group whose members are absent from the + * registry, and a group whose members are registered at a storage path holding + * no `lbug` file at all — which is what makes them unreadable. */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; import { CLI_SPAWN_PREFIX } from '../../helpers/cli-entry.js'; import { spawnSync } from 'node:child_process'; import path from 'node:path'; import fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import os from 'node:os'; +import { INDEX_METADATA_FILE } from '../../../src/storage/repo-meta.js'; const testDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(testDir, '../../..'); @@ -25,16 +30,20 @@ afterAll(() => { } }); -function runGroup(args: string[]) { +function runGroupIn(home: string, args: string[]) { return spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, 'group', ...args], { cwd: repoRoot, encoding: 'utf8', timeout: 20000, stdio: ['pipe', 'pipe', 'pipe'], - env: { ...process.env, GITNEXUS_HOME: tmpHome }, + env: { ...process.env, GITNEXUS_HOME: home }, }); } +function runGroup(args: string[]) { + return runGroupIn(tmpHome, args); +} + describe('group CLI', () => { it('create + list', () => { const c = runGroup(['create', 'acme']); @@ -107,3 +116,464 @@ describe('group CLI', () => { } }); }); + +describe('group contracts reports its completeness', () => { + /** + * `groupContracts` returns the structured triple alongside the contracts, so + * an agent can tell a complete listing from a floor. The `--json` path used + * to destructure `{ contracts, crossLinks }` and re-serialize just those two, + * which silently dropped every other field the service returned — including + * the ones that say the listing is incomplete. Printing the payload whole is + * what keeps a new field from needing a matching CLI edit to become visible. + */ + const seedRegistry = (group: string, registry: Record): void => { + const groupDir = path.join(tmpHome, 'groups', group); + fs.mkdirSync(groupDir, { recursive: true }); + fs.writeFileSync(path.join(groupDir, 'contracts.json'), JSON.stringify(registry, null, 2)); + }; + + const baseRegistry = { + version: 1, + generatedAt: '2026-01-01T00:00:00.000Z', + contracts: [], + crossLinks: [], + repoSnapshots: {}, + missingRepos: [], + }; + + it('carries the incompleteness fields through --json', () => { + expect(runGroup(['create', 'jsonfloor']).status).toBe(0); + seedRegistry('jsonfloor', { ...baseRegistry, unreadableRepos: ['app/backend'] }); + + const r = runGroup(['contracts', 'jsonfloor', '--json']); + expect(r.status).toBe(0); + const payload = JSON.parse(r.stdout) as Record; + + expect(payload.unreadableRepos).toEqual(['app/backend']); + expect(payload.truncated).toBe(true); + expect(payload.truncationReason).toBe('incomplete-sync'); + expect(payload.riskEpistemic).toBe('lower-bound'); + // Still everything it always returned. + expect(payload.contracts).toEqual([]); + expect(payload.crossLinks).toEqual([]); + }); + + it('tells a human reader the listing is a floor, and which repos are missing from it', () => { + expect(runGroup(['create', 'humanfloor']).status).toBe(0); + seedRegistry('humanfloor', { ...baseRegistry, unreadableRepos: ['app/backend'] }); + + const r = runGroup(['contracts', 'humanfloor']); + expect(r.status).toBe(0); + expect(r.stdout).toContain('app/backend'); + expect(r.stdout.toLowerCase()).toContain('incomplete'); + }); + + it('control: a complete registry says nothing about truncation on either surface', () => { + expect(runGroup(['create', 'complete']).status).toBe(0); + seedRegistry('complete', { ...baseRegistry, unreadableRepos: [] }); + + const j = JSON.parse(runGroup(['contracts', 'complete', '--json']).stdout) as Record< + string, + unknown + >; + expect(j.truncated).toBe(false); + expect(j.truncationReason).toBeUndefined(); + expect(j.riskEpistemic).toBeUndefined(); + + const h = runGroup(['contracts', 'complete']); + expect(h.stdout.toLowerCase()).not.toContain('incomplete'); + }); +}); + +/** + * The per-repo status table had ONE failure label — `MISSING (no entry in the + * registry)` — and every reason a repo failed to resolve was printed with it, + * including a global registry that could not be read at all. For that case the + * line states something nobody measured (the command never got to read any + * entry) and points at the wrong repair: index the repo, when the fix is to + * repair the registry. + * + * These cases go through the real CLI because the label is the deliverable — + * the service payload can carry the distinction perfectly while the table + * still prints one word for both. + */ +describe('group status names which failure a repo hit', () => { + let home: string; + + /** Two members: one the registry will know about, one it never will. */ + const GROUP_YAML = `version: 1 +name: labels +description: "" +repos: + backend: backend-registry + svc/users: svc-users-registry +links: [] +packages: {} +detect: + http: false + grpc: false + thrift: false + topics: false + shared_libs: false + embedding_fallback: false +matching: + bm25_threshold: 0.7 + embedding_threshold: 0.65 + max_candidates_per_step: 3 +`; + + beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-group-status-labels-')); + const groupDir = path.join(home, 'groups', 'labels'); + fs.mkdirSync(groupDir, { recursive: true }); + fs.writeFileSync(path.join(groupDir, 'group.yaml'), GROUP_YAML, 'utf8'); + }); + + afterEach(() => { + fs.rmSync(home, { recursive: true, force: true }); + }); + + /** + * A registry row that survives `LocalBackend.init()`'s validation pass — + * which prunes (and rewrites) any entry whose storage path has no metadata + * file, so a row backed by nothing would silently become a genuine absence + * before `group status` ever read the registry. + */ + const registeredRow = (name: string, dirName: string): Record => { + const repoPath = path.join(home, dirName); + const storagePath = path.join(repoPath, '.gitnexus'); + fs.mkdirSync(storagePath, { recursive: true }); + fs.writeFileSync(path.join(storagePath, INDEX_METADATA_FILE), '{}', 'utf8'); + return { + name, + path: repoPath, + storagePath, + indexedAt: '2026-01-01T00:00:00.000Z', + lastCommit: 'abc123', + }; + }; + + const writeRegistry = (body: string): void => + fs.writeFileSync(path.join(home, 'registry.json'), body, 'utf8'); + + it('says MISSING for a repo a readable registry simply does not hold', () => { + // The label this command has always printed, kept honest: the registry + // reads fine and genuinely has no row for either member. + writeRegistry('[]'); + + const r = runGroupIn(home, ['status', 'labels']); + + expect(r.status).toBe(0); + expect(r.stdout).toMatch(/^ +backend +MISSING {3}\(no entry in the registry\)$/m); + expect(r.stdout).toMatch(/^ +svc\/users +MISSING {3}\(no entry in the registry\)$/m); + expect(r.stdout).not.toContain('UNRESOLVABLE'); + }); + + it('says UNRESOLVABLE for a repo the registry holds but cannot resolve', () => { + // Two registered clones under one name: the row is right there, and + // resolution still cannot pick one. Printing "no entry in the registry" + // here would be a false statement about the file just read — and the two + // members must come out with DIFFERENT labels in the same table. + writeRegistry( + JSON.stringify([ + registeredRow('backend-registry', 'clone-a'), + registeredRow('backend-registry', 'clone-b'), + ]), + ); + + const r = runGroupIn(home, ['status', 'labels']); + + expect(r.status).toBe(0); + // One line, not four: the ambiguity error is multi-line and gets folded. + expect(r.stdout).toMatch(/^ +backend +UNRESOLVABLE \(.*backend-registry.*\)$/m); + expect(r.stdout).toMatch(/^ +svc\/users +MISSING {3}\(no entry in the registry\)$/m); + }); + + it('says UNRESOLVABLE for every member when the registry itself cannot be read', () => { + // Nothing was measured about any repo, so "no entry in the registry" is a + // claim about a file that could not be parsed. Every configured member is + // unresolved — including one whose row might have been perfectly fine. + writeRegistry('{"repos": []}'); + + const r = runGroupIn(home, ['status', 'labels']); + + expect(r.status).toBe(0); + expect(r.stdout).toMatch(/^ +backend +UNRESOLVABLE \(.*registry\.json.*\)$/m); + expect(r.stdout).toMatch(/^ +svc\/users +UNRESOLVABLE \(.*registry\.json.*\)$/m); + expect(r.stdout).not.toContain('MISSING'); + }); +}); + +/** + * A group.yaml with every detector off, so nothing in a sync opens a repo graph + * and the only thing that can vary is what the registry says about its members. + * + * `links` is spliced in verbatim because the two shapes below need different + * ones: a manifest link is the single input that makes a sync produce contracts + * with no indexed repo (synthetic UIDs — see + * `group-service-sync-lazy-import.test.ts`), which is what gives the wrote-line + * counts to assert something other than zeroes. + */ +function writeGroupYaml( + home: string, + group: string, + repos: Record, + links = '[]', +): string { + const groupDir = path.join(home, 'groups', group); + fs.mkdirSync(groupDir, { recursive: true }); + const repoLines = Object.entries(repos) + .map(([groupPath, registryName]) => ` ${groupPath}: ${registryName}`) + .join('\n'); + fs.writeFileSync( + path.join(groupDir, 'group.yaml'), + `version: 1 +name: ${group} +description: "" +repos: +${repoLines} +links: ${links} +packages: {} +detect: + http: false + grpc: false + thrift: false + topics: false + shared_libs: false + embedding_fallback: false + includes: false + workspace_deps: false +matching: + bm25_threshold: 0.7 + embedding_threshold: 0.65 + max_candidates_per_step: 3 +`, + 'utf8', + ); + return groupDir; +} + +/** + * `group sync` has three mutually exclusive things it can say about + * contracts.json, and the sentence is the ONLY channel that distinguishes them: + * all three exit 0, and two of them leave the file's contract counts identical. + * + * The line used to be the unconditional `Wrote contracts.json (0 contracts, 0 + * cross-links)`, printed even on a run that deliberately kept the previous + * registry — a confident false statement about persisted state on the exact + * path this command exists to make legible. These go through the real CLI + * because the sentence IS the deliverable: the service payload can carry + * `registryOutcome` perfectly while the console still says one thing for all + * three. + */ +describe('group sync says what it did to contracts.json', () => { + let home: string; + + /** Contracts a preserve run must carry forward untouched. */ + const PRIOR_REGISTRY = { + version: 1, + generatedAt: '2026-01-01T00:00:00.000Z', + repoSnapshots: {}, + missingRepos: [], + unreadableRepos: [], + contracts: [], + crossLinks: [], + }; + + beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-group-sync-outcome-')); + }); + + afterEach(() => { + fs.rmSync(home, { recursive: true, force: true }); + }); + + /** + * Registry rows whose storage directory exists but holds no `lbug` file, so + * `initLbug` throws `LadybugDB not found at …` for every one of them. That is + * a load ERROR, not an absence: the repos resolve, and every one of them + * lands on `unreadableRepos` — the only state that reaches the two + * total-failure branches. A row missing from registry.json instead reports as + * MISSING and syncs to a written registry, which is the other case below. + */ + const registerReposWithNoIndex = (registryNames: Record): void => { + const rows = Object.entries(registryNames).map(([registryName, dirName]) => { + const repoPath = path.join(home, dirName); + const storagePath = path.join(repoPath, '.gitnexus'); + fs.mkdirSync(storagePath, { recursive: true }); + return { + name: registryName, + path: repoPath, + storagePath, + indexedAt: '2026-01-01T00:00:00.000Z', + lastCommit: 'abc123', + }; + }); + fs.writeFileSync(path.join(home, 'registry.json'), JSON.stringify(rows), 'utf8'); + }; + + it('prints what it wrote, and the counts, on a sync that produced a registry', () => { + // Every member is genuinely absent from the registry, which is a clean + // (if empty-handed) sync: the total-failure guard is gated on a load error, + // never on an empty result. The declared manifest link still yields two + // synthetic contracts and one cross-link, so the counts in the line are + // non-zero and therefore say something. + const groupDir = writeGroupYaml( + home, + 'wrote', + { 'app/backend': 'wrote-backend', 'app/frontend': 'wrote-frontend' }, + ` + - from: app/frontend + to: app/backend + type: custom + contract: rotateSigningKey + role: consumer`, + ); + fs.writeFileSync(path.join(home, 'registry.json'), '[]', 'utf8'); + + const r = runGroupIn(home, ['sync', 'wrote']); + + expect(r.status).toBe(0); + expect(r.stdout).toContain('Wrote contracts.json (2 contracts, 1 cross-links)'); + // The other two sentences are about the same file and contradict this one. + expect(r.stdout).not.toContain('Kept the previous contracts.json'); + expect(r.stdout).not.toContain('Did NOT write contracts.json'); + expect(fs.existsSync(path.join(groupDir, 'contracts.json'))).toBe(true); + }); + + it('says the previous contracts.json was KEPT when no repo could be read', () => { + // "Did NOT write contracts.json" was false here: this path REWRITES the + // file, keeping the previous sync's contracts and replacing only the two + // diagnostic lists. Saying otherwise sent an operator looking at an + // unchanged mtime to conclude the sync had not run. + const groupDir = writeGroupYaml(home, 'kept', { + 'app/backend': 'kept-backend', + 'app/frontend': 'kept-frontend', + }); + registerReposWithNoIndex({ 'kept-backend': 'backend', 'kept-frontend': 'frontend' }); + const contractsPath = path.join(groupDir, 'contracts.json'); + fs.writeFileSync(contractsPath, JSON.stringify(PRIOR_REGISTRY), 'utf8'); + + const r = runGroupIn(home, ['sync', 'kept']); + + expect(r.status).toBe(0); + expect(r.stdout).toContain( + 'Kept the previous contracts.json — no repo in this group could be read.', + ); + expect(r.stdout).toContain('Its contracts and cross-links are unchanged'); + expect(r.stdout).not.toContain('Wrote contracts.json'); + expect(r.stdout).not.toContain('Did NOT write contracts.json'); + + // What makes the sentence true rather than merely present: the file is + // still there, its contracts are the previous run's, and only the + // diagnostic list describes THIS run. + const onDisk = JSON.parse(fs.readFileSync(contractsPath, 'utf8')) as Record; + expect(onDisk.contracts).toEqual(PRIOR_REGISTRY.contracts); + expect(onDisk.generatedAt).toBe(PRIOR_REGISTRY.generatedAt); + expect(onDisk.unreadableRepos).toEqual(['app/backend', 'app/frontend']); + }); + + it('says nothing was written when no repo could be read and there is no prior registry', () => { + // Distinct from the branch above on purpose: there is nothing on disk to + // keep, so promising the previous sync's contracts are safe would send an + // operator whose group has never synced looking for a file that has never + // existed. + const groupDir = writeGroupYaml(home, 'nothing', { + 'app/backend': 'nothing-backend', + 'app/frontend': 'nothing-frontend', + }); + registerReposWithNoIndex({ 'nothing-backend': 'backend', 'nothing-frontend': 'frontend' }); + + const r = runGroupIn(home, ['sync', 'nothing']); + + expect(r.status).toBe(0); + expect(r.stdout).toContain( + 'Did NOT write contracts.json — no repo in this group could be read,', + ); + expect(r.stdout).toContain('there is no previous contracts.json to fall back on'); + expect(r.stdout).not.toContain('Wrote contracts.json'); + expect(r.stdout).not.toContain('Kept the previous contracts.json'); + // And the claim is true of disk: no file was invented to go with it. + expect(fs.existsSync(path.join(groupDir, 'contracts.json'))).toBe(false); + }); +}); + +/** + * `undefined` and `[]` are different answers about the last sync's unreadable + * repos — "never recorded" versus the measurement "none" — and `group status` + * is where an operator reads them. Printing nothing for both would let an + * unmeasured sync read as evidence that every index opened cleanly, which is + * the fail-open the tri-state exists to close. + */ +describe('group status reports what the last sync recorded as unreadable', () => { + let home: string; + + const BASE_REGISTRY = { + version: 1, + generatedAt: '2026-01-01T00:00:00.000Z', + repoSnapshots: {}, + missingRepos: [], + contracts: [], + crossLinks: [], + }; + + const NOT_RECORDED_LINE = 'Last sync unreadable repos: not recorded'; + + beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-group-status-unreadable-')); + // An empty registry, so every member reports MISSING and nothing in the + // per-repo table can vary between these three cases. + fs.writeFileSync(path.join(home, 'registry.json'), '[]', 'utf8'); + }); + + afterEach(() => { + fs.rmSync(home, { recursive: true, force: true }); + }); + + const seed = (group: string, registry: Record): void => { + const groupDir = writeGroupYaml(home, group, { + 'app/backend': `${group}-backend`, + 'app/frontend': `${group}-frontend`, + }); + fs.writeFileSync(path.join(groupDir, 'contracts.json'), JSON.stringify(registry), 'utf8'); + }; + + it('says the field was not recorded when the registry never carried it', () => { + // A contracts.json written before the field existed has no opinion about + // which indexes were readable, and the remedy is to re-run the sync — not + // to conclude that none of them failed. + seed('unrecorded', BASE_REGISTRY); + + const r = runGroupIn(home, ['status', 'unrecorded']); + + expect(r.status).toBe(0); + expect(r.stdout).toContain(NOT_RECORDED_LINE); + expect(r.stdout).toContain('the registry predates this field, or its value could not be read'); + expect(r.stdout).toContain('Re-run `gitnexus group sync` to record it.'); + }); + + it('says nothing at all when the registry recorded an empty list', () => { + // `[]` is a measurement — this sync accounted for every repo — so there is + // no caveat to print and no repo to name. Reporting the "not recorded" + // caveat here would tell an operator to re-run the sync that just + // succeeded. + seed('measured', { ...BASE_REGISTRY, unreadableRepos: [] }); + + const r = runGroupIn(home, ['status', 'measured']); + + expect(r.status).toBe(0); + expect(r.stdout).not.toContain('Last sync unreadable repos'); + }); + + it('names the repos when the registry recorded some', () => { + // Without this, "says nothing at all" above would also be satisfied by a + // command that never printed this line on any registry. + seed('named', { ...BASE_REGISTRY, unreadableRepos: ['app/backend'] }); + + const r = runGroupIn(home, ['status', 'named']); + + expect(r.status).toBe(0); + expect(r.stdout).toContain('Last sync unreadable repos: app/backend'); + expect(r.stdout).not.toContain(NOT_RECORDED_LINE); + }); +}); diff --git a/gitnexus/test/integration/group/group-sync-lock-concurrency.test.ts b/gitnexus/test/integration/group/group-sync-lock-concurrency.test.ts new file mode 100644 index 000000000..fff0dd746 --- /dev/null +++ b/gitnexus/test/integration/group/group-sync-lock-concurrency.test.ts @@ -0,0 +1,609 @@ +/** + * The per-group sync lock (R9): two concurrent syncs of one group cannot lose + * one another's writes, and a sync that cannot be protected does not run. + * + * The exclusion cases contend with a REAL second process (`group-sync-lock-child.mjs`) + * rather than an in-process mock: the default backend's exclusion is a kernel + * socket binding and the file backend's is an O_EXCL create, so nothing observed + * inside one process can prove either. + * + * Nothing here is platform-skipped. The cases that need the FILE backend pin it + * explicitly (`GITNEXUS_INDEX_LOCK_BACKEND=file`) — the pin is load-bearing, not + * incidental: on Linux and Windows `selectBackend()` answers `socket`, and the + * socket backend never touches the filesystem, so an unpinned filesystem-failure + * case would measure nothing on two of the three platforms. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; +import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { syncGroup } from '../../../src/core/group/sync.js'; +import { + GROUP_SYNC_LOCK_TIMEOUT_MS, + GroupSyncLockError, + getGroupSyncLockDir, + withGroupSyncLock, +} from '../../../src/core/group/group-lock.js'; +import { GroupService } from '../../../src/core/group/service.js'; +import { makeGroupToolPort } from '../../unit/group/fixtures.js'; +import type { LockRecord } from '../../../src/storage/index-lock.js'; +import { CLI_SPAWN_PREFIX, tsxLoaderUrl } from '../../helpers/cli-entry.js'; +import type { GroupConfig, StoredContract } from '../../../src/core/group/types.js'; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(testDir, '../../..'); +const childScript = path.resolve(repoRoot, 'test', 'fixtures', 'group-sync-lock-child.mjs'); +const groupLockSource = path.resolve(repoRoot, 'src', 'core', 'group', 'group-lock.ts'); +const indexLockSpecifier = '../../../src/storage/index-lock.js'; +const groupLockSpecifier = '../../../src/core/group/group-lock.js'; + +const makeConfig = (name: string): GroupConfig => ({ + version: 1, + name, + description: '', + repos: {}, + links: [], + packages: {}, + detect: { + http: true, + grpc: false, + thrift: false, + topics: false, + shared_libs: false, + includes: false, + workspace_deps: false, + embedding_fallback: false, + }, + matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, +}); + +const parentContract: StoredContract = { + contractId: 'http::GET::/api/parent', + type: 'http', + role: 'provider', + symbolUid: 'uid-parent', + symbolRef: { filePath: 'src/parent.ts', name: 'Parent.get' }, + symbolName: 'Parent.get', + confidence: 0.9, + meta: { method: 'GET', path: '/api/parent' }, + repo: 'app/parent', +}; + +/** A persisting sync driven entirely off an extractor override (no repo index). */ +const runSync = (groupDir: string) => + syncGroup(makeConfig(path.basename(groupDir)), { + groupDir, + extractorOverride: async () => [parentContract], + }); + +const contractsPath = (groupDir: string): string => path.join(groupDir, 'contracts.json'); +const readContracts = (groupDir: string): Record => + JSON.parse(readFileSync(contractsPath(groupDir), 'utf8')) as Record; + +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); + +const waitFor = async (predicate: () => boolean, timeoutMs: number): Promise => { + const start = Date.now(); + for (;;) { + if (predicate()) return; + if (Date.now() - start > timeoutMs) throw new Error('condition not met within timeout'); + await sleep(25); + } +}; + +const waitForExit = (proc: ChildProcess, timeoutMs: number): Promise => + new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('child did not exit')), timeoutMs); + proc.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + }); + +let home: string; +const children: ChildProcess[] = []; + +/** Create `/groups/` with a group.yaml, the way `group create` does. */ +const makeGroup = (name: string): string => { + const dir = path.join(home, 'groups', name); + mkdirSync(dir, { recursive: true }); + writeFileSync( + path.join(dir, 'group.yaml'), + `version: 1\nname: ${name}\ndescription: ''\nrepos: {}\nlinks: []\n`, + ); + return dir; +}; + +/** + * Spawn the holder. tsx-on-source (not `dist/`) so the child runs the same + * module this process imported — the lock's directory and endpoint derivation + * must agree across the two, and a stale build would silently prove nothing. + */ +const spawnHolder = (opts: { + groupDir: string; + marker: string; + holdMs?: number; + contracts?: string; + released?: string; + backend?: string; +}): ChildProcess => { + const child = spawn(process.execPath, ['--import', tsxLoaderUrl(), childScript], { + env: { + ...process.env, + GROUP_LOCK_MODULE: pathToFileURL(groupLockSource).href, + GROUP_DIR: opts.groupDir, + MARKER: opts.marker, + HOLD_MS: String(opts.holdMs ?? 0), + ...(opts.contracts ? { CONTRACTS: opts.contracts } : {}), + ...(opts.released ? { RELEASED: opts.released } : {}), + ...(opts.backend ? { GITNEXUS_INDEX_LOCK_BACKEND: opts.backend } : {}), + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + children.push(child); + return child; +}; + +beforeEach(() => { + home = mkdtempSync(path.join(os.tmpdir(), 'gnx-group-lock-')); +}); + +afterEach(async () => { + for (const c of children) { + if (c.exitCode === null && c.signalCode === null) c.kill('SIGKILL'); + } + children.length = 0; + vi.doUnmock(indexLockSpecifier); + vi.resetModules(); + delete process.env.GITNEXUS_INDEX_LOCK_BACKEND; + delete process.env.GITNEXUS_INDEX_LOCK_TIMEOUT_MS; + rmSync(home, { recursive: true, force: true }); +}); + +describe('group sync lock — uncontended (regression gate)', () => { + it('a single sync completes and writes contracts.json exactly as before', async () => { + const groupDir = makeGroup('solo'); + const result = await runSync(groupDir); + + expect(result.registryOutcome).toBe('written'); + expect(result.contracts).toHaveLength(1); + expect(existsSync(contractsPath(groupDir))).toBe(true); + expect((readContracts(groupDir).contracts as unknown[]).length).toBe(1); + }, 60_000); + + it('holds the lock on /sync-lock, never on the group directory itself', async () => { + // KTD3. `acquireIndexLock`'s file backend writes `analyze.lock` into the + // directory it is handed, so handing it the group directory would drop a + // lock file beside contracts.json and share a namespace with anything else + // that ever locks a group. Pin the file backend: on the socket backend the + // lock leaves no filesystem trace at all, so this would assert nothing. + process.env.GITNEXUS_INDEX_LOCK_BACKEND = 'file'; + const groupDir = makeGroup('located'); + expect(getGroupSyncLockDir(groupDir)).toBe(path.join(groupDir, 'sync-lock')); + + await runSync(groupDir); + + expect(existsSync(getGroupSyncLockDir(groupDir))).toBe(true); + expect(existsSync(path.join(groupDir, 'analyze.lock'))).toBe(false); + }, 60_000); +}); + +describe('group sync lock — cross-process exclusion', () => { + it('makes the second sync wait, so the final registry is the LATER sync, not the first', async () => { + // The child writes its own contracts.json LAST, immediately before releasing. + // Without the lock the parent's sync would finish first and the child's write + // would land on top of it — the lost update this exists to prevent. With the + // lock the parent cannot start persisting until the child is done, so the + // final file is the parent's. + const groupDir = makeGroup('contended'); + const marker = path.join(home, 'held.marker'); + const released = path.join(home, 'released.marker'); + const HOLD_MS = 1500; + + spawnHolder({ + groupDir, + marker, + holdMs: HOLD_MS, + contracts: contractsPath(groupDir), + released, + }); + await waitFor(() => existsSync(marker), 60_000); + + const startedAt = Date.now(); + const result = await runSync(groupDir); + const finishedAt = Date.now(); + + expect(result.registryOutcome).toBe('written'); + // The holder released before we finished persisting. + const releasedAt = Number(readFileSync(released, 'utf8')); + expect(finishedAt).toBeGreaterThanOrEqual(releasedAt); + // And we genuinely waited rather than racing through: the hold began before + // our clock started, so a lock-free run would have finished near-instantly. + expect(finishedAt - startedAt).toBeGreaterThan(HOLD_MS / 2); + // The surviving registry is ours, not the holder's. + const written = readContracts(groupDir); + expect(written.writtenBy).toBeUndefined(); + expect((written.contracts as StoredContract[])[0].contractId).toBe(parentContract.contractId); + }, 120_000); + + it('lets a waiting sync proceed once the holder dies', async () => { + const groupDir = makeGroup('bereaved'); + const marker = path.join(home, 'held.marker'); + const child = spawnHolder({ groupDir, marker }); // holds until killed + await waitFor(() => existsSync(marker), 60_000); + + let settled = false; + const pending = runSync(groupDir).finally(() => { + settled = true; + }); + await sleep(600); + expect(settled).toBe(false); // blocked on the live holder + + child.kill('SIGKILL'); + await waitForExit(child, 30_000); + + const result = await pending; + expect(result.registryOutcome).toBe('written'); + expect(existsSync(contractsPath(groupDir))).toBe(true); + }, 120_000); + + it('does not make syncs of two different groups contend', async () => { + // The holder never releases, so if the lock were group-agnostic this sync + // would block until the wait ceiling and the case would fail by timeout. + const held = makeGroup('group-a'); + const other = makeGroup('group-b'); + const marker = path.join(home, 'held.marker'); + const child = spawnHolder({ groupDir: held, marker }); + await waitFor(() => existsSync(marker), 60_000); + + const result = await runSync(other); + + expect(result.registryOutcome).toBe('written'); + expect(existsSync(contractsPath(other))).toBe(true); + expect(existsSync(contractsPath(held))).toBe(false); + expect(child.exitCode).toBeNull(); // still holding group-a + }, 120_000); +}); + +describe('group sync lock — fails closed', () => { + /** Occupy `/sync-lock` with a regular file: the lock directory then + * cannot be created (EEXIST), on every platform, with no permission games. */ + const blockLockDir = (groupDir: string): void => { + writeFileSync(getGroupSyncLockDir(groupDir), 'not a directory'); + }; + + it('refuses to sync when the sync-lock directory cannot be created', async () => { + process.env.GITNEXUS_INDEX_LOCK_BACKEND = 'file'; + const groupDir = makeGroup('blocked'); + blockLockDir(groupDir); + + await expect(runSync(groupDir)).rejects.toBeInstanceOf(GroupSyncLockError); + expect(existsSync(contractsPath(groupDir))).toBe(false); + }, 60_000); + + it('rejects the lock-free handle a read-only filesystem produces', async () => { + // KTD4.2. `acquireIndexLock` answers EROFS/EACCES/EPERM with a no-op handle + // that is byte-identical in shape to a real one — right for `analyze`, fatal + // here, because the sync would go on to write with nothing protecting it. + // The failure is injected at the one syscall that produces it, so the REAL + // acquire path runs and the REAL no-op handle comes back; a permissions + // fixture would have to be skipped on Windows, where mode bits do not deny + // directory creation, and skipping is what makes this guarantee a fiction. + process.env.GITNEXUS_INDEX_LOCK_BACKEND = 'file'; + const groupDir = makeGroup('readonly'); + const lockDir = getGroupSyncLockDir(groupDir); + + vi.resetModules(); + vi.doMock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + const mkdirSync: typeof actual.mkdirSync = (( + p: Parameters[0], + o, + ) => { + if (String(p) === lockDir) { + const err: NodeJS.ErrnoException = new Error(`EACCES: permission denied, mkdir '${p}'`); + err.code = 'EACCES'; + throw err; + } + return actual.mkdirSync(p, o); + }) as typeof actual.mkdirSync; + return { ...actual, mkdirSync, default: { ...actual, mkdirSync } }; + }); + const fresh = await import(groupLockSpecifier); + + let ran = false; + await expect( + fresh.withGroupSyncLock(groupDir, async () => { + ran = true; + }), + ).rejects.toMatchObject({ name: 'GroupSyncLockError', reason: 'lock-free' }); + expect(ran).toBe(false); + + vi.doUnmock('node:fs'); + vi.resetModules(); + }, 60_000); + + it('propagates an acquire timeout instead of running the sync unprotected', async () => { + const groupDir = makeGroup('timed-out'); + const holder: LockRecord = { + v: 1, + pid: 4242, + hostname: os.hostname(), + startTime: null, + token: 't', + invocationId: 'other-sync', + acquiredAt: new Date().toISOString(), + }; + + vi.resetModules(); + vi.doMock(indexLockSpecifier, async () => { + const actual = + await vi.importActual( + indexLockSpecifier, + ); + return { + ...actual, + acquireIndexLock: async () => { + throw new actual.IndexLockTimeoutError(holder, 600_000); + }, + }; + }); + const fresh = await import(groupLockSpecifier); + + let ran = false; + const err = await fresh + .withGroupSyncLock(groupDir, async () => { + ran = true; + }) + .then( + () => null, + (e: Error) => e, + ); + + expect(ran).toBe(false); + expect(err).toMatchObject({ name: 'GroupSyncLockError', reason: 'timeout' }); + // The wording is the subject of the suite below; here it only has to be the + // group lock's own message rather than the primitive's raw failure text. + expect((err as Error).message).toContain('sync lock on group "timed-out"'); + // The original error is preserved as `cause`, holder metadata intact. Asserted + // structurally, not with `instanceof`: this case drives a freshly re-evaluated + // module graph, whose `IndexLockTimeoutError` is a different class object from + // the statically imported one. + expect((err as { cause?: unknown }).cause).toMatchObject({ + name: 'IndexLockTimeoutError', + holder: { invocationId: 'other-sync' }, + holderKnown: true, + }); + }, 60_000); + + it('passes its own wait ceiling, so GITNEXUS_INDEX_LOCK_TIMEOUT_MS cannot make it unbounded', async () => { + // `resolveTimeoutMs` prefers an explicit argument over the env var, whose + // `<= 0` case resolves to POSITIVE_INFINITY — inheriting it would turn this + // lock's fail-closed timeout into a hang. + process.env.GITNEXUS_INDEX_LOCK_TIMEOUT_MS = '0'; + const groupDir = makeGroup('ceiling'); + const seen: Array> = []; + + vi.resetModules(); + vi.doMock(indexLockSpecifier, async () => { + const actual = + await vi.importActual( + indexLockSpecifier, + ); + return { + ...actual, + acquireIndexLock: async (_dir: string, o: Record) => { + seen.push(o); + return { record: {} as LockRecord, release: () => {} }; + }, + }; + }); + const fresh = await import(groupLockSpecifier); + + await fresh.withGroupSyncLock(groupDir, async () => undefined); + + expect(seen).toHaveLength(1); + expect(seen[0].timeoutMs).toBe(GROUP_SYNC_LOCK_TIMEOUT_MS); + expect(Number.isFinite(seen[0].timeoutMs as number)).toBe(true); + }, 60_000); +}); + +describe('group sync lock — what the timeout says happened', () => { + /** + * Drive `withGroupSyncLock` against an `acquireIndexLock` that waits `waitMs` + * and then times out exactly as the primitive does, and hand back the error + * the wrapper produced. + * + * `reportedMs` is the figure baked into the INHERITED message and is + * deliberately nowhere near the real wait: a wrapper that re-uses the + * primitive's text reports that number, one that times the acquisition itself + * reports `waitMs`. `IndexLockTimeoutError` carries no elapsed field, so the + * two are the only places the figure can come from. + */ + const timedOutAcquire = async (opts: { + groupDir: string; + waitMs: number; + reportedMs: number; + holderKnown: boolean; + }): Promise => { + // `holderKnown: false` mirrors `unknownHolder()` in index-lock.ts: the + // socket backend exposes no owner metadata, so the record is a placeholder + // (`pid -1`) that no message may present as a real holder. + const holder: LockRecord = { + v: 1, + pid: opts.holderKnown ? 4242 : -1, + hostname: os.hostname(), + startTime: null, + token: opts.holderKnown ? 't' : '', + invocationId: opts.holderKnown ? 'other-sync' : '', + acquiredAt: opts.holderKnown ? new Date().toISOString() : '', + }; + + vi.resetModules(); + vi.doMock(indexLockSpecifier, async () => { + const actual = + await vi.importActual( + indexLockSpecifier, + ); + return { + ...actual, + acquireIndexLock: async () => { + await sleep(opts.waitMs); + throw new actual.IndexLockTimeoutError(holder, opts.reportedMs, opts.holderKnown); + }, + }; + }); + const fresh = await import(groupLockSpecifier); + + return fresh + .withGroupSyncLock(opts.groupDir, async () => undefined) + .then( + () => null, + (e: Error) => e, + ); + }; + + const waitedMsIn = (message: string): number => + Number(/Timed out after (\d+)ms/.exec(message)?.[1] ?? NaN); + + it('names the group, the operation, and the wait it measured itself', async () => { + const groupDir = makeGroup('slow-group'); + const err = await timedOutAcquire({ + groupDir, + waitMs: 120, + reportedMs: 600_000, + holderKnown: true, + }); + + expect(err).toMatchObject({ name: 'GroupSyncLockError', reason: 'timeout' }); + const msg = String(err?.message); + expect(msg).toContain('sync lock on group "slow-group"'); + expect(msg).toContain(getGroupSyncLockDir(groupDir)); + expect(msg).toContain('was not synced'); + + // The elapsed wait is this wrapper's own measurement. The primitive + // announced 600000ms; the acquisition actually took ~120ms, and only a + // wrapper that timed it can say so. Half the sleep is the floor, the way + // the exclusion case above bounds its own wait — a timer cannot fire at + // half its delay on any host. + const waited = waitedMsIn(msg); + expect(Number.isFinite(waited)).toBe(true); + expect(waited).toBeGreaterThanOrEqual(60); + expect(msg).not.toContain('600000'); + + // The primitive's error is still the cause, so nothing is lost by rewording. + expect((err as { cause?: unknown }).cause).toMatchObject({ + name: 'IndexLockTimeoutError', + holder: { invocationId: 'other-sync' }, + }); + }, 60_000); + + it('does not blame an analyze, and does not name a holder the backend cannot identify', async () => { + // The inherited message says "another gitnexus analyze" holds the lock — + // a cause this path cannot establish (nothing but a group sync ever locks + // `/sync-lock`), and on the socket backend it cannot name the + // holder at all: `holderKnown` is false and `holder.pid` is the placeholder + // -1. Fail-closed made both claims user-visible for the first time. + const groupDir = makeGroup('anonymous-holder'); + const err = await timedOutAcquire({ + groupDir, + waitMs: 0, + reportedMs: 600_000, + holderKnown: false, + }); + + const msg = String(err?.message); + expect(msg).toContain('sync lock on group "anonymous-holder"'); + expect(msg).not.toMatch(/analyze/i); + // No pid is quoted at all — not the placeholder, not any other. Matched on + // the shape the message would use to name one, so a random temp-directory + // segment cannot satisfy it by accident. + expect(msg).not.toMatch(/pid\s+-?\d+/i); + expect(msg).toContain('cannot identify the holder'); + }, 60_000); + + it('names the holder when the backend does identify one', async () => { + // The other half of the branch: on the file backend the record is real, and + // suppressing it would throw away the one thing that lets an operator find + // the process to wait for. + const groupDir = makeGroup('identified-holder'); + const err = await timedOutAcquire({ + groupDir, + waitMs: 0, + reportedMs: 600_000, + holderKnown: true, + }); + + const msg = String(err?.message); + expect(msg).toMatch(/pid 4242/); + expect(msg).toContain(os.hostname()); + expect(msg).toContain('other-sync'); + expect(msg).not.toMatch(/analyze/i); + expect(msg).not.toContain('cannot identify the holder'); + }, 60_000); + + it('control: an acquisition that succeeds raises nothing', async () => { + // No mock: the real lock, uncontended. Without this, every assertion above + // could be satisfied by a wrapper that failed on every acquisition. + const groupDir = makeGroup('uncontended-message'); + + await expect(withGroupSyncLock(groupDir, async () => 'ran')).resolves.toBe('ran'); + }, 60_000); +}); + +describe('group sync lock — how a lock failure surfaces', () => { + it('fails the `group sync` command with the lock message, not a stack trace', () => { + process.env.GITNEXUS_INDEX_LOCK_BACKEND = 'file'; + const groupDir = makeGroup('cli-blocked'); + writeFileSync(getGroupSyncLockDir(groupDir), 'not a directory'); + + const run = spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, 'group', 'sync', 'cli-blocked'], { + cwd: repoRoot, + encoding: 'utf8', + timeout: 120_000, + env: { ...process.env, GITNEXUS_HOME: home, GITNEXUS_INDEX_LOCK_BACKEND: 'file' }, + }); + + expect(run.status).not.toBe(0); + // The message goes through pino (`console.error` is an eslint error in this + // package — a forcing function for that migration), so it arrives as a JSON + // envelope rather than raw text. Read the `msg` field: asserting the raw + // substring would pass only by accident of quoting, and would go green + // again if the line were ever downgraded to a bare stderr write. + const logged = run.stderr + .split('\n') + .filter((line) => line.trim().startsWith('{')) + .map((line) => JSON.parse(line) as { level: number; msg: string }); + const failure = logged.find((entry) => entry.msg.includes('Did not sync group')); + expect(failure, `no failure log in stderr: ${run.stderr}`).toBeDefined(); + expect(failure?.level).toBe(50); // pino error + expect(failure?.msg).toContain('Did not sync group "cli-blocked"'); + expect(failure?.msg).toContain('sync lock'); + expect(run.stderr).not.toContain('GroupSyncLockError: '); + expect(run.stderr).not.toMatch(/^\s+at /m); // no stack frames + expect(existsSync(contractsPath(groupDir))).toBe(false); + }, 180_000); + + it('returns a lock failure through group_sync as an error payload, never an empty success', async () => { + process.env.GITNEXUS_INDEX_LOCK_BACKEND = 'file'; + const groupDir = makeGroup('mcp-blocked'); + writeFileSync(getGroupSyncLockDir(groupDir), 'not a directory'); + process.env.GITNEXUS_HOME = home; + + try { + const service = new GroupService(makeGroupToolPort(home)); + const payload = (await service.groupSync({ name: 'mcp-blocked' })) as Record; + + expect(typeof payload.error).toBe('string'); + expect(String(payload.error)).toContain('sync lock'); + // The failure must not masquerade as a clean sync of an empty group. + expect(payload.contracts).toBeUndefined(); + expect(payload.registryOutcome).toBeUndefined(); + expect(existsSync(contractsPath(groupDir))).toBe(false); + } finally { + delete process.env.GITNEXUS_HOME; + } + }, 120_000); +}); diff --git a/gitnexus/test/unit/group/bridge-db.test.ts b/gitnexus/test/unit/group/bridge-db.test.ts index 5fb3308de..56e70501e 100644 --- a/gitnexus/test/unit/group/bridge-db.test.ts +++ b/gitnexus/test/unit/group/bridge-db.test.ts @@ -10,13 +10,18 @@ import { closeBridgeDb, contractNodeId, writeBridge, + writeBridgeUnlocked, + bridgeMetaMatchesFile, openBridgeDbReadOnly, readBridgeMeta, bridgeExists, createContractLookupIndex, indexContract, findContractNode, + type WriteBridgeInput, } from '../../../src/core/group/bridge-db.js'; +import { getGroupSyncLockDir, withGroupSyncLock } from '../../../src/core/group/group-lock.js'; +import { BRIDGE_SCHEMA_VERSION } from '../../../src/core/group/bridge-schema.js'; import { retryRename } from '../../../src/storage/fs-atomic.js'; import type { BridgeHandle, CrossLink } from '../../../src/core/group/types.js'; import { makeContract } from './fixtures.js'; @@ -153,6 +158,75 @@ describe('writeBridge + read', () => { expect(exists).toBe(true); }); + it("replaces the previous sync's metadata on a successful rebuild", async () => { + // meta.json describes the bridge's completeness, and since #3011 that is + // load-bearing: runGroupImpact folds `unreadableRepos ∪ missingRepos` into + // its truncation fields, so a value from an earlier sync is a wrong answer + // about this one. + // + // Scope, stated because the obvious stronger reading is wrong: this covers + // the SUCCESSFUL path only. It cannot pin the removal-before-swap ordering, + // because writeBridge overwrites meta.json at the end either way — the + // assertions below hold with the removal in either position. The ordering + // is pinned in `bridge-meta-swap-window.test.ts`, which fails the swap + // itself and checks the previous sync's metadata cannot survive it. + await writeBridge(tmpDir, { + contracts: [makeContract()], + crossLinks: [], + repoSnapshots: {}, + missingRepos: [], + }); + const before = await readBridgeMeta(tmpDir); + + await writeBridge(tmpDir, { + contracts: [makeContract()], + crossLinks: [], + repoSnapshots: {}, + missingRepos: [], + unreadableRepos: ['svc/users'], + }); + const after = await readBridgeMeta(tmpDir); + + expect(before.unreadableRepos).toBeUndefined(); + expect(after.unreadableRepos).toEqual(['svc/users']); + }); + + it('reports version 0 for a bridge whose meta.json is gone', async () => { + // `version: 0` is the "no provenance" signal `runGroupImpact` fails closed + // on, so it is worth asserting directly rather than only through the + // callers that consume it. No fault is injected into writeBridge here — + // the file is removed afterwards — so this pins readBridgeMeta's contract, + // not the write ordering (see `bridge-meta-swap-window.test.ts` for that). + await writeBridge(tmpDir, { + contracts: [makeContract()], + crossLinks: [], + repoSnapshots: {}, + missingRepos: [], + unreadableRepos: ['svc/users'], + }); + + await fsp.rm(path.join(tmpDir, 'meta.json'), { force: true }); + const meta = await readBridgeMeta(tmpDir); + + expect(meta.version).toBe(0); + expect(meta.unreadableRepos).toBeUndefined(); + }); + + it('persists an explicitly empty unreadableRepos measurement', async () => { + // Same distinction as the registry: `[]` means the sync accounted for every + // repo, and dropping it collapses that into "never recorded". + await writeBridge(tmpDir, { + contracts: [makeContract()], + crossLinks: [], + repoSnapshots: {}, + missingRepos: [], + unreadableRepos: [], + }); + + const meta = await readBridgeMeta(tmpDir); + expect(meta.unreadableRepos).toEqual([]); + }); + it('test_writeBridge_returns_report_with_insert_counts', async () => { const report = await writeBridge(tmpDir, { contracts: [makeContract(), makeContract({ repo: 'frontend', role: 'consumer' })], @@ -448,6 +522,226 @@ describe('writeBridge + read', () => { expect(meta.missingRepos).toEqual([]); }); }); +/* ------------------------------------------------------------------ */ +/* The bridge swap runs inside the caller's critical section (R9) */ +/* ------------------------------------------------------------------ */ + +/** + * R9, writer-writer half. The swap and the metadata write are two operations: + * `bridge.lbug` is renamed into place first, and only then is `meta.json` + * written with the size and mtime of the file it describes. Two writers that + * overlap can therefore leave one writer's metadata beside the other's + * database. What prevents it is the group sync lock, held across the whole + * swap. + * + * That is why the swap comes in two halves. `writeBridgeUnlocked` assumes the + * lock is already held and is what `syncGroup` calls from inside its + * `withGroupSyncLock` region; `writeBridge` is the thin acquiring wrapper for + * callers that are not already in that region — every caller in this file, and + * every other direct caller in the suite. Routing the held-lock caller through + * the wrapper instead would be a SECOND acquisition of a non-reentrant + * primitive, which does not fail fast: it waits out the ten-minute ceiling + * against a lock its own call stack holds. The first case below is the + * regression gate for exactly that, and it goes red by timeout. + * + * SCOPE, so the block is not read as more than it is: this is writer-writer + * exclusion only. The reader-side promotion of a leftover backup file runs on + * ordinary reads, outside anyone's critical section, and `bridgeMetaMatchesFile` + * remains the reader's defense there. + * + * Nothing here opens `bridge.lbug`. The in-process write-then-read reopen is + * the documented LadybugDB Windows limitation this file skips elsewhere + * (`itLbugReopen`), so every assertion below is made on file state and on + * `bridgeMetaMatchesFile`, which reads `meta.json` and the database's `stat` + * and never opens it. No case in this block is platform-skipped, and the + * surrounding `writeBridge + read` describe is the unchanged control for the + * single-direct-call path. + */ +describe("writeBridge — the swap runs inside the caller's critical section (R9)", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'bridge-lock-')); + }); + + afterEach(async () => { + await cleanupTempDir(tmpDir); + }); + + /** One contract, plus a `missingRepos` marker naming the writer that built it. */ + const payload = (writer: string): WriteBridgeInput => ({ + contracts: [makeContract({ repo: writer })], + crossLinks: [], + repoSnapshots: {}, + missingRepos: [writer], + }); + + /** + * How long a contended writer is given to finish while the lock is held + * elsewhere. An uncontended write of this payload takes ~50-110ms in this + * suite, so the window is more than fifteen times the work — "still not + * finished" is a statement about the lock rather than about how fast the host + * is, and a wrapper that does not acquire finishes inside it on any host. + */ + const CONTENDED_WINDOW_MS = 2000; + + /** + * A plain existence check. Deliberately NOT `bridgeExists`, which is a READER + * and promotes a leftover `bridge.lbug.bak` back into place on its way to an + * answer — the reader-side path this block makes no claim about, and one that + * would repair the crashed state the last case is trying to hand to the next + * writer. + */ + const onDisk = (name: string): Promise => + fsp.access(path.join(tmpDir, name)).then( + () => true, + () => false, + ); + + it('a caller that already holds the group lock completes the swap without acquiring a second one', async () => { + // The production shape: `syncGroup` holds the lock across its whole persist + // section and calls the LOCK-FREE half from inside it. `acquireIndexLock` is + // not reentrant, so a swap that acquired for itself would not fail fast — it + // would wait out GROUP_SYNC_LOCK_TIMEOUT_MS (ten minutes) against a lock this + // very call stack is holding. The short per-case timeout is the assertion: + // this case goes red by TIMEOUT the moment the inner half starts acquiring. + const report = await withGroupSyncLock(tmpDir, () => + writeBridgeUnlocked(tmpDir, payload('held-lock-caller')), + ); + + expect(report.contractsInserted).toBe(1); + expect(await bridgeExists(tmpDir)).toBe(true); + const meta = await readBridgeMeta(tmpDir); + expect(meta.missingRepos).toEqual(['held-lock-caller']); + expect(await bridgeMetaMatchesFile(tmpDir, meta)).toBe(true); + }, 15_000); + + it('a direct write cannot enter the swap while another holder has the group lock', async () => { + // The exclusion itself, observed as ordering rather than as a race: a holder + // takes the group lock, a direct `writeBridge` starts underneath it, and the + // write may not complete until the holder lets go. Nothing is mocked — the + // holder takes the same real lock the wrapper does. + const order: string[] = []; + let markHeld!: () => void; + const lockIsHeld = new Promise((resolve) => { + markHeld = resolve; + }); + let releaseHolder!: () => void; + const holderMayRelease = new Promise((resolve) => { + releaseHolder = resolve; + }); + + const holder = withGroupSyncLock(tmpDir, async () => { + markHeld(); + await holderMayRelease; + order.push('holder-released'); + }); + await lockIsHeld; + + let settled = false; + const contender = writeBridge(tmpDir, payload('contender')).then(() => { + order.push('write-finished'); + settled = true; + }); + + await new Promise((resolve) => setTimeout(resolve, CONTENDED_WINDOW_MS)); + // The write is still outside the swap. Without the wrapper's acquisition it + // has long since finished, and the ordering assertion below inverts. + expect(settled).toBe(false); + // And it has not written anything either: the swap is what produces both files. + expect(await onDisk('bridge.lbug')).toBe(false); + expect(await onDisk('meta.json')).toBe(false); + + releaseHolder(); + await holder; + await contender; + + expect(order).toEqual(['holder-released', 'write-finished']); + expect((await readBridgeMeta(tmpDir)).missingRepos).toEqual(['contender']); + }, 30_000); + + it('after two contended direct writes the metadata on disk vouches for the database on disk', async () => { + // Two writers into one group at once. Serialized, the loser's swap completes + // in full before the winner's begins, so what is left is one writer's + // database under one writer's metadata — never a mixture, and never a stamp + // taken from the other writer's file. + const [first, second] = await Promise.all([ + writeBridge(tmpDir, payload('writer-a')), + writeBridge(tmpDir, payload('writer-b')), + ]); + expect(first.contractsInserted).toBe(1); + expect(second.contractsInserted).toBe(1); + + const meta = await readBridgeMeta(tmpDir); + // Exactly one writer's measurement — not both, not neither. + expect(meta.missingRepos).toHaveLength(1); + expect(['writer-a', 'writer-b']).toContain(meta.missingRepos[0]); + // ...and the stamp it carries describes the database that is actually there. + expect(await bridgeMetaMatchesFile(tmpDir, meta)).toBe(true); + expect(meta.provenanceUnknown).toBeUndefined(); + expect(meta.version).toBe(BRIDGE_SCHEMA_VERSION); + + // Nothing is left half-swapped: the backup was consumed and neither staging + // directory survives its writer. + const left = await fsp.readdir(tmpDir); + expect(left.filter((f) => f.startsWith('bridge.lbug.bak'))).toEqual([]); + expect(left.filter((f) => f.startsWith('bridge-tmp-'))).toEqual([]); + }, 60_000); + + it('the wrapper releases the group lock, so the next writer is not blocked by the last one', async () => { + await writeBridge(tmpDir, payload('first')); + + // Sequential, not concurrent. A wrapper that acquired and never released + // would not fail here — it would hang until the ten-minute ceiling, which is + // what the short per-case timeout turns into a red. + await expect(withGroupSyncLock(tmpDir, async () => 'free')).resolves.toBe('free'); + + const again = await writeBridge(tmpDir, payload('second')); + expect(again.contractsInserted).toBe(1); + const meta = await readBridgeMeta(tmpDir); + expect(meta.missingRepos).toEqual(['second']); + expect(await bridgeMetaMatchesFile(tmpDir, meta)).toBe(true); + }, 30_000); + + it('a writer that died mid-swap leaves state the next write recovers from', async () => { + await writeBridge(tmpDir, payload('before-the-crash')); + + // Reproduce what a process killed between the two renames leaves behind: the + // live database moved aside to `bridge.lbug.bak`, no `bridge.lbug` at all, + // the staging directory it never cleaned up, and its lock directory still on + // disk. That last one matters on the file backend, where the directory + // outlives the holder; on the socket backend the kernel drops the binding + // when the holder dies and there is nothing on disk to leave. Pre-creating it + // is harmless there and load-bearing here, which is why it is not skipped. + await fsp.rename(path.join(tmpDir, 'bridge.lbug'), path.join(tmpDir, 'bridge.lbug.bak')); + for (const suffix of ['.wal', '.shadow']) { + await fsp + .rename( + path.join(tmpDir, `bridge.lbug${suffix}`), + path.join(tmpDir, `bridge.lbug.bak${suffix}`), + ) + .catch(() => { + /* sidecar absent — nothing to move */ + }); + } + const orphanStaging = path.join(tmpDir, 'bridge-tmp-deadwriter'); + await fsp.mkdir(orphanStaging, { recursive: true }); + await fsp.writeFile(path.join(orphanStaging, 'bridge.lbug'), 'half-written'); + await fsp.mkdir(getGroupSyncLockDir(tmpDir), { recursive: true }); + expect(await onDisk('bridge.lbug')).toBe(false); + expect(await onDisk('bridge.lbug.bak')).toBe(true); + + const report = await writeBridge(tmpDir, payload('after-the-crash')); + + expect(report.contractsInserted).toBe(1); + expect(await onDisk('bridge.lbug')).toBe(true); + const meta = await readBridgeMeta(tmpDir); + expect(meta.missingRepos).toEqual(['after-the-crash']); + expect(await bridgeMetaMatchesFile(tmpDir, meta)).toBe(true); + // The dead writer's backup is consumed by the recovery, not inherited by it. + expect(await onDisk('bridge.lbug.bak')).toBe(false); + }, 60_000); +}); /* ------------------------------------------------------------------ */ /* getCachedBridgeReadOnly cache tests */ diff --git a/gitnexus/test/unit/group/bridge-meta-swap-window.test.ts b/gitnexus/test/unit/group/bridge-meta-swap-window.test.ts new file mode 100644 index 000000000..5ad4a4258 --- /dev/null +++ b/gitnexus/test/unit/group/bridge-meta-swap-window.test.ts @@ -0,0 +1,506 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { makeContract } from './fixtures.js'; +import { BRIDGE_SCHEMA_VERSION } from '../../../src/core/group/bridge-schema.js'; + +/** + * `writeBridge` replaces `bridge.lbug` and writes `meta.json` as two separate + * operations, so there is a window between them that an interrupted or failing + * sync stops inside. Which way that window fails is a correctness decision, not + * a detail. + * + * `meta.json` records which repos the sync could not account for, and since + * #3011 `runGroupImpact` folds that into its truncation fields. So a STALE meta + * left beside a NEWLY swapped bridge asserts that the new bridge is as complete + * as the previous sync was — a confident wrong answer about the exact thing this + * channel exists to make legible. + * + * Deleting the old meta before the swap would close that, and is wrong. 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. Its metadata would + * then be gone unrecoverably, and cross-repo impact would answer "we cannot say" + * for as long as the swap kept failing. That is a working feature destroyed to + * close a narrow window. + * + * So nothing is deleted. `writeBridge` stamps the database's size and mtime into + * the metadata, and `bridgeMetaMatchesFile` checks the pair still belongs + * together. This file pins both halves: a stale meta is rejected, and a sync + * that fails leaves the previous, matching pair intact. + */ + +/** + * `mode` selects which rename fails, and the distinction matters: + * + * - `'all'` models the Windows shape the fix is really about. The old + * database's move to `.bak` is itself wrapped in a catch that swallows + * failures, so a held read-only handle makes that move fail SILENTLY and the + * subsequent `tmp -> bridge.lbug` throw — leaving the old database exactly + * where it was, still valid. + * - `'final'` fails only the `tmp -> bridge.lbug` step, so the old database has + * already been moved aside to `.bak` and no database is in place at all. + */ +const renameMock = vi.hoisted(() => ({ mode: 'none' as 'none' | 'all' | 'final' })); + +vi.mock('../../../src/storage/fs-atomic.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + retryRename: async (src: string, dst: string) => { + const fails = + renameMock.mode === 'all' || (renameMock.mode === 'final' && dst.endsWith('bridge.lbug')); + if (fails) throw new Error(`simulated rename failure for ${dst}`); + return actual.retryRename(src, dst); + }, + }; +}); + +const { writeBridge, readBridgeMeta, bridgeMetaMatchesFile, closeAllCachedBridges } = + await import('../../../src/core/group/bridge-db.js'); + +const input = (unreadableRepos?: string[]) => ({ + contracts: [makeContract()], + crossLinks: [], + repoSnapshots: {}, + missingRepos: [], + ...(unreadableRepos ? { unreadableRepos } : {}), +}); + +describe('writeBridge meta.json swap window', () => { + let groupDir: string; + + beforeEach(async () => { + renameMock.mode = 'none'; + groupDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-bridge-window-')); + }); + + afterEach(async () => { + renameMock.mode = 'none'; + await fsp.rm(groupDir, { recursive: true, force: true }); + }); + + it('keeps the previous metadata when the database swap fails, and it still matches', async () => { + // The regression this file exists for. An earlier version of the fix deleted + // meta.json before the swap; because the old-database rename is inside a + // catch that swallows failures, `writeBridge` can throw with that database + // still in place — and the metadata describing it already destroyed. + await writeBridge(groupDir, input([])); + const seeded = await readBridgeMeta(groupDir); + + // Every rename fails, so the old database never moves: this is the shape a + // held handle produces on Windows. + renameMock.mode = 'all'; + await expect(writeBridge(groupDir, input(['svc/users']))).rejects.toThrow('simulated rename'); + + const after = await readBridgeMeta(groupDir); + + // Nothing was lost: the previous sync's measurement survives... + expect(after.version).toBe(seeded.version); + expect(after.generatedAt).toBe(seeded.generatedAt); + expect(after.unreadableRepos).toEqual([]); + // ...and it still describes the database that is actually on disk, so + // cross-repo impact keeps answering from it instead of degrading to a floor + // until some future sync happens to succeed. + await expect(bridgeMetaMatchesFile(groupDir, after)).resolves.toBe(true); + }); + + it('reports no match when the swap moved the database aside and then failed', async () => { + // The other failure shape: the old database reached `.bak` and the new one + // never arrived, so there is no `bridge.lbug` for the surviving metadata to + // describe. Rejecting is correct here — `ensureBridgeReady` fails loudly on + // the absent database anyway, which is a better answer than a silent floor. + await writeBridge(groupDir, input([])); + const seeded = await readBridgeMeta(groupDir); + + renameMock.mode = 'final'; + await expect(writeBridge(groupDir, input(['svc/users']))).rejects.toThrow('simulated rename'); + + const after = await readBridgeMeta(groupDir); + + expect(after.generatedAt).toBe(seeded.generatedAt); + await expect(bridgeMetaMatchesFile(groupDir, after)).resolves.toBe(false); + }); + + it('rejects metadata that describes a different database', async () => { + // The other half: the stale-meta-beside-a-new-bridge window. Simulated by + // replacing the database underneath a metadata file that was written for + // the previous one — which is the state a sync interrupted between the swap + // and the metadata write leaves behind. + await writeBridge(groupDir, input([])); + const stale = await readBridgeMeta(groupDir); + + const dbPath = path.join(groupDir, 'bridge.lbug'); + const bytes = await fsp.readFile(dbPath); + await fsp.writeFile(dbPath, Buffer.concat([bytes, Buffer.from([0])])); + + await expect(bridgeMetaMatchesFile(groupDir, stale)).resolves.toBe(false); + }); + + it('accepts metadata that carries no stamp but was written after its database', async () => { + // Back-compat: a bridge written before the stamp existed carries no stamp + // to check, and failing those closed would mark every pre-existing bridge + // incomplete — a repo-wide regression traded for a narrow window. It is + // still paired to a database, though: `writeBridge` renames the database in + // and writes the metadata after, so this pair's write order is intact and + // that is what it is judged on. + await writeBridge(groupDir, input([])); + const meta = await readBridgeMeta(groupDir); + const legacy = { ...meta }; + delete legacy.bridgeSize; + delete legacy.bridgeMtimeMs; + + await expect(bridgeMetaMatchesFile(groupDir, legacy)).resolves.toBe(true); + }); + + it('rejects a stamp when the database is gone entirely', async () => { + await writeBridge(groupDir, input([])); + const meta = await readBridgeMeta(groupDir); + await fsp.rm(path.join(groupDir, 'bridge.lbug'), { force: true }); + + await expect(bridgeMetaMatchesFile(groupDir, meta)).resolves.toBe(false); + }); + + it('records the new metadata when the swap succeeds', async () => { + // The control: removing the old meta early must not cost the happy path + // its metadata, which a fix that only deleted would. + await writeBridge(groupDir, input()); + + await writeBridge(groupDir, input(['svc/users'])); + + const after = await readBridgeMeta(groupDir); + expect(after.version).toBeGreaterThan(0); + expect(after.unreadableRepos).toEqual(['svc/users']); + }); +}); + +describe('bridgeMetaMatchesFile with a half-written stamp', () => { + let groupDir: string; + + beforeEach(async () => { + renameMock.mode = 'none'; + groupDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-bridge-partial-')); + }); + + afterEach(async () => { + renameMock.mode = 'none'; + await fsp.rm(groupDir, { recursive: true, force: true }); + }); + + /** + * A stamp is a PAIR. Either both halves describe the database beside them or + * the metadata cannot vouch for it at all. + * + * The absent-stamp branch exists for metadata written before stamping, which + * is a benign, known state. A metadata file carrying exactly one half is not + * that: something wrote a stamp and did not finish, which is the very + * condition the stamp was added to detect. Accepting it — as an `undefined` + * check joined by `||` did — hands back "verified" for the one shape that + * most deserves suspicion. + */ + const seedStamped = async (): Promise => { + await writeBridge(groupDir, input([])); + }; + + const rewriteMeta = async (mutate: (m: Record) => void): Promise => { + const metaPath = path.join(groupDir, 'meta.json'); + const raw = JSON.parse(await fsp.readFile(metaPath, 'utf-8')) as Record; + mutate(raw); + await fsp.writeFile(metaPath, JSON.stringify(raw, null, 2)); + }; + + it('rejects metadata carrying a size but no mtime', async () => { + await seedStamped(); + await rewriteMeta((m) => { + delete m.bridgeMtimeMs; + }); + const meta = await readBridgeMeta(groupDir); + expect(meta.bridgeSize).toBeTypeOf('number'); + expect(meta.bridgeMtimeMs).toBeUndefined(); + await expect(bridgeMetaMatchesFile(groupDir, meta)).resolves.toBe(false); + }); + + it('rejects metadata carrying an mtime but no size', async () => { + await seedStamped(); + await rewriteMeta((m) => { + delete m.bridgeSize; + }); + const meta = await readBridgeMeta(groupDir); + expect(meta.bridgeMtimeMs).toBeTypeOf('number'); + expect(meta.bridgeSize).toBeUndefined(); + await expect(bridgeMetaMatchesFile(groupDir, meta)).resolves.toBe(false); + }); + + it('still accepts metadata carrying neither half, which is the legacy shape', async () => { + await seedStamped(); + await rewriteMeta((m) => { + delete m.bridgeSize; + delete m.bridgeMtimeMs; + }); + const meta = await readBridgeMeta(groupDir); + await expect(bridgeMetaMatchesFile(groupDir, meta)).resolves.toBe(true); + }); + + it('control: a fully stamped pair written together still matches', async () => { + await seedStamped(); + const meta = await readBridgeMeta(groupDir); + await expect(bridgeMetaMatchesFile(groupDir, meta)).resolves.toBe(true); + }); +}); + +describe('bridgeMetaMatchesFile pairs an unstamped meta by write order', () => { + /** + * A metadata file with no stamp cannot answer "is this the database I was + * written for?" from its own contents. It is not silent, though: a successful + * `writeBridge` renames the database into place and THEN writes the metadata, + * so `meta.mtime >= db.mtime` holds for every pair written together — + * including pairs written by builds that predate stamping, which is the whole + * reason those are not simply failed closed. + * + * A database strictly NEWER than the metadata beside it inverts that order, + * and the only way to reach it is a swap whose metadata write did not land. + * + * This is a heuristic on write order, not proof of provenance, so these cases + * set both timestamps explicitly with `fsp.utimes`. Nothing here sleeps and + * nothing waits for a filesystem to tick: the separation is written, not + * hoped for, so the same verdict comes back on a 1-second-granularity + * filesystem as on a nanosecond one. + */ + let groupDir: string; + + /** Fixed, whole-second instants — exactly representable on any filesystem. */ + const WRITTEN_AT = new Date('2026-01-01T00:00:00.000Z'); + const TEN_SECONDS_LATER = new Date('2026-01-01T00:00:10.000Z'); + + beforeEach(async () => { + renameMock.mode = 'none'; + groupDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-bridge-unstamped-')); + }); + + afterEach(async () => { + renameMock.mode = 'none'; + await closeAllCachedBridges(); + await fsp.rm(groupDir, { recursive: true, force: true }); + }); + + /** + * Produce the legacy shape from a real bridge: a database written by + * `writeBridge` with metadata beside it that carries no stamp, exactly as a + * build from before stamping left it. + */ + const seedUnstamped = async (): Promise => { + await writeBridge(groupDir, input([])); + const metaPath = path.join(groupDir, 'meta.json'); + const raw = JSON.parse(await fsp.readFile(metaPath, 'utf-8')) as Record; + delete raw.bridgeSize; + delete raw.bridgeMtimeMs; + await fsp.writeFile(metaPath, JSON.stringify(raw, null, 2)); + }; + + const setMtimes = async (db: Date | null, meta: Date | null): Promise => { + if (db) await fsp.utimes(path.join(groupDir, 'bridge.lbug'), db, db); + if (meta) await fsp.utimes(path.join(groupDir, 'meta.json'), meta, meta); + }; + + it('accepts an unstamped pair whose two files share a timestamp', async () => { + // The coarse-filesystem case: both writes land in the same tick, so the + // order they happened in is no longer visible. Equality is the pair being + // written together as far as anything can tell, and rejecting it would fail + // every legacy bridge on a 1-second-granularity filesystem. + await seedUnstamped(); + await setMtimes(WRITTEN_AT, WRITTEN_AT); + const meta = await readBridgeMeta(groupDir); + + expect(meta.bridgeSize).toBeUndefined(); + expect(meta.bridgeMtimeMs).toBeUndefined(); + await expect(bridgeMetaMatchesFile(groupDir, meta)).resolves.toBe(true); + }); + + it('accepts an unstamped meta written after the database it sits beside', async () => { + await seedUnstamped(); + await setMtimes(WRITTEN_AT, TEN_SECONDS_LATER); + const meta = await readBridgeMeta(groupDir); + + await expect(bridgeMetaMatchesFile(groupDir, meta)).resolves.toBe(true); + }); + + it('rejects an unstamped meta when the database was replaced underneath it', async () => { + // The window this branch exists for. A sync that swapped the database and + // stopped before writing metadata leaves the PREVIOUS sync's completeness + // beside a database it never measured — and `runGroupImpact` spends that as + // fact. With no stamp to check, the inverted write order is the only thing + // that says so, and it says so unambiguously. + await seedUnstamped(); + await setMtimes(TEN_SECONDS_LATER, WRITTEN_AT); + const meta = await readBridgeMeta(groupDir); + + await expect(bridgeMetaMatchesFile(groupDir, meta)).resolves.toBe(false); + }); + + it('rejects an unstamped meta when there is no database beside it at all', async () => { + // Metadata describing a file that is not there describes nothing. The + // stamped path already answers `false` here; the unstamped path must not + // answer `true` just because it had no stamp to compare. + await seedUnstamped(); + await fsp.rm(path.join(groupDir, 'bridge.lbug'), { force: true }); + const meta = await readBridgeMeta(groupDir); + + await expect(bridgeMetaMatchesFile(groupDir, meta)).resolves.toBe(false); + }); + + it('keeps following the stamp when a stamped pair has its file times skewed against it', async () => { + // The ordering guard, acceptance direction. A stamped meta whose FILE is + // older than the database still matches, because the stamp inside it says + // so and the stamp is the stronger evidence. Only the metadata file's time + // is moved — touching the database would invalidate the stamp itself and + // make this measure the wrong thing. + await writeBridge(groupDir, input([])); + const dbStat = await fsp.stat(path.join(groupDir, 'bridge.lbug')); + await setMtimes(null, new Date(dbStat.mtimeMs - 10_000)); + const meta = await readBridgeMeta(groupDir); + + expect(meta.bridgeSize).toBeTypeOf('number'); + await expect(bridgeMetaMatchesFile(groupDir, meta)).resolves.toBe(true); + }); + + it('keeps following the stamp when a stale stamped meta has the newer file time', async () => { + // The ordering guard, rejection direction. The mtime heuristic must not be + // reachable as a second chance for a stamp that already failed: this pair + // has the write order a paired write produces and a stamp that says the + // database is not the one it describes. + await writeBridge(groupDir, input([])); + const dbPath = path.join(groupDir, 'bridge.lbug'); + const bytes = await fsp.readFile(dbPath); + await fsp.writeFile(dbPath, Buffer.concat([bytes, Buffer.from([0])])); + const dbStat = await fsp.stat(dbPath); + await setMtimes(null, new Date(dbStat.mtimeMs + 10_000)); + const meta = await readBridgeMeta(groupDir); + + await expect(bridgeMetaMatchesFile(groupDir, meta)).resolves.toBe(false); + }); +}); + +describe('bridgeMetaMatchesFile reads an explicit provenance marker first', () => { + /** + * The strongest evidence a metadata file can carry about which database it + * describes is a statement from the writer that it does NOT describe the one + * beside it. `bridgeMetaMatchesFile` orders its checks by evidence strength, + * and this one outranks both of the others — its doc has said so since the + * stamp landed; these cases make it true. + * + * The marker exists because the preserve path in `syncGroup` refreshes + * `meta.json` without touching `bridge.lbug`. That rewrite is atomic, so the + * metadata's mtime becomes now while the database's stays old — the write + * order a paired write produces, and the shape the unstamped rule ACCEPTS. + * Writing "no stamp" instead of a marker would therefore let a preserve sync + * convert a pair the rule had been rejecting into one it waves through. + */ + let groupDir: string; + + beforeEach(async () => { + renameMock.mode = 'none'; + groupDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-bridge-marker-')); + }); + + afterEach(async () => { + renameMock.mode = 'none'; + await closeAllCachedBridges(); + await fsp.rm(groupDir, { recursive: true, force: true }); + }); + + it('rejects a marked pair whose stamp matches the database beside it', async () => { + await writeBridge(groupDir, input([])); + const meta = await readBridgeMeta(groupDir); + + // The control: this exact pair is otherwise verified by the stamp. + await expect(bridgeMetaMatchesFile(groupDir, meta)).resolves.toBe(true); + + await expect( + bridgeMetaMatchesFile(groupDir, { ...meta, provenanceUnknown: true }), + ).resolves.toBe(false); + }); + + it('rejects a marked pair whose write order says it is paired', async () => { + // The unstamped branch, and the one the preserve path actually reaches: an + // atomic metadata rewrite always leaves `meta.mtime >= db.mtime`, so the + // heuristic has nothing left to object to and the marker is the only + // surviving record of the verdict. + await writeBridge(groupDir, input([])); + const meta = await readBridgeMeta(groupDir); + const legacy = { ...meta }; + delete legacy.bridgeSize; + delete legacy.bridgeMtimeMs; + + await expect(bridgeMetaMatchesFile(groupDir, legacy)).resolves.toBe(true); + + await expect( + bridgeMetaMatchesFile(groupDir, { ...legacy, provenanceUnknown: true }), + ).resolves.toBe(false); + }); + + it('rejects a marked metadata file even when the database is gone', async () => { + // Nothing about the files can overturn the marker, including the absence of + // the file the stamp branch would have stat'd. + await writeBridge(groupDir, input([])); + const meta = await readBridgeMeta(groupDir); + await fsp.rm(path.join(groupDir, 'bridge.lbug'), { force: true }); + + await expect( + bridgeMetaMatchesFile(groupDir, { ...meta, provenanceUnknown: true }), + ).resolves.toBe(false); + }); +}); + +describe('readBridgeMeta normalizes a version that is not a version', () => { + let groupDir: string; + + beforeEach(async () => { + renameMock.mode = 'none'; + groupDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-bridge-version-')); + }); + + afterEach(async () => { + renameMock.mode = 'none'; + await fsp.rm(groupDir, { recursive: true, force: true }); + }); + + /** + * `0` is this file's word for "no provenance", and every gate is written + * against it. A parseable but impossible version — negative, fractional, + * NaN-adjacent — is not a schema version, and if it survives the read it + * splits the gates apart: the two openers compare `> 0 && !== CURRENT` and + * let it through, `bridgeExists` compares `=== 0 || === CURRENT` and says the + * bridge is not there, and the provenance check compares `=== 0` and calls + * the answer complete. Four gates, four verdicts, one file. + * + * Normalizing at the reader is what keeps them agreeing, rather than teaching + * each gate the same new case. + */ + const seedVersion = async (version: unknown): Promise => { + await fsp.writeFile(path.join(groupDir, 'bridge.lbug'), 'db'); + await fsp.writeFile( + path.join(groupDir, 'meta.json'), + JSON.stringify({ version, generatedAt: '', missingRepos: [] }), + ); + }; + + it.each([ + ['negative', -1], + ['fractional', 1.5], + // JSON cannot carry Infinity — it serializes to `null`, so this one is + // caught by the pre-existing type check rather than by the range check. + // Kept because it is a shape a hand-edited file can still present. + ['infinite', Number.POSITIVE_INFINITY], + ])('reads a %s version as no provenance rather than as a schema version', async (_label, v) => { + await seedVersion(v); + const meta = await readBridgeMeta(groupDir); + expect(meta.version).toBe(0); + }); + + it('control: the current schema version is preserved exactly', async () => { + await seedVersion(BRIDGE_SCHEMA_VERSION); + const meta = await readBridgeMeta(groupDir); + expect(meta.version).toBe(BRIDGE_SCHEMA_VERSION); + }); +}); diff --git a/gitnexus/test/unit/group/bridge-pairing-precedes-open.test.ts b/gitnexus/test/unit/group/bridge-pairing-precedes-open.test.ts new file mode 100644 index 000000000..5bde914b5 --- /dev/null +++ b/gitnexus/test/unit/group/bridge-pairing-precedes-open.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fsp from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +/** + * The pairing verdict must be measured BEFORE anything opens `bridge.lbug`. + * + * An unstamped metadata file is paired to its database by write order, so the + * answer depends on `bridge.lbug`'s mtime. Cross-repo impact and trace both open + * that database and only afterwards ask about provenance — so on any platform + * or LadybugDB build where a read-only open advances the file's mtime, every + * pre-stamp bridge would report provenance-unknown from its first query onward. + * That is the repo-wide regression the write-order rule was chosen to avoid, and + * it would arrive as a silent downgrade rather than an error. + * + * Whether a given OS does that is not observable everywhere: pinning it by + * really opening the database needs an in-process write→read reopen of the same + * `bridge.lbug`, which is a documented Windows limitation. A test skipped on + * Windows would leave the property unverified on exactly the platform whose + * file semantics are most likely to differ. + * + * So this asserts the ordering instead of the platform's behavior. The open is + * stubbed to advance the database's mtime — the hostile case, forced, on every + * platform. If the verdict is taken before the open it is unaffected; if anyone + * moves it after, this goes red on Linux, macOS and Windows alike. + */ +const openSpy = vi.fn(); + +vi.mock('../../../src/core/group/bridge-db.js', async () => { + const actual = await vi.importActual( + '../../../src/core/group/bridge-db.js', + ); + return { + ...actual, + getCachedBridgeReadOnly: async (groupDir: string) => { + openSpy(); + // Simulate an open that touches the database. Ten seconds ahead of the + // metadata beside it, which under the write-order rule reads as "this + // database is newer than the metadata describing it" — unpaired. + const dbPath = path.join(groupDir, 'bridge.lbug'); + const future = new Date(Date.now() + 10_000); + await fsp.utimes(dbPath, future, future); + return { conn: {}, db: {} } as unknown as Awaited< + ReturnType + >; + }, + }; +}); + +const { ensureBridgeReady } = await import('../../../src/core/group/cross-impact.js'); +const { BRIDGE_SCHEMA_VERSION } = await import('../../../src/core/group/bridge-schema.js'); + +describe('the bridge pairing verdict is taken before the database is opened', () => { + let groupDir: string; + + beforeEach(async () => { + openSpy.mockClear(); + groupDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-pair-order-')); + }); + + afterEach(async () => { + await fsp.rm(groupDir, { recursive: true, force: true }); + }); + + /** A legacy pair: unstamped metadata written after its database, as a real sync leaves it. */ + const seedUnstampedPair = async (): Promise => { + const base = new Date(1_700_000_000_000); + await fsp.writeFile(path.join(groupDir, 'bridge.lbug'), 'db'); + await fsp.writeFile( + path.join(groupDir, 'meta.json'), + JSON.stringify({ + version: BRIDGE_SCHEMA_VERSION, + generatedAt: '', + missingRepos: [], + }), + ); + await fsp.utimes(path.join(groupDir, 'bridge.lbug'), base, base); + await fsp.utimes(path.join(groupDir, 'meta.json'), base, base); + }; + + it('reports an unstamped pair as paired even when the open advances the database mtime', async () => { + await seedUnstampedPair(); + + const prep = await ensureBridgeReady(groupDir); + + expect('error' in prep).toBe(false); + expect(openSpy).toHaveBeenCalledTimes(1); + if ('error' in prep) throw new Error(prep.error); + // Measured before the open, so the open's mtime bump cannot reach it. + expect(prep.meta.pairedWithDatabase).toBe(true); + }); + + it('still reports a genuinely unpaired legacy bridge as unpaired', async () => { + // The control. If the verdict were hardcoded or dropped, this would pass + // vacuously alongside the case above. + await seedUnstampedPair(); + const newer = new Date(1_700_000_060_000); + await fsp.utimes(path.join(groupDir, 'bridge.lbug'), newer, newer); + + const prep = await ensureBridgeReady(groupDir); + + expect('error' in prep).toBe(false); + if ('error' in prep) throw new Error(prep.error); + expect(prep.meta.pairedWithDatabase).toBe(false); + }); +}); diff --git a/gitnexus/test/unit/group/cross-impact-fanout-cap.test.ts b/gitnexus/test/unit/group/cross-impact-fanout-cap.test.ts index 39aa23bbb..aac1ba794 100644 --- a/gitnexus/test/unit/group/cross-impact-fanout-cap.test.ts +++ b/gitnexus/test/unit/group/cross-impact-fanout-cap.test.ts @@ -114,6 +114,15 @@ describe('group impact fan-out is bounded by a count, not by the clock (#2787)', ...Array.from({ length: REPO_COUNT }, (_, i) => repoKey(i)), ]); await fsp.writeFile(path.join(groupDir, 'bridge.lbug'), ''); + // The metadata this suite's `readBridgeMeta` mock hands back has to exist on + // disk as well as in the mock. It carries no size/mtime stamp, so + // `bridgeMetaMatchesFile` pairs it to the database by write order — and a + // metadata file that is not there cannot be paired to anything. Written + // AFTER `bridge.lbug`, which is the order a real sync produces. + await fsp.writeFile( + path.join(groupDir, 'meta.json'), + JSON.stringify({ version: 1, generatedAt: '', missingRepos: [] }), + ); }); afterEach(async () => { diff --git a/gitnexus/test/unit/group/cross-impact-incomplete-bridge.test.ts b/gitnexus/test/unit/group/cross-impact-incomplete-bridge.test.ts new file mode 100644 index 000000000..f863d91ac --- /dev/null +++ b/gitnexus/test/unit/group/cross-impact-incomplete-bridge.test.ts @@ -0,0 +1,449 @@ +/** + * A bridge built by a sync that could not account for every configured repo is + * MISSING crossings, not free of them. Those repos' contracts — and every + * cross-link touching them — never made it into `bridge.lbug`, and nothing in + * the impact walk can notice: the only incompleteness channel on a + * `GroupImpactResult` is `truncationFields(...)`, and that is driven purely by + * fan-out state. + * + * The failure this file pins: `group impact` on a symbol whose one downstream + * consumer lives in an unreadable repo returned `{ cross: [], truncated: false }` + * — "complete: nothing depends on this". That is a wrong answer, not an empty + * one, for the tool an agent uses to license a delete or a rename. + * + * `readBridgeMeta` is deliberately NOT stubbed here: the `meta.json` each case + * writes is the input under test, so it has to travel the real read. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fsp from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import type { BridgeHandle, BridgeMeta } from '../../../src/core/group/types.js'; +import type { GroupToolPort } from '../../../src/core/group/service.js'; +import { BRIDGE_SCHEMA_VERSION } from '../../../src/core/group/bridge-schema.js'; +import { makeGroupToolPort, writeGroupYaml } from './fixtures.js'; + +const bridgeHandle = { + _db: {}, + _conn: {}, + groupDir: '', + _readOnly: true, +} as BridgeHandle; + +const bridgeRows = vi.hoisted(() => ({ + value: [] as Array>, +})); + +vi.mock('../../../src/core/group/bridge-db.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getCachedBridgeReadOnly: vi.fn(async () => bridgeHandle), + queryBridge: vi.fn(async () => bridgeRows.value), + closeBridgeDb: vi.fn(async () => undefined), + }; +}); + +const { runGroupImpact } = await import('../../../src/core/group/cross-impact.js'); +const { writeBridgeMeta, closeBridgeDb } = await import('../../../src/core/group/bridge-db.js'); + +const UNREADABLE_REPO = 'svc/users'; +const MISSING_REPO = 'svc/billing'; + +/** A crossing the fan-out will try to traverse. */ +const crossingRow = { + neighborRepo: 'svc/orders', + neighborUid: 'Function:src/handler.ts:handle', + neighborFilePath: 'src/handler.ts', + matchType: 'exact', + confidence: 1, + contractId: 'custom::c000', + contractType: 'custom', +}; + +type ImpactShape = { + truncated: boolean; + truncationReason?: string; + riskEpistemic?: string; + truncatedRepos: string[]; + cross: unknown[]; +}; + +/** No `?? []` fallback on purpose: an `{ error }` result must blow up here. */ +const shapeOf = (result: unknown): ImpactShape => result as ImpactShape; + +const sortedRepos = (result: unknown): string[] => [...shapeOf(result).truncatedRepos].sort(); + +/** A port whose only defect is that one neighbour repo fails to resolve. */ +const portWithUnresolvableNeighbour = (home: string, neighbourRepo: string): GroupToolPort => + makeGroupToolPort(home, { + resolveRepo: vi.fn(async (name: string) => { + if (name === `${neighbourRepo}-registry`) throw new Error('repo not registered'); + return { id: name, name, repoPath: name, storagePath: path.join(home, name) }; + }) as GroupToolPort['resolveRepo'], + }); + +describe('group impact over a bridge built from an incomplete sync', () => { + let home: string; + let groupDir: string; + + beforeEach(async () => { + home = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-incomplete-bridge-')); + groupDir = path.join(home, 'groups', 'waveful'); + await writeGroupYaml(groupDir, ['backend', 'svc/orders', UNREADABLE_REPO, MISSING_REPO]); + await fsp.writeFile(path.join(groupDir, 'bridge.lbug'), ''); + bridgeRows.value = []; + }); + + afterEach(async () => { + await fsp.rm(home, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + const writeMeta = (meta: Omit): Promise => + writeBridgeMeta(groupDir, { + version: BRIDGE_SCHEMA_VERSION, + generatedAt: '2026-01-01T00:00:00.000Z', + ...meta, + }); + + /** + * meta.json exactly as given. `writeBridgeMeta` is typed, and the values + * these cases are about are ones `BridgeMeta` forbids — which is precisely + * why nothing on the read path was checking for them: a truncated write, a + * hand-edit, or a foreign writer can still leave them on disk. + */ + const writeRawMeta = (fields: Record): Promise => + fsp.writeFile( + path.join(groupDir, 'meta.json'), + JSON.stringify({ + version: BRIDGE_SCHEMA_VERSION, + generatedAt: '2026-01-01T00:00:00.000Z', + ...fields, + }), + ); + + const run = (port: GroupToolPort, extraParams: Record = {}) => + runGroupImpact( + { port, gitnexusDir: home }, + { + name: 'waveful', + repo: 'backend', + target: 'publish', + direction: 'upstream', + ...extraParams, + }, + ); + + it('reports a repo the sync could not read as truncation, not as a clean empty result', async () => { + // The headline case. Every other signal here says "complete": the local + // walk finished, the bridge returned no crossings, no cap and no clock + // fired. `unreadableRepos` in meta.json is the ONLY evidence that the + // empty `cross` is a lower bound rather than a verdict. + await writeMeta({ missingRepos: [], unreadableRepos: [UNREADABLE_REPO] }); + + const result = await run(makeGroupToolPort(home)); + + expect(result).toMatchObject({ + cross: [], + truncated: true, + truncationReason: 'incomplete-sync', + riskEpistemic: 'lower-bound', + }); + expect(sortedRepos(result)).toEqual([UNREADABLE_REPO]); + }); + + it('treats a repo with no registry entry the same way', async () => { + // A MISSING repo is equally absent from the bridge — the sync had nothing + // to extract from it, so its contracts are gone from every query against + // this bridge for exactly the same reason. + await writeMeta({ missingRepos: [MISSING_REPO] }); + + const result = await run(makeGroupToolPort(home)); + + expect(result).toMatchObject({ + truncated: true, + truncationReason: 'incomplete-sync', + riskEpistemic: 'lower-bound', + }); + expect(sortedRepos(result)).toEqual([MISSING_REPO]); + }); + + it('names each incomplete repo once when a repo is both unreadable and missing', async () => { + // The two lists are independent diagnostics and can overlap. A caller + // reading `truncatedRepos` as "the repos I could not see" must not be + // handed the same one twice. + await writeMeta({ missingRepos: [MISSING_REPO], unreadableRepos: [MISSING_REPO] }); + + const result = await run(makeGroupToolPort(home)); + + expect(sortedRepos(result)).toEqual([MISSING_REPO]); + }); + + it('claims no floor when the bridge is complete and the walk finished', async () => { + // The control that gives the cases above their meaning: a clean bridge and + // a clean walk must still produce a result with NO truncation shape at all, + // or `incomplete-sync` would just be the new name for every answer. + await writeMeta({ missingRepos: [], unreadableRepos: [] }); + + const result = await run(makeGroupToolPort(home)); + + expect(result).toMatchObject({ truncated: false, truncatedRepos: [] }); + expect(result).not.toHaveProperty('truncationReason'); + expect(result).not.toHaveProperty('riskEpistemic'); + }); + + it('reports a bridge with no meta.json at all as a floor, not as complete', async () => { + // `writeBridge` swaps the database file and writes meta.json as two steps, + // so a sync interrupted between them leaves a NEW bridge with NO metadata. + // `readBridgeMeta` answers `version: 0` for that (and for an unparseable + // one), which carries no repo lists — so reading it as "complete" would + // hand back a confident `{ cross: [], truncated: false }` about a bridge + // whose provenance is unknown. That is the fail-open this channel exists + // to close, arriving through the door the write path leaves open. + await fsp.rm(path.join(groupDir, 'meta.json'), { force: true }); + + const result = await run(makeGroupToolPort(home)); + + expect(result).toMatchObject({ + truncated: true, + truncationReason: 'incomplete-sync', + riskEpistemic: 'lower-bound', + }); + }); + + it('reports an unparseable meta.json as a floor too', async () => { + await fsp.writeFile(path.join(groupDir, 'meta.json'), '{"version": '); + + const result = await run(makeGroupToolPort(home)); + + expect(result).toMatchObject({ truncated: true, truncationReason: 'incomplete-sync' }); + }); + + it('answers a lower bound when the missing-repo list is an object, instead of throwing', async () => { + // `runGroupImpact` spread both repo lists straight into a `new Set([...])`. + // A non-iterable value there is a TypeError thrown out of the whole query — + // an operator asking about their blast radius gets a stack trace instead of + // the honest "this bridge's provenance is unreadable, treat the answer as a + // floor" that the very same metadata already licenses. + await writeRawMeta({ missingRepos: { 'svc/users': true } }); + + const result = await run(makeGroupToolPort(home)); + + expect(result).toMatchObject({ + truncated: true, + truncationReason: 'incomplete-sync', + riskEpistemic: 'lower-bound', + }); + // Nothing was measured, so nothing is named. The reason field carries the + // signal; inventing repo names out of an unreadable value would not. + expect(shapeOf(result).truncatedRepos).toEqual([]); + }); + + it('answers a lower bound when the unreadable-repo list is a number', async () => { + // The other list, and a non-iterable of a different kind — a scalar reaches + // the same spread. `missingRepos` here IS well formed and measured empty, + // which is what makes this case about the second list alone. + await writeRawMeta({ missingRepos: [], unreadableRepos: 3 }); + + const result = await run(makeGroupToolPort(home)); + + expect(result).toMatchObject({ + truncated: true, + truncationReason: 'incomplete-sync', + riskEpistemic: 'lower-bound', + }); + expect(shapeOf(result).truncatedRepos).toEqual([]); + }); + + it('does not report the entries of a list that is not a list of repo paths', async () => { + // `Array.isArray` alone would pass this: it is an array, and it is even + // partly right. But `truncatedRepos` is printed by `cli/group.ts` with + // `.join(', ')`, so the object entry surfaces to an operator as + // `[object Object]` — a repo name that does not exist, presented as a + // measurement. A value we cannot read is not a value we half-report. + await writeRawMeta({ missingRepos: [MISSING_REPO, { repo: UNREADABLE_REPO }] }); + + const result = await run(makeGroupToolPort(home)); + + expect(result).toMatchObject({ + truncated: true, + truncationReason: 'incomplete-sync', + riskEpistemic: 'lower-bound', + }); + expect(shapeOf(result).truncatedRepos).toEqual([]); + }); + + it('releases the bridge handle on a malformed meta.json, and answers the next query normally', async () => { + // The throw happened AFTER the read-only bridge lease was taken and BEFORE + // the `try` whose `finally` releases it, so every malformed-metadata query + // burned a refcount that is never given back — the cached handle can then + // never be closed or invalidated, and `group sync` cannot swap the database + // underneath it on Windows. Releasing is not a detail of the fix: it is why + // a second query on the same group still gets an answer. + vi.mocked(closeBridgeDb).mockClear(); + await writeRawMeta({ missingRepos: {} }); + + await run(makeGroupToolPort(home)); + + expect(vi.mocked(closeBridgeDb).mock.calls.length).toBe(1); + + await writeMeta({ missingRepos: [], unreadableRepos: [] }); + const second = await run(makeGroupToolPort(home)); + + expect(second).toMatchObject({ truncated: false, truncatedRepos: [] }); + expect(vi.mocked(closeBridgeDb).mock.calls.length).toBe(2); + }); + + it('reports a meta.json that is not an object at all as a floor, not as a crash', async () => { + // `JSON.parse('null')` succeeds, so the parse guard never fires and the + // cast hands `null` to a `.version` read. Same class as the two lists: a + // successfully-parsed file whose SHAPE is not metadata. + await fsp.writeFile(path.join(groupDir, 'meta.json'), 'null'); + + const result = await run(makeGroupToolPort(home)); + + expect(result).toMatchObject({ truncated: true, truncationReason: 'incomplete-sync' }); + }); + + it('does not read a meta.json written before the field existed as incomplete', async () => { + // Back-compat: `unreadableRepos` is optional, and a bridge written by an + // older build simply does not record it. Absence must not be read as "some + // repo was unreadable" — that would mark every pre-existing bridge as a + // lower bound and make the marker meaningless. + await writeMeta({ missingRepos: [] }); + const onDisk: unknown = JSON.parse( + await fsp.readFile(path.join(groupDir, 'meta.json'), 'utf-8'), + ); + + const result = await run(makeGroupToolPort(home)); + + expect(onDisk).not.toHaveProperty('unreadableRepos'); + expect(result).toMatchObject({ truncated: false, truncatedRepos: [] }); + expect(result).not.toHaveProperty('truncationReason'); + }); + + it('keeps reporting timeout when the fan-out clock fired and the bridge is also incomplete', async () => { + // Both causes at once. `timeout` is the retryable one — the same query can + // succeed on the next run — while `incomplete-sync` needs a different + // remedy (`gitnexus group sync`). The caller is told the cause it can act + // on first, and the unreadable repo still shows up in `truncatedRepos`. + // A never-resolving `impactByUid` makes the budget timer the only thing + // that can settle the race, so this branch is taken on every host; nothing + // here measures elapsed time. + await writeMeta({ missingRepos: [], unreadableRepos: [UNREADABLE_REPO] }); + bridgeRows.value = [crossingRow]; + const port = makeGroupToolPort(home, { + impactByUid: vi.fn(() => new Promise(() => {})) as GroupToolPort['impactByUid'], + }); + + const result = await run(port, { timeoutMs: 200 }); + + expect(result).toMatchObject({ + truncated: true, + truncationReason: 'timeout', + riskEpistemic: 'lower-bound', + }); + expect(sortedRepos(result)).toEqual([crossingRow.neighborRepo, UNREADABLE_REPO].sort()); + }); + + it('keeps reporting partial when the fan-out cut a crossing and the bridge is also incomplete', async () => { + // Same precedence rule for the other runtime limit: a crossing that could + // not be traversed (its repo does not resolve) is `partial`, and the + // structural cause does not get to overwrite it. + await writeMeta({ missingRepos: [], unreadableRepos: [UNREADABLE_REPO] }); + bridgeRows.value = [crossingRow]; + const port = portWithUnresolvableNeighbour(home, crossingRow.neighborRepo); + + const result = await run(port); + + expect(result).toMatchObject({ truncated: true, truncationReason: 'partial' }); + expect(sortedRepos(result)).toEqual([crossingRow.neighborRepo, UNREADABLE_REPO].sort()); + }); + + /** + * The declared scope of a group-impact query is its subgroup prefix (plus + * the repo the walk starts from), and the incomplete-repo set has to be read + * through it. A subgroup-scoped query already drops every neighbour outside + * the prefix, so an unreadable repo it excluded could not have contributed a + * crossing to THIS answer — reporting it as a floor anyway marks a complete + * result incomplete, and a marker that fires on answers it does not describe + * is a marker an agent learns to ignore. + */ + describe("narrowed to the query's declared scope", () => { + it('answers complete when the declared subgroup excludes the unreadable repo', async () => { + // The scoped twin of the headline case: same bridge, same metadata, but + // the query asks only about `svc/orders`, and `svc/users` is not in it. + await writeMeta({ missingRepos: [], unreadableRepos: [UNREADABLE_REPO] }); + + const result = await run(makeGroupToolPort(home), { subgroup: 'svc/orders' }); + + expect(result).toMatchObject({ truncated: false, truncatedRepos: [] }); + expect(result).not.toHaveProperty('truncationReason'); + expect(result).not.toHaveProperty('riskEpistemic'); + }); + + it('still answers a lower bound for the same query with no subgroup', async () => { + // The control that keeps the case above honest: drop the scope and the + // very same bridge must go back to reporting the floor. An unscoped query + // declares the whole group, so the intersection is the whole set. + await writeMeta({ missingRepos: [], unreadableRepos: [UNREADABLE_REPO] }); + + const result = await run(makeGroupToolPort(home)); + + expect(result).toMatchObject({ + truncated: true, + truncationReason: 'incomplete-sync', + riskEpistemic: 'lower-bound', + }); + expect(sortedRepos(result)).toEqual([UNREADABLE_REPO]); + }); + + it('keeps the lower bound when the declared subgroup contains the unreadable repo', async () => { + // `svc` is a prefix of `svc/users`, so the repo IS declared here and the + // answer is still a floor. The filter narrows by membership, not by + // exact equality — a subgroup that spans the unreadable repo gains + // nothing from the scope. + await writeMeta({ missingRepos: [], unreadableRepos: [UNREADABLE_REPO] }); + + const result = await run(makeGroupToolPort(home), { subgroup: 'svc' }); + + expect(result).toMatchObject({ + truncated: true, + truncationReason: 'incomplete-sync', + riskEpistemic: 'lower-bound', + }); + expect(sortedRepos(result)).toEqual([UNREADABLE_REPO]); + }); + + it('names only the declared repos when some incomplete repos are out of scope', async () => { + // Two incomplete repos, one inside the declared scope and one outside. + // `truncatedRepos` is what an operator reads as "the repos I could not + // see for this question", so naming a repo the question excluded is a + // wrong answer in the same way marking the result incomplete is. + await writeMeta({ missingRepos: [MISSING_REPO], unreadableRepos: [UNREADABLE_REPO] }); + + const result = await run(makeGroupToolPort(home), { subgroup: UNREADABLE_REPO }); + + expect(result).toMatchObject({ truncated: true, truncationReason: 'incomplete-sync' }); + expect(sortedRepos(result)).toEqual([UNREADABLE_REPO]); + }); + + it("keeps the lower bound when the unreadable repo is the query's own repo", async () => { + // The walk starts from `backend`'s contracts in the bridge, so when + // `backend` is the repo the sync could not read there are no crossings to + // find at all — for any scope. A subgroup that excludes the origin repo + // must not turn that vacuum into a confident "nothing depends on this". + await writeMeta({ missingRepos: [], unreadableRepos: ['backend'] }); + + const result = await run(makeGroupToolPort(home), { subgroup: 'svc/orders' }); + + expect(result).toMatchObject({ + truncated: true, + truncationReason: 'incomplete-sync', + riskEpistemic: 'lower-bound', + }); + expect(sortedRepos(result)).toEqual(['backend']); + }); + }); +}); diff --git a/gitnexus/test/unit/group/cross-trace-incomplete-bridge.test.ts b/gitnexus/test/unit/group/cross-trace-incomplete-bridge.test.ts new file mode 100644 index 000000000..e2ab8f2e0 --- /dev/null +++ b/gitnexus/test/unit/group/cross-trace-incomplete-bridge.test.ts @@ -0,0 +1,305 @@ +/** + * A cross-repo TRACE reads the same bridge `group impact` reads, and inherits + * the same failure: a bridge built by a sync that could not account for every + * configured repo is MISSING crossings, not free of them. `stitchCrossRepo` + * answered `status: 'not_found'` — "no ContractLink connects these endpoints" — + * for a bridge that never held the endpoint repo's contracts at all, and the + * only difference between that answer and an authoritative one was prose in + * `notes`. + * + * What this file pins is the MACHINE-readable difference: the same structured + * triple `truncated` / `truncationReason` / `riskEpistemic` that + * `GroupImpactResult` carries, computed for the trace by the SAME helper + * (`crossRepoCompleteness`), so an agent reading either surface learns + * "complete" vs "floor" from one vocabulary instead of from two note strings. + * + * The other half is scope: the incomplete-repo set is filtered by what the + * QUERY declared, not by what the walk happened to touch. A trace between two + * healthy repos is not a lower bound because some third repo in the group was + * unreadable — but a DESTINATION trace, which declares no `to` at all, has + * every repo in scope by construction. + * + * `readBridgeMeta` is deliberately NOT stubbed: the `meta.json` each case + * writes is the input under test, so it has to travel the real read. Only the + * bridge DATABASE is mocked, which is what keeps every case here running + * identically on every platform — nothing reopens an lbug file. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fsp from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import type { BridgeHandle, BridgeMeta } from '../../../src/core/group/types.js'; +import type { GroupSymbolResolution, GroupToolPort } from '../../../src/core/group/service.js'; +import { BRIDGE_SCHEMA_VERSION } from '../../../src/core/group/bridge-schema.js'; +import { makeGroupToolPort, writeGroupYaml } from './fixtures.js'; + +const bridgeHandle = { + _db: {}, + _conn: {}, + groupDir: '', + _readOnly: true, +} as BridgeHandle; + +const bridgeRows = vi.hoisted(() => ({ + value: [] as Array>, +})); + +vi.mock('../../../src/core/group/bridge-db.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getCachedBridgeReadOnly: vi.fn(async () => bridgeHandle), + queryBridge: vi.fn(async () => bridgeRows.value), + closeBridgeDb: vi.fn(async () => undefined), + }; +}); + +const { runGroupTrace } = await import('../../../src/core/group/cross-trace.js'); +const { writeBridgeMeta } = await import('../../../src/core/group/bridge-db.js'); + +const FROM_REPO = 'app/frontend'; +const TO_REPO = 'app/backend'; +/** A third member, on no traced path — the scope filter's whole subject. */ +const OFF_PATH_REPO = 'svc/users'; + +const FROM_UID = 'fe::callUsers'; +const TO_UID = 'be::getUsers'; + +const okSym = (id: string, name: string, filePath: string): GroupSymbolResolution => ({ + kind: 'ok', + symbol: { id, name, type: 'Function', filePath, startLine: 10, endLine: 14 }, +}); + +/** Keyed on `:` — if-free dispatch, no branching. */ +const SYMBOLS: Record = { + [`${FROM_REPO}-registry:callUsers`]: okSym(FROM_UID, 'callUsers', 'src/api.ts'), + [`${TO_REPO}-registry:getUsers`]: okSym(TO_UID, 'getUsers', 'src/routes.ts'), +}; + +const okTrace = (name: string, filePath: string): unknown => ({ + status: 'ok', + from: { name, filePath, startLine: 10 }, + to: { name, filePath, startLine: 10 }, + hopCount: 1, + hops: [{ name, filePath, startLine: 10 }], + edges: [{ relType: 'CALLS', confidence: 1 }], +}); + +/** Both segments of the one crossing connect — the successful-trace cases. */ +const CONNECTING_SEGMENTS: Record = { + [`${FROM_REPO}-registry:${FROM_UID}->consumer-uid`]: okTrace('callUsers', 'src/api.ts'), + [`${TO_REPO}-registry:provider-uid->${TO_UID}`]: okTrace('getUsers', 'src/routes.ts'), +}; + +const crossingRow = (contractId: string): Record => ({ + consumerUid: 'consumer-uid', + providerUid: 'provider-uid', + consumerFile: 'src/api.ts', + providerFile: 'src/routes.ts', + providerRepo: TO_REPO, + providerName: 'getUsers', + matchType: 'exact', + confidence: 0.9, + contractId, + contractType: 'http', +}); + +type TraceShape = { + status: string; + notes: string[]; + truncated?: boolean; + truncationReason?: string; + riskEpistemic?: string; + truncatedRepos?: string[]; +}; + +/** No `?? {}` fallback on purpose: an unexpected result must blow up here. */ +const shapeOf = (result: unknown): TraceShape => result as TraceShape; + +describe('cross-repo trace over a bridge built from an incomplete sync', () => { + let home: string; + let groupDir: string; + + beforeEach(async () => { + home = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-trace-incomplete-')); + groupDir = path.join(home, 'groups', 'waveful'); + await writeGroupYaml(groupDir, [FROM_REPO, TO_REPO, OFF_PATH_REPO]); + // Written BEFORE meta.json so the unstamped pair reads as paired by write + // order — otherwise every case here would be "provenance unknown" and the + // scope cases could not be told apart from the control. + await fsp.writeFile(path.join(groupDir, 'bridge.lbug'), ''); + bridgeRows.value = []; + }); + + afterEach(async () => { + await fsp.rm(home, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + const writeMeta = (meta: Omit): Promise => + writeBridgeMeta(groupDir, { + version: BRIDGE_SCHEMA_VERSION, + generatedAt: '2026-01-01T00:00:00.000Z', + ...meta, + }); + + const soundMeta = (): Promise => writeMeta({ missingRepos: [], unreadableRepos: [] }); + + const port = (segments: Record = {}): GroupToolPort => + makeGroupToolPort(home, { + resolveSymbol: vi.fn( + async (repo, q) => + SYMBOLS[`${repo.name}:${q.name ?? q.uid ?? ''}`] ?? { kind: 'not_found' }, + ) as GroupToolPort['resolveSymbol'], + trace: vi.fn( + async (repo, params) => + segments[`${repo.name}:${params.from_uid}->${params.to_uid}`] ?? { status: 'no_path' }, + ) as GroupToolPort['trace'], + }); + + const run = (p: GroupToolPort, extraParams: Record = {}): Promise => + runGroupTrace( + { port: p, gitnexusDir: home }, + { name: 'waveful', from: 'callUsers', to: 'getUsers', ...extraParams }, + ); + + it('reports a not_found trace as a lower bound when an endpoint repo was never read', async () => { + // The headline case. Every other signal says "complete": both endpoints + // resolved, the bridge answered, no cap fired. `unreadableRepos` naming the + // `to` repo is the ONLY evidence that "no ContractLink connects these + // endpoints" is a floor — that repo's contracts are absent from this + // bridge, so the link could not have been found even if it exists. + await writeMeta({ missingRepos: [], unreadableRepos: [TO_REPO] }); + + const result = shapeOf(await run(port())); + + expect(result).toMatchObject({ + status: 'not_found', + truncated: true, + truncationReason: 'incomplete-sync', + riskEpistemic: 'lower-bound', + truncatedRepos: [TO_REPO], + }); + }); + + it('reports a trace over a bridge with no provenance as a lower bound too', async () => { + // `writeBridge` swaps the database and writes meta.json as two steps, so an + // interrupted sync leaves a NEW bridge with NO metadata. `readBridgeMeta` + // answers `version: 0`, which carries no repo lists at all — so nothing can + // be named, and the reason field is the entire signal. + await fsp.rm(path.join(groupDir, 'meta.json'), { force: true }); + + const result = shapeOf(await run(port())); + + expect(result).toMatchObject({ + status: 'not_found', + truncated: true, + truncationReason: 'incomplete-sync', + riskEpistemic: 'lower-bound', + }); + // Nothing was measured, so nothing is named — inventing repo names out of + // an unreadable value would not be a measurement. + expect(result).not.toHaveProperty('truncatedRepos'); + }); + + it('marks a SUCCESSFUL trace over a bridge with no provenance', async () => { + // A found path is still an answer from a bridge that may not describe the + // database beside it: other crossings may be missing and this one may be + // stale. The fields ride on `status: 'ok'` for exactly that reason — an + // incompleteness channel that only fires on the empty answer teaches an + // agent that a non-empty answer is always complete. + await fsp.rm(path.join(groupDir, 'meta.json'), { force: true }); + bridgeRows.value = [crossingRow('http::GET::/api/users')]; + + const result = shapeOf(await run(port(CONNECTING_SEGMENTS))); + + expect(result).toMatchObject({ + status: 'ok', + truncated: true, + truncationReason: 'incomplete-sync', + riskEpistemic: 'lower-bound', + }); + }); + + it('does not mark a trace whose endpoints exclude the unreadable repo', async () => { + // R7: the incomplete set is filtered by the query's DECLARED scope. A + // third member being unreadable says nothing about whether frontend + // reaches backend — marking it would make every answer in a group with one + // sick repo a lower bound, which is how a floor marker stops meaning + // anything. + await writeMeta({ missingRepos: [], unreadableRepos: [OFF_PATH_REPO] }); + + const result = shapeOf(await run(port())); + + expect(result.status).toBe('not_found'); + expect(result).not.toHaveProperty('truncated'); + expect(result).not.toHaveProperty('truncationReason'); + expect(result).not.toHaveProperty('riskEpistemic'); + expect(result).not.toHaveProperty('truncatedRepos'); + }); + + it('claims no floor when the bridge is sound', async () => { + // The control that gives the cases above their meaning. + await soundMeta(); + + const result = shapeOf(await run(port())); + + expect(result.status).toBe('not_found'); + expect(result).not.toHaveProperty('truncated'); + expect(result).not.toHaveProperty('truncationReason'); + expect(result).not.toHaveProperty('riskEpistemic'); + }); + + it('distinguishes the two not_found answers without string-matching a note', async () => { + // The verification this unit exists for. Both runs produce the SAME prose; + // the structured field is the only thing that separates "no path exists" + // from "we could not have seen the path". + await writeMeta({ missingRepos: [], unreadableRepos: [TO_REPO] }); + const overIncomplete = shapeOf(await run(port())); + await soundMeta(); + const overSound = shapeOf(await run(port())); + + expect(overIncomplete.notes).toEqual(overSound.notes); + expect(overIncomplete.truncationReason).toBe('incomplete-sync'); + expect(overSound.truncationReason).toBeUndefined(); + }); + + it('has every repo in scope for a destination trace, which declares no `to`', async () => { + // A destination trace asks "where does this call land?" — the answer may be + // in ANY member, so no repo can be filtered out of the incomplete set. An + // unreadable provider repo is precisely how "no outgoing ContractLink + // leaves this repo" becomes a wrong answer rather than an empty one. + await writeMeta({ missingRepos: [], unreadableRepos: [OFF_PATH_REPO] }); + + const result = shapeOf(await run(port(), { to: undefined })); + + expect(result).toMatchObject({ + status: 'not_found', + truncated: true, + truncationReason: 'incomplete-sync', + riskEpistemic: 'lower-bound', + truncatedRepos: [OFF_PATH_REPO], + }); + }); + + it('keeps reporting the crossing cap when the bridge is also incomplete', async () => { + // Precedence mirrors `runGroupImpact`: the runtime limit is the one the + // caller can act on (narrow the query), while 'incomplete-sync' needs a + // different remedy (`gitnexus group sync`). The unreadable repo is still + // named. + await writeMeta({ missingRepos: [], unreadableRepos: [TO_REPO] }); + bridgeRows.value = Array.from({ length: 51 }, (_, i) => + crossingRow(`http::GET::/api/users/${i}`), + ); + + const result = shapeOf(await run(port())); + + expect(result).toMatchObject({ + status: 'not_found', + truncated: true, + truncationReason: 'partial', + riskEpistemic: 'lower-bound', + truncatedRepos: [TO_REPO], + }); + }); +}); diff --git a/gitnexus/test/unit/group/manifest-synthetic-impact.test.ts b/gitnexus/test/unit/group/manifest-synthetic-impact.test.ts index b6b635f4d..93d672f0b 100644 --- a/gitnexus/test/unit/group/manifest-synthetic-impact.test.ts +++ b/gitnexus/test/unit/group/manifest-synthetic-impact.test.ts @@ -49,6 +49,15 @@ describe('group impact through manifest-only endpoints', () => { const groupDir = path.join(home, 'groups', 'waveful'); await writeGroupYaml(groupDir, ['backend', 'app']); await fsp.writeFile(path.join(groupDir, 'bridge.lbug'), ''); + // The metadata this suite's `readBridgeMeta` mock hands back has to exist on + // disk as well as in the mock. It carries no size/mtime stamp, so + // `bridgeMetaMatchesFile` pairs it to the database by write order — and a + // metadata file that is not there cannot be paired to anything. Written + // AFTER `bridge.lbug`, which is the order a real sync produces. + await fsp.writeFile( + path.join(groupDir, 'meta.json'), + JSON.stringify({ version: 1, generatedAt: '', missingRepos: [] }), + ); }); afterEach(async () => { diff --git a/gitnexus/test/unit/group/registry-unreadable-repos.test.ts b/gitnexus/test/unit/group/registry-unreadable-repos.test.ts new file mode 100644 index 000000000..24b7a6219 --- /dev/null +++ b/gitnexus/test/unit/group/registry-unreadable-repos.test.ts @@ -0,0 +1,519 @@ +/** + * `ContractRegistry.unreadableRepos` is optional, and its absence means "the + * last sync did not record this", not "the last sync found none unreadable". + * Every registry written before the field existed is in that state. + * + * The failure this file pins: both the registry loader and `groupStatus` + * normalized a missing field to `[]`, so a group whose contracts.json predates + * the diagnostic reported a clean, measured zero — an unmeasured state rendered + * as a good result. `[]` and `undefined` are different answers here, and the + * CLI's `group status` prints them differently for exactly that reason. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fsp from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { GroupService, type GroupToolPort } from '../../../src/core/group/service.js'; +import { makeGroupToolPort, writeGroupYaml } from './fixtures.js'; + +/** The fields every case shares; only `unreadableRepos` is under test. */ +const REGISTRY_BASE = { + version: 1, + generatedAt: '2026-01-01T00:00:00.000Z', + repoSnapshots: {}, + missingRepos: [], + contracts: [], + crossLinks: [], +}; + +/** One valid row, so "the registry still loads" is observable in the payload. */ +const GOOD_CONTRACT = { + contractId: 'http::GET::/api/users', + type: 'http', + repo: 'backend', + role: 'provider', + symbolUid: 'u', + symbolRef: { filePath: 'src/routes.ts', name: 'getUsers' }, + symbolName: 'getUsers', + confidence: 1, + meta: {}, +}; + +type StatusPayload = { group: string; unreadableRepos?: unknown; missingRepos?: unknown }; + +/** + * One row of the per-repo status table. Every field is `unknown` so a wrong + * TYPE fails the assertion rather than being coerced past it — `undefined` and + * `false` are different answers about which failure a row means. + */ +type RepoStatusRow = { missing?: unknown; unresolvable?: unknown; unresolvableReason?: unknown }; +type RepoStatusPayload = { repos: Record }; +/** One valid cross-link, so the control can assert that half of the payload too. */ +const GOOD_CROSS_LINK = { + from: { repo: 'frontend', symbolUid: 'f' }, + to: { repo: 'backend', symbolUid: 'u' }, + contractId: 'http::GET::/api/users', + type: 'http', + matchType: 'exact', + confidence: 1, +}; + +type ContractsPayload = { + contracts?: unknown[]; + crossLinks?: unknown[]; + skippedCorrupt?: number; + error?: string; + /** The registry's own diagnostics, echoed onto the listing. */ + missingRepos?: unknown; + unreadableRepos?: unknown; + /** The shared incompleteness triple (KTD10) — `unknown` so a wrong TYPE fails. */ + truncated?: unknown; + truncationReason?: unknown; + riskEpistemic?: unknown; +}; + +describe('unreadableRepos survives a round trip through contracts.json', () => { + let home: string; + let groupDir: string; + + beforeEach(async () => { + home = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-registry-unreadable-')); + groupDir = path.join(home, 'groups', 'waveful'); + await writeGroupYaml(groupDir, ['backend', 'svc/users']); + vi.stubEnv('GITNEXUS_HOME', home); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await fsp.rm(home, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + /** + * Written as raw JSON, not through `writeContractRegistry`: the point of + * several cases is a file shape the current `ContractRegistry` type cannot + * express — a legacy file with the key missing, or a corrupted one. + */ + const writeRegistryJson = (extra: Record): Promise => + fsp.writeFile( + path.join(groupDir, 'contracts.json'), + JSON.stringify({ ...REGISTRY_BASE, ...extra }, null, 2), + 'utf8', + ); + + const status = async (): Promise => { + const svc = new GroupService(makeGroupToolPort(home)); + return (await svc.groupStatus({ name: 'waveful' })) as StatusPayload; + }; + + const contracts = async (): Promise => { + const svc = new GroupService(makeGroupToolPort(home)); + return (await svc.groupContracts({ name: 'waveful' })) as ContractsPayload; + }; + + it('reports a registry that never recorded the field as not recorded', async () => { + // The whole point: a contracts.json written before this diagnostic existed + // has no opinion about which indexes opened. Reporting `[]` here tells the + // caller the last sync measured zero unreadable repos, which never happened. + await writeRegistryJson({}); + + const result = await status(); + + expect(result.unreadableRepos).toBeUndefined(); + expect(result.unreadableRepos).not.toEqual([]); + }); + + it('reports a measured zero as a measured zero', async () => { + // The companion that gives the case above its meaning. A sync that read + // every index DID record an answer, and that answer is an empty list. + await writeRegistryJson({ unreadableRepos: [] }); + + const result = await status(); + + expect(result.unreadableRepos).toEqual([]); + }); + + it('passes a recorded list through intact', async () => { + await writeRegistryJson({ unreadableRepos: ['app/backend'] }); + + const result = await status(); + + expect(result.unreadableRepos).toEqual(['app/backend']); + }); + + const corruptValues: Array<{ label: string; value: unknown }> = [ + { label: 'null', value: null }, + { label: 'a bare string', value: 'app/backend' }, + { label: 'an object', value: { 'app/backend': true } }, + // An array of the wrong element type is the shape `Array.isArray` alone + // waves through, and it is the one that reaches `.join(', ')` and renders + // as `[object Object]` — a measurement the operator can read but not act on. + { label: 'an array of objects', value: [{ repo: 'app/backend' }] }, + { label: 'an array of numbers', value: [1, 2] }, + ]; + + it.each(corruptValues)( + 'does not launder $label in the unreadableRepos slot into a clean empty list', + async ({ value }) => { + // A hand-edited or half-written registry must not be able to produce the + // one value that means "measured, and everything was fine". + // + // `groupStatus` reads the file through `readContractRegistry`, which is a + // bare `JSON.parse(...) as ContractRegistry` — the validation in + // `loadContractRegistryResilient` never runs on this path — so the shape + // gate lives in `getStatus` itself. It has to: a non-array here used to + // reach `cli/group.ts` and die in `.join(', ')`, which is the command + // whose entire job is explaining an unreadable thing crashing on one. + // + // A value we cannot read is "not recorded", the same as absent. + await writeRegistryJson({ unreadableRepos: value }); + + const result = await status(); + + expect(result.group).toBe('waveful'); + expect(result.unreadableRepos).toBeUndefined(); + expect(result.unreadableRepos).not.toEqual([]); + }, + ); + + it.each(corruptValues)( + 'does not hand $label in the missingRepos slot to the CLI either', + async ({ value }) => { + // Same gate, same reason: `cli/group.ts` calls `.join(', ')` on this one + // too. `[]` is the right answer here rather than `undefined` — unlike + // `unreadableRepos`, `missingRepos` has always been required, so there is + // no "not recorded" state to preserve. + await writeRegistryJson({ missingRepos: value }); + + const result = await status(); + + expect(result.group).toBe('waveful'); + expect(result.missingRepos).toEqual([]); + }, + ); + + it('loads a registry that predates the field without inventing a value for it', async () => { + // `loadContractRegistryResilient` had zero test references when this + // back-compat promise was made, so the legacy shape was resting on a type + // annotation alone. This is the read path an agent hits right after a sync. + await writeRegistryJson({ contracts: [GOOD_CONTRACT] }); + + const result = await contracts(); + + expect(result.error).toBeUndefined(); + expect(result.contracts).toHaveLength(1); + expect(result.skippedCorrupt).toBeUndefined(); + }); + + it('still salvages good contract rows when the unreadableRepos slot is corrupt', async () => { + // The resilient loader's job is to hand back everything it can parse. A + // junk value in one diagnostic field must not cost the caller the rows + // next to it, and must not throw out of a read-only tool call. + await writeRegistryJson({ + unreadableRepos: 'app/backend', + contracts: [{ not: 'a-contract' }, GOOD_CONTRACT], + }); + + const result = await contracts(); + + expect(result.error).toBeUndefined(); + expect(result.contracts).toHaveLength(1); + expect(result.skippedCorrupt).toBe(1); + }); + + /** + * `group_contracts` is the third surface that can hand back a partial + * cross-repo answer (KTD10). `group_status` already reports the registry's + * two repo lists; the listing itself reported nothing at all, so an agent + * reading a contract set assembled from a sync that could not open half the + * group could not tell it apart from a complete one. + * + * The answer here is the SAME structured triple `GroupImpactResult` carries — + * `truncated` / `truncationReason` / `riskEpistemic` — computed by the SAME + * helper (`crossRepoCompleteness`), so the three surfaces cannot drift into + * three vocabularies. + */ + describe('group_contracts reports its completeness in the shared vocabulary', () => { + it('names the unreadable repos and marks the listing a floor', async () => { + await writeRegistryJson({ unreadableRepos: ['app/backend'], contracts: [GOOD_CONTRACT] }); + + const result = await contracts(); + + expect(result.unreadableRepos).toEqual(['app/backend']); + expect(result.truncated).toBe(true); + // Not 'partial'/'timeout': nothing was cut short by a runtime limit here. + // The remedy is `gitnexus group sync`, not a narrower query. + expect(result.truncationReason).toBe('incomplete-sync'); + expect(result.riskEpistemic).toBe('lower-bound'); + // The rows the sync DID read are still returned — a floor, not an error. + expect(result.contracts).toHaveLength(1); + }); + + it('reports a measured-clean registry as complete', async () => { + // The companion that gives the case above its meaning: a sync that read + // every index recorded an answer, and that answer is an empty list. + await writeRegistryJson({ unreadableRepos: [], contracts: [GOOD_CONTRACT] }); + + const result = await contracts(); + + expect(result.unreadableRepos).toEqual([]); + expect(result.truncated).toBe(false); + // The two companions are set WITH `truncated`, never without it. + expect(result.truncationReason).toBeUndefined(); + expect(result.riskEpistemic).toBeUndefined(); + }); + + it('omits the key for a registry that predates the field, and reports a floor', async () => { + // Absence is "not recorded", not "none". Inventing `[]` here would tell + // the agent the last sync measured zero unreadable repos — it never ran + // the measurement — and the same conflation would then say "complete". + await writeRegistryJson({ contracts: [GOOD_CONTRACT] }); + + const result = await contracts(); + + expect(Object.keys(result)).not.toContain('unreadableRepos'); + expect(result.unreadableRepos).toBeUndefined(); + expect(result.truncated).toBe(true); + expect(result.truncationReason).toBe('incomplete-sync'); + expect(result.riskEpistemic).toBe('lower-bound'); + }); + + it('counts a missing repo as incompleteness even when every index opened', async () => { + // The two lists are independent diagnostics with one consequence: none of + // those repos' contracts are in the artifact. A recorded-clean + // `unreadableRepos` must not launder a missing member into a complete set. + await writeRegistryJson({ + unreadableRepos: [], + missingRepos: ['svc/users'], + contracts: [GOOD_CONTRACT], + }); + + const result = await contracts(); + + expect(result.missingRepos).toEqual(['svc/users']); + expect(result.unreadableRepos).toEqual([]); + expect(result.truncated).toBe(true); + expect(result.truncationReason).toBe('incomplete-sync'); + expect(result.riskEpistemic).toBe('lower-bound'); + }); + + it.each(corruptValues)( + 'does not read $label in the unreadableRepos slot as a measured zero', + async ({ value }) => { + // Same gate as `group_status`, on the same registry field: a value we + // could not read is unrecorded, so the listing omits the key and says + // it is a floor rather than reporting a clean measured empty list. + await writeRegistryJson({ unreadableRepos: value, contracts: [GOOD_CONTRACT] }); + + const result = await contracts(); + + expect(Object.keys(result)).not.toContain('unreadableRepos'); + expect(result.truncated).toBe(true); + expect(result.truncationReason).toBe('incomplete-sync'); + }, + ); + + it.each(corruptValues)( + 'degrades $label in the missingRepos slot to an empty list', + async ({ value }) => { + // `missingRepos` has always been required, so there is no "not + // recorded" state to preserve — but an unreadable value must not reach + // the caller (or the completeness fold) as if it were a repo list. An + // array of objects is the shape `Array.isArray` alone waves through. + await writeRegistryJson({ + missingRepos: value, + unreadableRepos: [], + contracts: [GOOD_CONTRACT], + }); + + const result = await contracts(); + + expect(result.missingRepos).toEqual([]); + expect(result.truncated).toBe(false); + }, + ); + + it('keeps the contract and cross-link payload it has always returned', async () => { + // Control. The completeness fields are an ADDITION to this payload; if + // this case moves, the fold broke the surface it was meant to annotate. + await writeRegistryJson({ + unreadableRepos: [], + contracts: [GOOD_CONTRACT], + crossLinks: [GOOD_CROSS_LINK], + }); + + const result = await contracts(); + + expect(result.error).toBeUndefined(); + expect(result.contracts).toEqual([GOOD_CONTRACT]); + expect(result.crossLinks).toEqual([GOOD_CROSS_LINK]); + expect(result.skippedCorrupt).toBeUndefined(); + }); + }); + + /** + * The per-repo table had ONE failure label — `missing`, printed as "no entry + * in the registry" — and every cause collapsed into it, including a global + * registry that could not be read at all. For that cause "no entry" is a + * statement about a file nothing could be read from, and it points at the + * wrong repair: index the repo, when the fix is to repair the registry. + * + * `getStatus` therefore reads the global registry through the STRICT mode. + * The lenient read's `catch { return [] }` turns an unreadable registry into + * an empty one, which is indistinguishable from a genuine absence — it can + * only ever produce the `missing` answer, so it cannot express these cases. + */ + describe('group status tells a missing repo apart from an unresolvable one', () => { + /** A registry row carrying every field the strict read demands of one. */ + const registryRow = (name: string): Record => ({ + name, + path: path.join(home, name), + storagePath: path.join(home, name, '.gitnexus'), + indexedAt: '2026-01-01T00:00:00.000Z', + lastCommit: 'abc123', + }); + + /** + * Written verbatim rather than through the registry writer: half these + * cases need a file shape `RegistryEntry[]` cannot express — a JSON + * object, a truncated write, a row that names nothing. + */ + const writeGlobalRegistry = (body: string): Promise => + fsp.writeFile(path.join(home, 'registry.json'), body, 'utf8'); + + const notFound = (name: string): never => { + throw new Error(`Repository "${name}" not found. Available: `); + }; + + /** + * Stands in for `LocalBackend.resolveRepo`: a handle for the names given, + * and its own not-found error for the rest. The fixture only means + * anything while it agrees with the registry file the case wrote — + * `getStatus` reads that file itself, and the two answers are what these + * cases are about. + */ + const portResolving = (resolvable: string[]): GroupToolPort => { + const handles = new Map( + resolvable.map((name) => [ + name, + { + id: name, + name, + repoPath: path.join(home, name), + storagePath: path.join(home, name, '.gitnexus'), + }, + ]), + ); + return makeGroupToolPort(home, { + resolveRepo: vi.fn(async (registryName?: string) => { + const wanted = String(registryName); + return handles.get(wanted) ?? notFound(wanted); + }), + }); + }; + + const statusWith = async (port: GroupToolPort): Promise => + (await new GroupService(port).groupStatus({ name: 'waveful' })) as RepoStatusPayload; + + it('renders a repo the readable registry simply lacks as missing', async () => { + // The guard on the other side of the split: the new label must not + // swallow the old one. This repo has no row, that is exactly why + // resolution failed, and "no entry in the registry" is a true statement. + await writeGlobalRegistry(JSON.stringify([registryRow('backend-registry')])); + + const result = await statusWith(portResolving(['backend-registry'])); + + expect(result.repos['svc/users'].missing).toBe(true); + expect(result.repos['svc/users'].unresolvable).toBeFalsy(); + expect(result.repos['svc/users'].unresolvableReason).toBeUndefined(); + }); + + it('renders a repo the registry does hold but cannot resolve as unresolvable', async () => { + // The same port failure as the case above, in the same group, with one + // difference: the registry HAS the row. "No entry in the registry" would + // be a false statement about the file the command just read. + await writeGlobalRegistry( + JSON.stringify([registryRow('backend-registry'), registryRow('svc/users-registry')]), + ); + + const result = await statusWith(portResolving(['backend-registry'])); + + expect(result.repos['svc/users'].unresolvable).toBe(true); + expect(result.repos['svc/users'].unresolvableReason).toContain('svc/users-registry'); + // The pre-split flag keeps its meaning, so a consumer written before the + // split still sees an unusable repo flagged rather than a clean row. + expect(result.repos['svc/users'].missing).toBe(true); + }); + + it('carries both states in one payload, distinguishably', async () => { + // What an agent reads. Nothing resolves; the registry knows one of the + // two repos and not the other. Two failures, two different answers. + await writeGlobalRegistry(JSON.stringify([registryRow('backend-registry')])); + + const result = await statusWith(portResolving([])); + + expect(result.repos['backend'].unresolvable).toBe(true); + expect(result.repos['svc/users'].unresolvable).toBe(false); + expect(result.repos['backend'].missing).toBe(true); + expect(result.repos['svc/users'].missing).toBe(true); + }); + + const unreadableRegistries: Array<{ label: string; body: string }> = [ + { label: 'a JSON object', body: '{"repos": []}' }, + { label: 'a truncated write', body: '[{"name":"backend-registry",' }, + { label: 'not JSON at all', body: 'nope' }, + ]; + + it.each(unreadableRegistries)( + 'renders every configured repo as unresolvable when the registry is $label', + async ({ body }) => { + // The answer the lenient read cannot give: it collapses this file into + // `[]`, and every repo then reports "no entry in the registry" — a + // measurement of a file nothing could be measured from. + await writeGlobalRegistry(body); + + const result = await statusWith(portResolving([])); + + expect(result.repos['backend'].unresolvable).toBe(true); + expect(result.repos['svc/users'].unresolvable).toBe(true); + expect(result.repos['backend'].unresolvableReason).toContain('registry'); + }, + ); + + it('reports every repo as unresolvable when one row cannot identify a repo', async () => { + // The accepted consequence of the strict read: it rejects the WHOLE + // registry on one unidentifiable row, so `backend` is reported + // unresolvable even though its own row is intact and it still resolves. + // Deliberate — a registry the resolver cannot trust row-wise cannot be + // trusted about any row — and the answer is an unresolved state, never + // the clean `missing: false` row this used to print. + await writeGlobalRegistry( + JSON.stringify([ + registryRow('backend-registry'), + { ...registryRow('svc/users-registry'), name: ' ' }, + ]), + ); + + const result = await statusWith(portResolving(['backend-registry', 'svc/users-registry'])); + + expect(result.repos['backend'].unresolvable).toBe(true); + expect(result.repos['backend'].missing).toBe(true); + expect(result.repos['svc/users'].unresolvable).toBe(true); + }); + + it('renders neither state for a group whose repos all resolve', async () => { + // Control. Both labels are for failures; a healthy group must show + // neither, or the split is just a new way to raise a false alarm. + await writeGlobalRegistry( + JSON.stringify([registryRow('backend-registry'), registryRow('svc/users-registry')]), + ); + + const result = await statusWith(portResolving(['backend-registry', 'svc/users-registry'])); + + expect(result.repos['backend'].missing).toBe(false); + expect(result.repos['backend'].unresolvable).toBeFalsy(); + expect(result.repos['svc/users'].missing).toBe(false); + expect(result.repos['svc/users'].unresolvable).toBeFalsy(); + }); + }); +}); diff --git a/gitnexus/test/unit/group/service-group-sync-payload.test.ts b/gitnexus/test/unit/group/service-group-sync-payload.test.ts new file mode 100644 index 000000000..f96841d77 --- /dev/null +++ b/gitnexus/test/unit/group/service-group-sync-payload.test.ts @@ -0,0 +1,304 @@ +/** + * What `group_sync` and `group_contracts` PUT ON THE WIRE. + * + * Both tools document fields an agent is expected to branch on, and both build + * their payload by hand — a literal per field, each one a line that can be + * deleted without breaking a type or a build. Nothing asserted either payload, + * so dropping `unreadableRepos` or `registryOutcome` from the sync response, or + * the truncation triple from the contract listing, was a silent change: the + * caller simply stopped being told, and every existing test stayed green. + * + * Hence exact-shape assertions throughout. `toMatchObject` — which is what the + * one existing `groupSync` assertion uses, in + * `test/integration/group/group-service-sync-lazy-import.test.ts` — passes + * happily on a payload that has lost a key, which is precisely the regression + * this file exists to catch. + * + * The tri-state these cases pin, established by the sibling commits in this PR: + * + * - an ABSENT `unreadableRepos` means the sync never recorded which repos it + * could read, so any answer derived from the artifact is a floor; + * - an EMPTY list is a measurement — this sync accounted for every repo; + * - a POPULATED list names the repos whose contracts are not in there. + * + * `groupContracts` therefore OMITS the key in the absent case rather than + * inventing `[]`, and pairs it with `truncated: true` + + * `truncationReason: 'incomplete-sync'` + `riskEpistemic: 'lower-bound'`. An + * exact-shape assertion is the only kind that can see the difference between + * omitting a key and normalizing it to empty. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { SyncResult } from '../../../src/core/group/sync.js'; +import type { GroupToolPort, GroupRepoHandle } from '../../../src/core/group/service.js'; +import type { CrossLink } from '../../../src/core/group/types.js'; +import { makeContract } from './fixtures.js'; + +/** + * `GroupService.groupSync` reaches `syncGroup` through a dynamic + * `await import('./sync.js')`; vitest resolves that to the same module id as + * the specifier below, so this factory serves it. Mocked because the two + * forwarded fields are what is under test and a real sync cannot be steered to + * an arbitrary `registryOutcome` without an indexed repo — the real import is + * pinned separately, and deliberately unmocked, in + * `test/integration/group/group-service-sync-lazy-import.test.ts`. + */ +const syncGroupMock = vi.fn<() => Promise>(); + +vi.mock('../../../src/core/group/sync.js', () => ({ + syncGroup: (...args: unknown[]) => syncGroupMock(...(args as [])), +})); + +const { GroupService } = await import('../../../src/core/group/service.js'); + +const port: GroupToolPort = { + resolveRepo: vi.fn( + async (name?: string): Promise => ({ + id: name ?? 'repo', + name: name ?? 'repo', + repoPath: '/tmp/repo', + storagePath: '/tmp/repo/.gitnexus', + }), + ), + impact: vi.fn(async () => ({ symbols: [] })), + query: vi.fn(async () => ({ processes: [] })), + impactByUid: vi.fn(async () => null), + context: vi.fn(async () => ({ + status: 'found' as const, + symbol: { filePath: 'src/routes.ts', uid: 'uid-1', name: 'getUsers' }, + })), +}; + +const GROUP = 'payload'; + +/** Every field of a `SyncResult`, overridable one at a time. */ +const syncResult = (overrides: Partial = {}): SyncResult => ({ + contracts: [], + crossLinks: [], + unmatched: [], + missingRepos: [], + unreadableRepos: [], + repoSnapshots: {}, + registryOutcome: 'written', + ...overrides, +}); + +const CONTRACT = makeContract({ repo: 'app/backend' }); +const CROSS_LINK: CrossLink = { + contractId: CONTRACT.contractId, + type: 'http', + matchType: 'exact', + confidence: 1, + from: { + repo: 'app/frontend', + symbolUid: 'uid-2', + symbolRef: { filePath: 'src/client.ts', name: 'callUsers' }, + }, + to: { + repo: 'app/backend', + symbolUid: 'uid-1', + symbolRef: { filePath: 'src/routes.ts', name: 'getUsers' }, + }, +}; + +let home: string; +let groupDir: string; + +beforeEach(() => { + syncGroupMock.mockReset(); + home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-group-payload-')); + groupDir = path.join(home, 'groups', GROUP); + fs.mkdirSync(groupDir, { recursive: true }); + fs.writeFileSync( + path.join(groupDir, 'group.yaml'), + `version: 1 +name: ${GROUP} +description: "" +repos: + app/backend: payload-backend + app/frontend: payload-frontend +`, + 'utf8', + ); + vi.stubEnv('GITNEXUS_HOME', home); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(home, { recursive: true, force: true }); +}); + +const seedRegistry = (registry: Record): void => + fs.writeFileSync(path.join(groupDir, 'contracts.json'), JSON.stringify(registry), 'utf8'); + +const BASE_REGISTRY = { + version: 1, + generatedAt: '2026-01-01T00:00:00.000Z', + repoSnapshots: {}, + missingRepos: [], + contracts: [CONTRACT], + crossLinks: [CROSS_LINK], +}; + +describe('group_sync forwards what the sync learned about the repos and the file', () => { + it('carries the unreadable list and the registry outcome, by exact shape', async () => { + // The headline case: a sync that could read nothing and therefore kept the + // previous registry. An agent that calls `group_sync` and then + // `group_contracts` a moment later otherwise sees contract counts that + // disagree with this payload, with nothing here explaining why the write + // was skipped — and no way to tell "the group has no contracts" from "this + // run could not read the repos that hold them". + syncGroupMock.mockResolvedValue( + syncResult({ + contracts: [CONTRACT], + crossLinks: [CROSS_LINK], + unmatched: [CONTRACT], + missingRepos: ['app/frontend'], + unreadableRepos: ['app/backend'], + registryOutcome: 'preserved', + }), + ); + + const payload = await new GroupService(port).groupSync({ name: GROUP }); + + // `toEqual`, not `toMatchObject`: deleting either forwarded line from the + // return literal leaves a payload that a partial match still accepts. + expect(payload).toEqual({ + contracts: 1, + crossLinks: 1, + unmatched: 1, + missingRepos: ['app/frontend'], + unreadableRepos: ['app/backend'], + registryOutcome: 'preserved', + }); + }); + + it('reports an empty unreadable list as the measurement it is', async () => { + // `[]` here is "this sync accounted for every repo", and it has to arrive + // as `[]` rather than as an absent key: on the response boundary the two + // are the difference between a clean result and an unmeasured one. + syncGroupMock.mockResolvedValue(syncResult({ registryOutcome: 'written' })); + + const payload = await new GroupService(port).groupSync({ name: GROUP }); + + expect(payload).toEqual({ + contracts: 0, + crossLinks: 0, + unmatched: 0, + missingRepos: [], + unreadableRepos: [], + registryOutcome: 'written', + }); + }); + + it('names each write outcome the sync can reach', async () => { + // `registryOutcome` is a union of four, and the CLI's outcome chain has no + // fallback branch — a value that never reached the wire would fall through + // it silently. Forwarding is verbatim, so this pins that too. + const outcomes: SyncResult['registryOutcome'][] = [ + 'written', + 'preserved', + 'no-prior-registry', + 'not-attempted', + ]; + const seen: unknown[] = []; + + for (const registryOutcome of outcomes) { + syncGroupMock.mockResolvedValue(syncResult({ registryOutcome })); + const payload = (await new GroupService(port).groupSync({ name: GROUP })) as Record< + string, + unknown + >; + seen.push(payload.registryOutcome); + } + + expect(seen).toEqual(outcomes); + }); +}); + +describe('group_contracts forwards its structured incompleteness', () => { + it('omits the unreadable list, and calls the listing a floor, when the sync never recorded one', async () => { + // Provenance unknown. The registry predates the field (or held something + // that was not a list of repo paths), so this listing cannot say which + // repos the sync failed to read — and therefore cannot claim to be + // complete. Inventing `[]` here would report an unmeasured state as a clean + // one, which is the conflation the whole tri-state removes. + seedRegistry(BASE_REGISTRY); + + const payload = await new GroupService(port).groupContracts({ name: GROUP }); + + expect(payload).toEqual({ + contracts: [CONTRACT], + crossLinks: [CROSS_LINK], + missingRepos: [], + truncated: true, + truncationReason: 'incomplete-sync', + riskEpistemic: 'lower-bound', + }); + // The same claim stated directly, because it is an ABSENCE and absence is + // the one thing a reader of the assertion above has to infer. + expect(payload).not.toHaveProperty('unreadableRepos'); + }); + + it('returns the measured empty list, and calls the listing complete', async () => { + // The middle state, and the only one that may answer `truncated: false`. + seedRegistry({ ...BASE_REGISTRY, unreadableRepos: [] }); + + const payload = await new GroupService(port).groupContracts({ name: GROUP }); + + expect(payload).toEqual({ + contracts: [CONTRACT], + crossLinks: [CROSS_LINK], + missingRepos: [], + unreadableRepos: [], + truncated: false, + }); + // `truncationReason` and `riskEpistemic` ride `truncated: true` and must not + // appear beside a complete answer — an agent that branches on either one + // being present would read this listing as a floor. + expect(payload).not.toHaveProperty('truncationReason'); + expect(payload).not.toHaveProperty('riskEpistemic'); + }); + + it('names the repos, and marks the listing a floor, when the sync recorded some', async () => { + // The populated state. `truncated` alone says the answer was cut short; + // `unreadableRepos` is what says WHERE, and it is the field that turns "this + // listing is incomplete" into something an operator can act on. + seedRegistry({ ...BASE_REGISTRY, unreadableRepos: ['app/backend'] }); + + const payload = await new GroupService(port).groupContracts({ name: GROUP }); + + expect(payload).toEqual({ + contracts: [CONTRACT], + crossLinks: [CROSS_LINK], + missingRepos: [], + unreadableRepos: ['app/backend'], + truncated: true, + truncationReason: 'incomplete-sync', + riskEpistemic: 'lower-bound', + }); + }); + + it('marks the listing a floor for a repo the registry recorded as missing too', async () => { + // The two lists are independent diagnostics with one consequence — none of + // those repos' contracts are in the artifact — so the completeness fold + // reads both. A `truncated` derived from `unreadableRepos` alone would call + // this listing complete while a whole member is unaccounted for. + seedRegistry({ ...BASE_REGISTRY, missingRepos: ['app/frontend'], unreadableRepos: [] }); + + const payload = await new GroupService(port).groupContracts({ name: GROUP }); + + expect(payload).toEqual({ + contracts: [CONTRACT], + crossLinks: [CROSS_LINK], + missingRepos: ['app/frontend'], + unreadableRepos: [], + truncated: true, + truncationReason: 'incomplete-sync', + riskEpistemic: 'lower-bound', + }); + }); +}); diff --git a/gitnexus/test/unit/group/sync-partial-extraction.test.ts b/gitnexus/test/unit/group/sync-partial-extraction.test.ts new file mode 100644 index 000000000..a8fbc2c45 --- /dev/null +++ b/gitnexus/test/unit/group/sync-partial-extraction.test.ts @@ -0,0 +1,587 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { _captureLogger } from '../../../src/core/logger.js'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; +import type { + ContractRegistry, + ExtractedContract, + GroupConfig, + GroupManifestLink, + RepoHandle, +} from '../../../src/core/group/types.js'; + +/** + * Per-repo extraction is all-or-nothing. + * + * `syncGroup` runs each enabled extractor for a repo in sequence and any one of + * them can throw. Appending results to the shared `autoContracts` as they were + * produced meant a repo whose HTTP extractor succeeded and whose gRPC extractor + * then failed contributed a partial set to contracts.json — while the catch that + * caught the failure told the operator that repo's "contracts are omitted from + * this sync", and `group sync` printed the same. The persisted registry held an + * undocumented partial view of a repo that the diagnostics described as absent. + * + * Nothing about the earlier extractor's output is wrong in isolation. What makes + * it unusable is that no reader can tell which repos are complete: a contract + * that is silently absent reads exactly like a contract that does not exist. + */ + +const PARTIAL_CONTRACT: ExtractedContract = { + contractId: 'http::GET::/api/users', + type: 'http', + role: 'provider', + symbolUid: 'Function:src/users.ts:listUsers', + symbolRef: { filePath: 'src/users.ts', name: 'listUsers' }, + symbolName: 'listUsers', + confidence: 1, + meta: {}, +}; + +const httpExtract = vi.fn(); +const grpcExtract = vi.fn(); +// Bound through an arrow so the test body can read its calls: which repos the +// deferred manifest phase re-opens is the observable side of dropping a failed +// repo's handle, and a `vi.fn()` created inside the factory is unreachable here. +const initLbugMock = vi.fn(async () => {}); + +vi.mock('../../../src/core/lbug/pool-adapter.js', () => ({ + initLbug: (...args: unknown[]) => initLbugMock(...args), + executeParameterized: vi.fn(async () => []), + pinRepo: vi.fn(() => () => {}), + getMaxResidentRepos: vi.fn(() => 5), +})); + +vi.mock('../../../src/storage/repo-manager.js', () => ({ + readRegistry: vi.fn(async () => []), + readRegistryStrict: vi.fn(async () => []), +})); + +vi.mock('../../../src/core/group/extractors/http-route-extractor.js', () => ({ + HttpRouteExtractor: class { + extract = (...args: unknown[]) => httpExtract(...args); + }, +})); + +vi.mock('../../../src/core/group/extractors/grpc-extractor.js', () => ({ + GrpcExtractor: class { + extract = (...args: unknown[]) => grpcExtract(...args); + }, +})); + +const { syncGroup } = await import('../../../src/core/group/sync.js'); + +const handle: RepoHandle = { + id: 'pool-backend', + path: '/repos/backend', + repoPath: '/repos/backend', + storagePath: '/repos/backend/.gitnexus', +}; + +const config = (): GroupConfig => ({ + version: 1, + name: 'test', + description: '', + repos: { 'app/backend': 'backend-repo' }, + links: [], + packages: {}, + detect: { + http: true, + grpc: true, + thrift: false, + topics: false, + shared_libs: false, + embedding_fallback: false, + includes: false, + workspace_deps: false, + }, + matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, +}); + +describe('syncGroup when one extractor fails partway through a repo', () => { + let groupDir: string; + + beforeEach(() => { + httpExtract.mockReset(); + grpcExtract.mockReset(); + groupDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-partial-')); + }); + + afterEach(() => { + fs.rmSync(groupDir, { recursive: true, force: true }); + }); + + it('keeps none of that repo’s contracts, matching what the diagnostics say', async () => { + httpExtract.mockResolvedValue([PARTIAL_CONTRACT]); + grpcExtract.mockRejectedValue(new Error('gRPC extraction failed')); + + const result = await syncGroup(config(), { + groupDir, + resolveRepoHandle: async () => handle, + }); + + expect(httpExtract).toHaveBeenCalledTimes(1); + expect(result.unreadableRepos).toEqual(['app/backend']); + // The contract the HTTP extractor produced is discarded with the rest of + // the repo. Anything else contradicts the warning the same run emits. + expect(result.contracts).toEqual([]); + }); + + it('keeps every contract when all enabled extractors succeed', async () => { + // The control: the all-or-nothing rule must not cost the happy path its + // output, which a guard that simply dropped `repoContracts` would. + httpExtract.mockResolvedValue([PARTIAL_CONTRACT]); + grpcExtract.mockResolvedValue([]); + + const result = await syncGroup(config(), { + groupDir, + resolveRepoHandle: async () => handle, + }); + + expect(result.unreadableRepos).toEqual([]); + expect(result.contracts).toHaveLength(1); + expect(result.contracts[0].contractId).toBe('http::GET::/api/users'); + expect(result.contracts[0].repo).toBe('app/backend'); + }); +}); + +/** + * The staged contracts must be appended by a BOUNDED construct. + * + * Staging (above) is what made the append dangerous. Before it, each extractor's + * output was appended as it came back, so `autoContracts.push(...)` only ever + * spread one extractor's contracts; staging makes it spread the whole repo's. + * A spread call passes every element as a separate ARGUMENT, and the engine caps + * how many arguments a call can take — so a repo that stages enough contracts + * kills the sync with `RangeError: Maximum call stack size exceeded` on the one + * line whose job is to commit the work that just succeeded. + * + * This gate is structural rather than size-based ON PURPOSE. The argument limit + * is a function of the host's available stack: this machine accepts a 125k-element + * spread and dies at 150k, and a larger-stack host sails past both. A "make the + * fixture big enough to crash" test therefore passes against unfixed code on some + * hosts — which is precisely the guarantee a regression gate cannot give up. The + * size test below is a completeness/ordering check, not the guard. + * + * Scope: the per-repo extractor `try` block ONLY. `sync.ts` also spreads in the + * windowed manifest loop (`autoContracts.push(...windowResult.contracts)` and its + * cross-link twin). Those predate this change, are bounded by the window size, + * and are not what this gate is about — a text scan keyed on `autoContracts.push(...` + * would match them too and fail on code this change never touches. So the region + * is located by AST and by ROLE, not by name: the `const … : StoredContract[] = []` + * staging buffer declared per repo (the function-scoped `let autoContracts` is + * excluded by the `const`), then the one `try` whose block references it. Renaming + * either identifier keeps the gate pointed at the same code. + * + * `.apply(` is rejected alongside the spread: `push.apply(dest, staged)` is the + * same argument-limit hazard wearing different syntax. + */ +const SYNC_SOURCE_PATH = fileURLToPath(new URL('../../../src/core/group/sync.ts', import.meta.url)); + +/** Every node under `node`, in source order. No branching, so nothing is skippable. */ +function descendants(node: ts.Node): ts.Node[] { + const out: ts.Node[] = []; + const visit = (n: ts.Node): void => { + out.push(n); + n.forEachChild(visit); + }; + node.forEachChild(visit); + return out; +} + +/** `const : StoredContract[] = []` — the per-repo staging buffer. */ +function isStagingBufferDeclaration(node: ts.Node): node is ts.VariableDeclaration { + return ( + ts.isVariableDeclaration(node) && + node.type !== undefined && + ts.isArrayTypeNode(node.type) && + ts.isTypeReferenceNode(node.type.elementType) && + ts.isIdentifier(node.type.elementType.typeName) && + node.type.elementType.typeName.text === 'StoredContract' && + node.initializer !== undefined && + ts.isArrayLiteralExpression(node.initializer) && + node.initializer.elements.length === 0 && + ts.isVariableDeclarationList(node.parent) && + (node.parent.flags & ts.NodeFlags.Const) !== 0 + ); +} + +/** `x.apply(dest, args)` — an argument-limited append in non-spread clothing. */ +function isApplyCall(call: ts.CallExpression): boolean { + return ts.isPropertyAccessExpression(call.expression) && call.expression.name.text === 'apply'; +} + +function describeCall(sourceFile: ts.SourceFile, call: ts.CallExpression): string { + const { line } = sourceFile.getLineAndCharacterOfPosition(call.getStart(sourceFile)); + return `${line + 1}: ${call.getText(sourceFile).replace(/\s+/g, ' ')}`; +} + +describe('the per-repo staging append in sync.ts', () => { + it('appends the staged contracts without spreading them into a call', () => { + const source = fs.readFileSync(SYNC_SOURCE_PATH, 'utf-8'); + const sourceFile = ts.createSourceFile( + SYNC_SOURCE_PATH, + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + + const allNodes = descendants(sourceFile); + const stagingBuffers = allNodes.filter(isStagingBufferDeclaration); + // One staging buffer, or this gate no longer knows which code it guards. + expect(stagingBuffers.map((d) => d.name.getText(sourceFile))).toHaveLength(1); + const stagingNames = stagingBuffers.map((d) => d.name.getText(sourceFile)); + + // The block the buffer is declared in — the per-repo loop body. + const declaringBlocks = stagingBuffers + .map((d) => d.parent.parent.parent) // declaration → list → statement → block + .filter(ts.isBlock); + expect(declaringBlocks).toHaveLength(1); + + // The extractor try-block: a DIRECT statement of that block whose `try` reads + // the staging buffer. Direct statements only, deliberately — `syncGroup` wraps + // this whole section in its own try/finally (the lease sweep), and that + // ancestor reads the buffer too. Widening to "any try that mentions it" pulls + // in the entire function body, manifest-window spreads and all. + const extractorTryBlocks = declaringBlocks.flatMap((block) => + block.statements + .filter(ts.isTryStatement) + .filter((statement) => + descendants(statement.tryBlock).some( + (n) => ts.isIdentifier(n) && stagingNames.includes(n.text), + ), + ) + .map((statement) => statement.tryBlock), + ); + expect(extractorTryBlocks).toHaveLength(1); + + const unboundedAppends = extractorTryBlocks.flatMap((block) => + descendants(block) + .filter(ts.isCallExpression) + .filter((call) => call.arguments.some(ts.isSpreadElement) || isApplyCall(call)) + .map((call) => describeCall(sourceFile, call)), + ); + + // Every staged contract must reach `autoContracts` through a bounded loop: + // the count a repo can stage is then bounded by memory, not by how much + // stack the host happened to give this process. + expect(unboundedAppends).toEqual([]); + }); +}); + +/** + * A repo can stage more contracts than a call is allowed to take as arguments. + * 200_000 is over this host's measured spread ceiling (~125k) and under nothing + * in particular — the point is that the count is bounded by memory now, so the + * assertion is that all of them arrive, in the order the extractors produced them. + */ +const LARGE_CONTRACT_COUNT = 200_000; + +describe('syncGroup appending a repo that staged a large contract count', () => { + let groupDir: string; + + beforeEach(() => { + httpExtract.mockReset(); + grpcExtract.mockReset(); + groupDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-bulk-')); + }); + + afterEach(() => { + fs.rmSync(groupDir, { recursive: true, force: true }); + }); + + it('keeps every staged contract, in order', async () => { + const staged: ExtractedContract[] = Array.from({ length: LARGE_CONTRACT_COUNT }, (_, i) => ({ + ...PARTIAL_CONTRACT, + contractId: `http::GET::/api/item/${i}`, + symbolUid: `Function:src/items.ts:item${i}`, + })); + httpExtract.mockResolvedValue(staged); + grpcExtract.mockResolvedValue([]); + + const result = await syncGroup(config(), { + groupDir, + // Nothing here is about persistence; writing a 200k-contract registry and + // bridge would only make the test slow. + skipWrite: true, + resolveRepoHandle: async () => handle, + }); + + // An argument-limit RangeError lands in the per-repo catch, so an unbounded + // append shows up here as an "unreadable" repo with zero contracts — the + // extraction that actually succeeded, reported as an unreadable index. + expect(result.unreadableRepos).toEqual([]); + expect(result.contracts).toHaveLength(LARGE_CONTRACT_COUNT); + const firstOutOfOrder = result.contracts.findIndex( + (c, i) => c.contractId !== `http::GET::/api/item/${i}`, + ); + expect(firstOutOfOrder).toBe(-1); + }, 30_000); + + it('appends an ordinary repo’s contracts in the order the extractors produced them', async () => { + // The control. Ordering across extractors is observable in contracts.json + // and in every consumer of it, so the bounded append has to reproduce the + // sequence the spread produced: HTTP contracts first, then gRPC, each in + // the extractor's own order. + const httpContracts: ExtractedContract[] = ['a', 'b', 'c'].map((suffix) => ({ + ...PARTIAL_CONTRACT, + contractId: `http::GET::/api/${suffix}`, + })); + const grpcContracts: ExtractedContract[] = ['x', 'y'].map((suffix) => ({ + ...PARTIAL_CONTRACT, + type: 'grpc', + contractId: `grpc::svc.Service/${suffix}`, + })); + httpExtract.mockResolvedValue(httpContracts); + grpcExtract.mockResolvedValue(grpcContracts); + + const result = await syncGroup(config(), { + groupDir, + resolveRepoHandle: async () => handle, + }); + + expect(result.unreadableRepos).toEqual([]); + expect(result.contracts.map((c) => c.contractId)).toEqual([ + 'http::GET::/api/a', + 'http::GET::/api/b', + 'http::GET::/api/c', + 'grpc::svc.Service/x', + 'grpc::svc.Service/y', + ]); + }); +}); + +/** + * A repo the sync reported unreadable contributes NO contracts to the persisted + * registry — including through deferred manifest resolution. + * + * Per-repo staging (above) closes the extractor door only. It leaves the + * manifest one open: `repoHandles` kept the failed repo's pool identity, so the + * windowed manifest phase still counted it among the known repos, re-opened it, + * and `ManifestExtractor` emitted a contract for BOTH endpoints of every link + * naming it. contracts.json therefore listed a repo that the very same run's + * `unreadableRepos` said it could not read — the contradiction the staging + * change exists to remove, reproduced one phase later. + * + * The narrow part is what must NOT be dropped. `ManifestExtractor` resolves both + * endpoints of a link and emits one contract per endpoint, so dropping the whole + * link would also delete the HEALTHY partner's contract. A link is not the unit + * of ownership; the endpoint is. Hence the filter is by endpoint repo, and the + * all-healthy control below is what pins the healthy partner's output so an + * over-broad "drop the link" fix cannot pass. + * + * Every assertion here reads the WRITTEN contracts.json, not the in-memory + * `SyncResult`: the file is what `group status`, the bridge builder and the next + * sync consume, so an in-memory-only assertion would not describe the artifact + * the requirement is about. + */ + +const GRPC_LINK: GroupManifestLink = { + from: 'app/gateway', + to: 'app/backend', + type: 'grpc', + // `role` describes `from`: the gateway CONSUMES what the backend provides, so + // the provider endpoint is the repo whose extractor fails below. + role: 'consumer', + contract: 'orders.Orders/List', +}; + +const LINK_CONTRACT_ID = 'grpc::orders.Orders/List'; + +const linkedConfig = (): GroupConfig => ({ + ...config(), + repos: { 'app/gateway': 'gateway-repo', 'app/backend': 'backend-repo' }, + links: [GRPC_LINK], +}); + +/** + * Resolve handles from a table keyed on the GROUP path, so a two-repo case needs + * no branching in the test body. Distinct `repoPath`s are what let the extractor + * outcome below be keyed per repo. + */ +const LINKED_HANDLES = new Map([ + [ + 'app/gateway', + { + id: 'pool-gateway', + path: '/repos/gateway', + repoPath: '/repos/gateway', + storagePath: '/repos/gateway/.gitnexus', + }, + ], + [ + 'app/backend', + { + id: 'pool-backend', + path: '/repos/backend', + repoPath: '/repos/backend', + storagePath: '/repos/backend/.gitnexus', + }, + ], +]); + +const resolveLinkedHandle = async ( + _registryName: string, + groupPath: string, +): Promise => LINKED_HANDLES.get(groupPath) ?? null; + +/** + * `extract(executor, repoPath, handle)` — key the outcome on the repo path so + * which repo fails is data, not a branch in a test body. A repo outside the + * failing set extracts cleanly. + */ +const grpcFailingIn = + (failing: ReadonlySet) => + async (_executor: unknown, repoPath: unknown): Promise => { + if (failing.has(String(repoPath))) throw new Error('gRPC extraction failed'); + return []; + }; + +const readPersistedRegistry = (dir: string): ContractRegistry => + JSON.parse(fs.readFileSync(path.join(dir, 'contracts.json'), 'utf8')) as ContractRegistry; + +/** `||` — the identity a registry reader cares about. */ +const contractIdentities = (registry: ContractRegistry): string[] => + registry.contracts.map((c) => `${c.repo}|${c.contractId}|${c.role}`); + +describe('syncGroup persisting a manifest link with an unreadable endpoint', () => { + let groupDir: string; + + beforeEach(() => { + httpExtract.mockReset(); + grpcExtract.mockReset(); + // `mockClear`, not `mockReset` — the resolving implementation is what makes + // `await initLbug(...)` a no-op for every other case in this file. + initLbugMock.mockClear(); + // The manifest link is the only contract source in these cases, so the + // per-repo extractors contribute nothing and the registry contains exactly + // what deferred manifest resolution emitted. + httpExtract.mockResolvedValue([]); + groupDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-manifest-')); + }); + + afterEach(() => { + fs.rmSync(groupDir, { recursive: true, force: true }); + }); + + it('names no contract for the repo the same run reported unreadable', async () => { + grpcExtract.mockImplementation(grpcFailingIn(new Set(['/repos/backend']))); + + const result = await syncGroup(linkedConfig(), { + groupDir, + resolveRepoHandle: resolveLinkedHandle, + }); + + expect(result.unreadableRepos).toEqual(['app/backend']); + expect(result.registryOutcome).toBe('written'); + + const onDisk = readPersistedRegistry(groupDir); + expect(onDisk.unreadableRepos).toEqual(['app/backend']); + expect(onDisk.contracts.filter((c) => c.repo === 'app/backend')).toEqual([]); + // Not just the `repo` tag: the manifest fallback uid is `manifest::::…`, + // so a contract can still carry the unreadable repo's name after a filter + // that only looked at one field. + expect(onDisk.contracts.filter((c) => JSON.stringify(c).includes('app/backend'))).toEqual([]); + }); + + it('keeps the healthy endpoint’s own contract from that same link', async () => { + grpcExtract.mockImplementation(grpcFailingIn(new Set(['/repos/backend']))); + + await syncGroup(linkedConfig(), { groupDir, resolveRepoHandle: resolveLinkedHandle }); + + // Byte-identical to the healthy endpoint's line in the all-healthy control + // below — that equality IS the requirement: one endpoint failing costs the + // other nothing. A fix that drops the whole link empties this array. + expect(contractIdentities(readPersistedRegistry(groupDir))).toEqual([ + `app/gateway|${LINK_CONTRACT_ID}|consumer`, + ]); + }); + + it('emits no cross-link for a pair whose other endpoint failed', async () => { + grpcExtract.mockImplementation(grpcFailingIn(new Set(['/repos/backend']))); + + await syncGroup(linkedConfig(), { groupDir, resolveRepoHandle: resolveLinkedHandle }); + + // A cross-link asserts a relationship between two repos. With one of them + // absent from this sync there is nothing to assert it against, and a + // half-anchored link is exactly the "confident about something it could not + // read" answer the registry must not give. + expect(readPersistedRegistry(groupDir).crossLinks).toEqual([]); + }); + + it('emits both contracts and the cross-link when both endpoints are healthy', async () => { + // The control. Without it, "drop everything the link touches" passes every + // case above while deleting a healthy repo's contracts. + grpcExtract.mockImplementation(grpcFailingIn(new Set())); + + const result = await syncGroup(linkedConfig(), { + groupDir, + resolveRepoHandle: resolveLinkedHandle, + }); + + expect(result.unreadableRepos).toEqual([]); + expect(result.registryOutcome).toBe('written'); + + const onDisk = readPersistedRegistry(groupDir); + expect(contractIdentities(onDisk)).toEqual([ + `app/backend|${LINK_CONTRACT_ID}|provider`, + `app/gateway|${LINK_CONTRACT_ID}|consumer`, + ]); + expect(onDisk.crossLinks).toHaveLength(1); + expect(onDisk.crossLinks[0]).toMatchObject({ + from: { repo: 'app/gateway' }, + to: { repo: 'app/backend' }, + type: 'grpc', + contractId: LINK_CONTRACT_ID, + matchType: 'manifest', + }); + }); + + it('does not re-open the index it just reported unreadable', async () => { + // The other half of the fix, and the one a contract-level assertion cannot + // see: the manifest phase derives its known-repo set from `repoHandles`, so + // a failed repo left in that map is re-initialized and queried a second + // time. Filtering the OUTPUT would still hide the contracts while the sync + // went on reading an index it had already told the operator it could not + // read — and, for a window at its residency cap, spending a slot on it. + grpcExtract.mockImplementation(grpcFailingIn(new Set(['/repos/backend']))); + + await syncGroup(linkedConfig(), { groupDir, resolveRepoHandle: resolveLinkedHandle }); + + const openedPools = initLbugMock.mock.calls.map((call) => String(call[0])); + // The gateway is opened twice: once to extract, once for its manifest + // window. The backend is opened once — the extraction attempt that failed — + // and never again. + expect(openedPools).toEqual(['pool-gateway', 'pool-backend', 'pool-gateway']); + }); + + it('tells the operator the endpoint was unreadable, not that it is unconfigured', async () => { + // The two diagnoses need different actions: an unconfigured repo means edit + // group.yaml, an unreadable one means re-index. Reusing the "not in + // config.repos" line for a repo that IS configured sends the operator to + // change a file that is already correct — and its "cross-links will use + // synthetic UIDs" tail describes an outcome that no longer happens, since + // this link's cross-link is dropped outright. + grpcExtract.mockImplementation(grpcFailingIn(new Set(['/repos/backend']))); + const cap = _captureLogger(); + + try { + await syncGroup(linkedConfig(), { groupDir, resolveRepoHandle: resolveLinkedHandle }); + } finally { + cap.restore(); + } + + const linkWarnings = cap + .records() + .filter((r) => r.level === 40) + .map((r) => String(r.msg ?? '')) + .filter((msg) => msg.includes('[group/sync] manifest link')); + + expect(linkWarnings).toHaveLength(1); + expect(linkWarnings[0]).toContain('could not read: app/backend'); + expect(linkWarnings[0]).not.toContain('not in config.repos'); + }); +}); diff --git a/gitnexus/test/unit/group/sync-unreadable-repos.test.ts b/gitnexus/test/unit/group/sync-unreadable-repos.test.ts new file mode 100644 index 000000000..d5b38cfd5 --- /dev/null +++ b/gitnexus/test/unit/group/sync-unreadable-repos.test.ts @@ -0,0 +1,1152 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { _captureLogger } from '../../../src/core/logger.js'; +import type { BridgeHandle, GroupConfig, RepoHandle } from '../../../src/core/group/types.js'; +import { BRIDGE_SCHEMA_VERSION } from '../../../src/core/group/bridge-schema.js'; +import { makeGroupToolPort, writeGroupYaml } from './fixtures.js'; + +/** + * A repo that is registered but whose index cannot be opened must not be + * reported as a MISSING repo, and must not silently replace a good + * contracts.json with an empty one. + * + * The failure this pins: `syncGroup` wrapped `initLbug` + extraction in a bare + * `catch {}` that pushed the repo onto `missingRepos` and discarded the error. + * A LadybugDB storage-version mismatch therefore surfaced as "repo not found", + * `group sync` printed `0 contracts, 0 cross-links` and exited 0, and the + * existing registry was overwritten with an empty one. + * + * Three of these cases exist because mutation testing showed the original four + * could not see the change they were named after: + * - a two-repo case, because with exactly one configured repo + * `unreadableRepos.length === configuredRepoCount` holds whenever anything + * fails, so deleting the `=== configuredRepoCount` conjunct — turning "every + * repo failed" into "any repo failed" — passed everything; + * - an all-missing case, because deleting the `unreadableRepos.length > 0` + * conjunct was caught only by a 9.7 s integration test in another directory; + * - a log assertion, because deleting both `logger.warn` calls — the entire + * stated purpose of the change — passed everything too. + */ + +const LBUG_VERSION_ERROR = + 'LadybugDB unavailable for backend-repo. Another process may be rebuilding the index. ' + + 'Retry later. (Runtime exception: Trying to read a database file with a different version. ' + + 'Database file version: 43, Current build storage version: 40)'; + +const initLbugMock = vi.fn(); +const readRegistryStrictMock = vi.fn(); + +/** + * A SEPARATE mock from the strict one, and that separation is the whole point. + * + * Both exports used to resolve to one mock, so the refuses-to-sync case below — + * which drives the read by rejecting — got the same rejection whichever export + * `syncGroup` called. It would have passed identically against the lenient read + * it exists to rule out, which is to say it measured nothing about which read is + * used. + * + * The implementation here is the lenient export's real contract: `readRegistry` + * swallows EACCES and a corrupt file alike and answers `[]`. Pointing + * `syncGroup` at it therefore turns an unreadable registry back into "no repo is + * registered" — every configured repo MISSING, the total-failure guard off, a + * good contracts.json replaced by an empty one at exit 0 — and the case goes + * red. On this path production reaches the lenient export only under + * `detect.workspace_deps`, which `makeConfig` leaves off, so no other case in + * this file can see the split. + */ +const readRegistryLenientMock = vi.fn(async (..._args: unknown[]): Promise => []); + +vi.mock('../../../src/core/lbug/pool-adapter.js', () => ({ + initLbug: (...args: unknown[]) => initLbugMock(...args), + executeParameterized: vi.fn(async () => []), + pinRepo: vi.fn(() => () => {}), + getMaxResidentRepos: vi.fn(() => 5), +})); + +vi.mock('../../../src/storage/repo-manager.js', () => ({ + readRegistry: (...args: unknown[]) => readRegistryLenientMock(...args), + readRegistryStrict: (...args: unknown[]) => readRegistryStrictMock(...args), +})); + +/** + * Armed by the bridge-write-failure suite at the bottom of this file, `null` + * everywhere else. There is no filesystem shape that makes the real writer fail + * while `writeContractRegistry` — same directory, one line earlier in + * `syncGroup` — still succeeds, and that ordering is the whole subject of the + * warning under test. + */ +let writeBridgeFailure: Error | null = null; + +/** + * Only the read-only OPEN legs are stubbed, so `runGroupImpact` can read the + * metadata a preserve sync just wrote without a native LadybugDB open of a + * placeholder file. The bridge write, `writeBridgeMeta`, `readBridgeMeta` and + * `bridgeMetaMatchesFile` all travel their real implementations — they are the + * code under test here, and `syncGroup` reaches the bridge write through this + * module too. The wrapper below is a pass-through in every test that does not + * arm `writeBridgeFailure`. + * + * It intercepts `writeBridgeUnlocked`, NOT the exported `writeBridge`: the swap + * comes in two halves, and `syncGroup` calls the lock-free one because it is + * already inside `withGroupSyncLock` (a second acquisition of a non-reentrant + * lock would hang every sync). Arming the acquiring wrapper instead would inject + * a fault into a function this path never calls, and the failure branch below + * would go quietly untested. + */ +vi.mock('../../../src/core/group/bridge-db.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + writeBridgeUnlocked: vi.fn(async (...args: Parameters) => { + if (writeBridgeFailure) throw writeBridgeFailure; + return actual.writeBridgeUnlocked(...args); + }), + getCachedBridgeReadOnly: vi.fn( + async (groupDir: string) => + ({ _db: {}, _conn: {}, groupDir, _readOnly: true }) as BridgeHandle, + ), + queryBridge: vi.fn(async () => [] as Array>), + closeBridgeDb: vi.fn(async () => undefined), + }; +}); + +/** + * Armed by the concurrent-sync suite at the bottom of this file, `null` + * everywhere else. It runs INSIDE the real group sync lock — after this sync + * acquired it, before its persist section starts — which is the one window in + * which another sync's write can land: extraction runs OUTSIDE the lock, so a + * sync that queued behind a winner is holding stats it took before the winner + * ever wrote. Nothing in-process can reach that window otherwise, and a rare + * interleave is not a test. + */ +let whileWaitingForTheGroupLock: (() => Promise) | null = null; + +/** + * A pass-through in every test that does not arm the hook: the REAL lock is + * acquired, on the real `/sync-lock`, exactly as production does. + */ +vi.mock('../../../src/core/group/group-lock.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + withGroupSyncLock: (groupDir: string, operation: () => Promise): Promise => + actual.withGroupSyncLock(groupDir, async () => { + const hook = whileWaitingForTheGroupLock; + whileWaitingForTheGroupLock = null; + if (hook) await hook(); + return operation(); + }), + }; +}); + +const { syncGroup } = await import('../../../src/core/group/sync.js'); +const { runGroupImpact } = await import('../../../src/core/group/cross-impact.js'); +const { bridgeMetaMatchesFile, closeAllCachedBridges, readBridgeMeta, writeBridgeMeta } = + await import('../../../src/core/group/bridge-db.js'); + +const registryEntry = (name: string, dir: string) => ({ + name, + path: `/repos/${dir}`, + storagePath: `/repos/${dir}/.gitnexus`, + indexedAt: '2026-01-01T00:00:00.000Z', + lastCommit: 'abc123', +}); + +const REGISTRY = [registryEntry('backend-repo', 'backend'), registryEntry('web-repo', 'web')]; + +const makeConfig = (repos: Record): GroupConfig => ({ + version: 1, + name: 'test', + description: '', + repos, + links: [], + packages: {}, + detect: { + http: true, + grpc: false, + thrift: false, + topics: false, + shared_libs: false, + embedding_fallback: false, + includes: false, + workspace_deps: false, + }, + matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, +}); + +/** + * Resolve handles from a table keyed on the registry name, so a multi-repo case + * needs no branching inside the test body. An unknown name resolves to `null`, + * which is the production "not in the registry" answer. + */ +const handleTable = (names: readonly string[]) => { + const byName = new Map( + names.map((name) => [ + name, + { + id: `pool-${name}`, + path: `/repos/${name}`, + repoPath: `/repos/${name}`, + storagePath: `/repos/${name}/.gitnexus`, + }, + ]), + ); + return async (registryName: string): Promise => + byName.get(registryName) ?? null; +}; + +/** `initLbug` is called with the pool id, so failures can be keyed on the repo. */ +const failInitFor = (failingPoolIds: ReadonlySet) => async (poolId: unknown) => { + if (failingPoolIds.has(String(poolId))) throw new Error(LBUG_VERSION_ERROR); +}; + +const PRIOR_REGISTRY = { + version: 1, + generatedAt: '2026-01-01T00:00:00.000Z', + repoSnapshots: {}, + missingRepos: [], + contracts: [{ contractId: 'http::GET::/api/users' }], + crossLinks: [{ contractId: 'http::GET::/api/users' }], +}; + +/** + * The one warning that describes the RUN rather than a single repo: it is the + * only record carrying the whole-run repo lists. The per-repo load failures + * logged beside it carry `repo` / `groupPath` instead, so selecting on the list + * field cannot pick one of those up by accident. + */ +const totalFailureWarning = (cap: ReturnType) => + cap.records().find((r) => r.level === 40 && Array.isArray(r.unreadableRepos)); + +/** + * Read a file's bytes and its stat through ONE open handle. + * + * `stat(path)` followed by `readFile(path)` is two independent path + * resolutions with a window between them — a real check-then-use race, and one + * CodeQL flags as `js/file-system-race`. It also makes the assertion weaker + * than it reads: the two calls can land on different inodes, so "the bytes and + * the mtime are both unchanged" would not actually be a statement about one + * file. Since these tests exist to prove a specific file was left alone, that + * distinction is the whole point rather than a technicality. + * + * One handle, both answers, no second lookup. + */ +const snapshotFile = async ( + filePath: string, +): Promise<{ text: string; size: number; mtimeMs: number }> => { + const handle = await fsp.open(filePath, 'r'); + try { + const [bytes, stat] = await Promise.all([handle.readFile(), handle.stat()]); + return { text: bytes.toString('utf8'), size: stat.size, mtimeMs: stat.mtimeMs }; + } finally { + await handle.close(); + } +}; + +describe('syncGroup with an unreadable index', () => { + let groupDir: string; + + beforeEach(() => { + initLbugMock.mockReset(); + readRegistryStrictMock.mockReset(); + readRegistryStrictMock.mockResolvedValue(REGISTRY); + // `mockClear`, not `mockReset`: the lenient answer IS its implementation + // (see its declaration), so resetting would erase the very behaviour that + // makes calling it distinguishable from calling the strict one. + readRegistryLenientMock.mockClear(); + groupDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-unreadable-')); + }); + + afterEach(() => { + fs.rmSync(groupDir, { recursive: true, force: true }); + }); + + it('reports an unopenable index as unreadable, not missing', async () => { + initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR)); + + const result = await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { + skipWrite: true, + }); + + expect(result.unreadableRepos).toEqual(['app/backend']); + // The repo IS registered — calling it "missing" sends the operator to + // `gitnexus analyze` for a problem that indexing will not fix. + expect(result.missingRepos).toEqual([]); + }); + + it('still reports a genuinely unregistered repo as missing', async () => { + const result = await syncGroup(makeConfig({ 'app/ghost': 'not-in-registry' }), { + skipWrite: true, + }); + + expect(result.missingRepos).toEqual(['app/ghost']); + expect(result.unreadableRepos).toEqual([]); + }); + + it('logs the underlying load error, with the repo it belongs to', async () => { + initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR)); + const cap = _captureLogger(); + + try { + await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { skipWrite: true }); + } finally { + cap.restore(); + } + + // The whole point of the change is that this error reaches the operator. + // Asserting only on `unreadableRepos` left both `logger.warn` calls + // deletable with every test still green. + const warnings = cap.records().filter((r) => r.level === 40); + const loadFailure = warnings.find((r) => String(r.repo ?? '') === 'backend-repo'); + + expect(loadFailure).toBeDefined(); + expect(String(loadFailure?.groupPath)).toBe('app/backend'); + expect(JSON.stringify(loadFailure?.err)).toContain('Current build storage version'); + }); + + it('preserves the previous contracts and refreshes the diagnostics when nothing could be read', async () => { + initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR)); + + const contractsPath = path.join(groupDir, 'contracts.json'); + fs.writeFileSync(contractsPath, JSON.stringify(PRIOR_REGISTRY)); + + const result = await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir }); + + expect(result.unreadableRepos).toEqual(['app/backend']); + expect(result.registryOutcome).toBe('preserved'); + + const onDisk = JSON.parse(fs.readFileSync(contractsPath, 'utf8')) as Record; + // The contracts are the previous run's and are kept verbatim — an + // extraction that read nothing is not evidence that the group has none. + expect(onDisk.contracts).toEqual(PRIOR_REGISTRY.contracts); + expect(onDisk.crossLinks).toEqual(PRIOR_REGISTRY.crossLinks); + // `generatedAt` dates the contracts, which did not change, so it does not + // move either — otherwise `group status` would claim this run produced them. + expect(onDisk.generatedAt).toBe(PRIOR_REGISTRY.generatedAt); + // ...but the diagnostic describing THIS run is refreshed, which is what + // makes `gitnexus group status` able to explain the failure afterwards. + expect(onDisk.unreadableRepos).toEqual(['app/backend']); + }); + + it('writes nothing at all when there is no previous registry to preserve', async () => { + initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR)); + + const result = await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir }); + + // NOT `preserved`. Nothing exists to preserve, and the CLI turns that word + // into "the contracts from the previous sync are preserved" — which sends + // an operator whose group has never synced looking for a file that has + // never existed. Same class of confident-wrong-answer as the rest of this. + expect(result.registryOutcome).toBe('no-prior-registry'); + expect(fs.existsSync(path.join(groupDir, 'contracts.json'))).toBe(false); + }); + + it('reports `preserved` only when a prior registry was actually refreshed', async () => { + initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR)); + fs.writeFileSync(path.join(groupDir, 'contracts.json'), JSON.stringify(PRIOR_REGISTRY)); + + const result = await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir }); + + expect(result.registryOutcome).toBe('preserved'); + }); + + it('does not report `preserved` when the prior registry will not parse', async () => { + // An unparseable prior is not a thing that got carried forward either. + initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR)); + fs.writeFileSync(path.join(groupDir, 'contracts.json'), '{"truncated": '); + + const result = await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir }); + + expect(result.registryOutcome).toBe('no-prior-registry'); + // ...and the unparseable file is left exactly as it was, not replaced. + expect(fs.readFileSync(path.join(groupDir, 'contracts.json'), 'utf8')).toBe('{"truncated": '); + }); + + it('names the previous sync in the total-failure warning when a prior registry was kept', async () => { + // This warning used to be emitted BEFORE the prior registry was resolved, + // so it promised "the contracts from the previous sync" without knowing + // whether there were any. This is the branch on which that promise is true. + initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR)); + fs.writeFileSync(path.join(groupDir, 'contracts.json'), JSON.stringify(PRIOR_REGISTRY)); + const cap = _captureLogger(); + + let result; + try { + result = await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir }); + } finally { + cap.restore(); + } + + const warning = totalFailureWarning(cap); + expect(warning).toBeDefined(); + // The log and the console say the same thing about which of the two + // happened: the CLI picks its sentence from `registryOutcome`, and this is + // the outcome whose sentence keeps the previous contracts. + expect(result.registryOutcome).toBe('preserved'); + expect(String(warning?.msg)).toContain('previous sync'); + // Still a warning, still carrying the lists that name the cause. + expect(warning?.level).toBe(40); + expect(warning?.unreadableRepos).toEqual(['app/backend']); + expect(warning?.missingRepos).toEqual([]); + }); + + it('does not claim anything was preserved when there is no prior registry', async () => { + // The same total failure with nothing on disk to preserve. The warning said + // the contracts from the previous sync were being kept — to an operator + // whose group has never synced, about a file that has never existed, while + // the console line for this same run says the opposite. What the message + // says about disk has to be what happened on it. + initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR)); + const cap = _captureLogger(); + + let result; + try { + result = await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir }); + } finally { + cap.restore(); + } + + const warning = totalFailureWarning(cap); + expect(warning).toBeDefined(); + expect(result.registryOutcome).toBe('no-prior-registry'); + expect(String(warning?.msg)).not.toMatch(/previous sync|preserv|keeping|kept/i); + expect(String(warning?.msg)).toContain('no previous contracts.json'); + // ...and it is still a warning carrying the same lists as the branch above. + expect(warning?.level).toBe(40); + expect(warning?.unreadableRepos).toEqual(['app/backend']); + expect(warning?.missingRepos).toEqual([]); + }); + + it('still writes when only SOME configured repos are unreadable', async () => { + // The case that pins the word "every" in `everyRepoFailed`. With a single + // configured repo, "every repo failed" and "any repo failed" are the same + // predicate, so the guard could be widened to abort on a single skewed repo + // in a five-repo group — silently freezing contracts.json forever — with + // nothing going red. + initLbugMock.mockImplementation(failInitFor(new Set(['pool-backend-repo']))); + + const result = await syncGroup( + makeConfig({ 'app/backend': 'backend-repo', 'app/web': 'web-repo' }), + { groupDir, resolveRepoHandle: handleTable(['backend-repo', 'web-repo']) }, + ); + + expect(result.unreadableRepos).toEqual(['app/backend']); + expect(result.missingRepos).toEqual([]); + expect(result.registryOutcome).toBe('written'); + + const onDisk = JSON.parse( + fs.readFileSync(path.join(groupDir, 'contracts.json'), 'utf8'), + ) as Record; + // The partial result records which repo is unaccounted for, so a reader of + // contracts.json can tell a small registry from a complete one. + expect(onDisk.unreadableRepos).toEqual(['app/backend']); + }); + + it('records an empty unreadable list on a clean sync, not an absent one', async () => { + // `[]` is a measurement — "this sync accounted for every repo" — and it is + // a different claim from a registry that never recorded the field. Omitting + // the empty case made that state unreachable: every clean sync wrote a + // registry whose `unreadableRepos` was absent, so `gitnexus group status` + // reported it as not recorded and told the operator to re-run the sync that + // had just succeeded. + const result = await syncGroup( + makeConfig({ 'app/backend': 'backend-repo', 'app/web': 'web-repo' }), + { groupDir, resolveRepoHandle: handleTable(['backend-repo', 'web-repo']) }, + ); + + expect(result.unreadableRepos).toEqual([]); + expect(result.registryOutcome).toBe('written'); + + const onDisk = JSON.parse( + fs.readFileSync(path.join(groupDir, 'contracts.json'), 'utf8'), + ) as Record; + expect(onDisk).toHaveProperty('unreadableRepos'); + expect(onDisk.unreadableRepos).toEqual([]); + }); + + it('still writes when every repo is merely MISSING and none failed to load', async () => { + // A group whose repos were all deregistered legitimately syncs to empty. + // The guard must stay off here: it is gated on a load ERROR, not on an + // empty result. Dropping the `unreadableRepos.length > 0` conjunct would + // turn a deliberate deregistration into a registry frozen forever. + const result = await syncGroup( + makeConfig({ 'app/ghost': 'not-in-registry', 'app/phantom': 'also-absent' }), + { groupDir }, + ); + + expect(result.unreadableRepos).toEqual([]); + expect(result.missingRepos).toEqual(['app/ghost', 'app/phantom']); + expect(result.registryOutcome).toBe('written'); + expect(fs.existsSync(path.join(groupDir, 'contracts.json'))).toBe(true); + }); + + it('does not claim to preserve a file on a dry run', async () => { + initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR)); + const cap = _captureLogger(); + + let result; + try { + result = await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { skipWrite: true }); + } finally { + cap.restore(); + } + + expect(result.registryOutcome).toBe('not-attempted'); + // The total-failure warning talks about an existing contracts.json. A + // caller that asked not to write may not even have a group directory, so + // telling it the file was left untouched describes a file that need not + // exist. + // Selected on the sentence both total-failure messages share, so this stays + // decisive whichever of the two the persisting path would have emitted. + const totalFailureWarnings = cap + .records() + .filter((r) => String(r.msg ?? '').includes('No repo in this group could be read')); + expect(totalFailureWarnings).toEqual([]); + }); + + it('refuses to sync when the global registry cannot be read', async () => { + // `readRegistry` swallows every failure and returns `[]`, so an EACCES or a + // truncated registry.json presented as "no repo is registered": every + // configured repo resolved to MISSING, the total-failure guard stayed off + // (it needs a load error), and a good contracts.json was replaced by an + // empty one at exit 0. That is an unreadable condition reported as missing, + // one frame above the code this change fixes. + // + // Only the STRICT export is armed to reject. The lenient one is a separate + // mock answering `[]` — production's own lenient behaviour — so a `syncGroup` + // reading through it never sees this failure at all: it would sync a group + // whose every repo is "unregistered" and overwrite the prior registry, which + // is what makes each of the three assertions below a statement about which + // read was used rather than about EACCES. + const eacces = Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }); + readRegistryStrictMock.mockRejectedValue(eacces); + + const contractsPath = path.join(groupDir, 'contracts.json'); + fs.writeFileSync(contractsPath, JSON.stringify(PRIOR_REGISTRY)); + + await expect( + syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir }), + ).rejects.toThrow('EACCES'); + + expect(JSON.parse(fs.readFileSync(contractsPath, 'utf8'))).toEqual(PRIOR_REGISTRY); + // The direct form of the same claim, so a regression names itself instead of + // arriving as "expected a rejection, got a resolved sync". + expect(readRegistryLenientMock).not.toHaveBeenCalled(); + expect(readRegistryStrictMock).toHaveBeenCalled(); + }); +}); + +/** + * The preserve path rewrites `contracts.json` and deliberately does NOT rebuild + * `bridge.lbug` — the contracts that bridge holds are the ones being preserved, + * so rebuilding it from an extraction that read nothing is the one write that + * could lose them. + * + * But `meta.json`, not `contracts.json`, is where `runGroupImpact` reads + * completeness from. Leaving it alone therefore left the two files telling + * different stories: `contracts.json` said "this sync could not read app/backend" + * while a cross-repo query, reading the previous sync's metadata, answered + * `{ cross: [], truncated: false }` — fully accounted for. That is a confident + * wrong answer about the exact thing the completeness channel exists to make + * legible, and it is what R6 forbids. + * + * Refreshing the metadata is not free, though, and the naive version of it is + * worse than the bug. This file rewrites `meta.json` ATOMICALLY, so its mtime + * becomes now while `bridge.lbug`'s stays old — which is precisely the shape + * the unstamped write-order rule ACCEPTS. A refresh that just carried the old + * fields forward would therefore LAUNDER a pair that was already broken into + * one that passes `bridgeMetaMatchesFile`. Hence the explicit marker, and hence + * the cases below that pin a broken pair as still broken afterwards. + */ +describe('the preserve path and the bridge metadata beside it', () => { + let home: string; + let groupDir: string; + let dbPath: string; + let metaPath: string; + + /** Fixed, whole-second instants — exactly representable on any filesystem. */ + const WRITTEN_AT = new Date('2026-01-01T00:00:00.000Z'); + const TEN_SECONDS_LATER = new Date('2026-01-01T00:00:10.000Z'); + const PRIOR_META_GENERATED_AT = '2026-01-01T00:00:00.000Z'; + + beforeEach(async () => { + initLbugMock.mockReset(); + readRegistryStrictMock.mockReset(); + readRegistryStrictMock.mockResolvedValue(REGISTRY); + home = await fsp.mkdtemp(path.join(os.tmpdir(), 'gitnexus-preserve-bridge-')); + groupDir = path.join(home, 'groups', 'waveful'); + dbPath = path.join(groupDir, 'bridge.lbug'); + metaPath = path.join(groupDir, 'meta.json'); + await writeGroupYaml(groupDir, ['app/backend']); + }); + + afterEach(async () => { + await closeAllCachedBridges(); + await fsp.rm(home, { recursive: true, force: true }); + }); + + const seedPriorRegistry = (): Promise => + fsp.writeFile(path.join(groupDir, 'contracts.json'), JSON.stringify(PRIOR_REGISTRY)); + + /** A stamped pair that matches: what a successful `writeBridge` leaves behind. */ + const seedMatchingPair = async (): Promise => { + await fsp.writeFile(dbPath, 'the previous sync database'); + const stat = await fsp.stat(dbPath); + await writeBridgeMeta(groupDir, { + version: BRIDGE_SCHEMA_VERSION, + generatedAt: PRIOR_META_GENERATED_AT, + bridgeSize: stat.size, + bridgeMtimeMs: stat.mtimeMs, + missingRepos: [], + unreadableRepos: [], + }); + }; + + /** + * The legacy shape, broken: metadata with no stamp sitting beside a database + * that was replaced after it. `unstampedMetaPairsByWriteOrder` is the ONLY + * thing that can see this, and it sees it purely through the two file times — + * which is why an atomic rewrite of `meta.json` erases the evidence. + */ + const seedUnstampedPairWithNewerDatabase = async (): Promise => { + await fsp.writeFile(dbPath, 'a database swapped in after the metadata'); + await writeBridgeMeta(groupDir, { + version: BRIDGE_SCHEMA_VERSION, + generatedAt: PRIOR_META_GENERATED_AT, + missingRepos: [], + unreadableRepos: [], + }); + await fsp.utimes(metaPath, WRITTEN_AT, WRITTEN_AT); + await fsp.utimes(dbPath, TEN_SECONDS_LATER, TEN_SECONDS_LATER); + }; + + const runTotalFailureSync = () => { + initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR)); + return syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir }); + }; + + const runSuccessfulSync = () => { + initLbugMock.mockReset(); + return syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { + groupDir, + resolveRepoHandle: handleTable(['backend-repo']), + }); + }; + + const runImpact = () => + runGroupImpact( + { port: makeGroupToolPort(home), gitnexusDir: home }, + { name: 'waveful', repo: 'app/backend', target: 'publish', direction: 'upstream' }, + ); + + const pairsAfterwards = async (): Promise => + bridgeMetaMatchesFile(groupDir, await readBridgeMeta(groupDir)); + + it('reports the repos this sync could not read as a lower bound on the next cross-repo query', async () => { + // The headline case, and the one that makes `contracts.json` and `group + // impact` describe the same set of unaccounted repos. Every other signal + // says "complete": the local walk finished, the bridge returned no + // crossings, no cap and no clock fired. + await seedPriorRegistry(); + await seedMatchingPair(); + + const result = await runTotalFailureSync(); + expect(result.registryOutcome).toBe('preserved'); + + const impact = await runImpact(); + + expect(impact).toMatchObject({ + cross: [], + truncated: true, + truncationReason: 'incomplete-sync', + riskEpistemic: 'lower-bound', + truncatedRepos: ['app/backend'], + }); + }); + + it('leaves the bridge database itself byte-for-byte untouched', async () => { + // The contracts this bridge holds are the ones being preserved. A refresh + // that rebuilt it would be the single write capable of losing them. + await seedPriorRegistry(); + await seedMatchingPair(); + const before = await snapshotFile(dbPath); + + await runTotalFailureSync(); + + const after = await snapshotFile(dbPath); + expect(after.text).toBe(before.text); + expect(after.size).toBe(before.size); + expect(after.mtimeMs).toBe(before.mtimeMs); + }); + + it('keeps a stamped pair that already failed the check failing, with its stamp untouched', async () => { + // Re-stamping here would MANUFACTURE provenance: it would declare that this + // metadata describes the database beside it, which is the one thing the + // failed check just said is not known. The stamp fields are carried through + // verbatim instead — dropping them would leave an unstamped file whose + // freshly-moved mtime the write-order rule then accepts. + await seedPriorRegistry(); + await fsp.writeFile(dbPath, 'a database this metadata was never written for'); + await writeBridgeMeta(groupDir, { + version: BRIDGE_SCHEMA_VERSION, + generatedAt: PRIOR_META_GENERATED_AT, + bridgeSize: 999_999, + bridgeMtimeMs: 1_700_000_000_000, + missingRepos: [], + unreadableRepos: [], + }); + expect(await pairsAfterwards()).toBe(false); + + await runTotalFailureSync(); + + const after = await readBridgeMeta(groupDir); + expect(after.bridgeSize).toBe(999_999); + expect(after.bridgeMtimeMs).toBe(1_700_000_000_000); + expect(after.provenanceUnknown).toBe(true); + expect(after.unreadableRepos).toEqual(['app/backend']); + expect(await pairsAfterwards()).toBe(false); + }); + + it('keeps an unstamped pair that already failed the check failing, though the rewrite moves meta.json to now', async () => { + // The laundering case, and the only shape that exercises the write-order + // rule. Before the sync the database is NEWER than the metadata beside it, + // which is the inverted write order that rule rejects. The atomic rewrite + // then makes `meta.json` the newer file — the exact shape it ACCEPTS — so + // without an explicit marker a preserve sync would hand back "verified" for + // a pair it just found broken. + await seedPriorRegistry(); + await seedUnstampedPairWithNewerDatabase(); + expect(await pairsAfterwards()).toBe(false); + + await runTotalFailureSync(); + + const dbStat = await fsp.stat(dbPath); + const metaStat = await fsp.stat(metaPath); + // The evidence the write-order rule reads has genuinely been inverted... + expect(metaStat.mtimeMs).toBeGreaterThanOrEqual(dbStat.mtimeMs); + const after = await readBridgeMeta(groupDir); + // ...no stamp was invented to replace it... + expect(after.bridgeSize).toBeUndefined(); + expect(after.bridgeMtimeMs).toBeUndefined(); + // ...and the verdict survives in the metadata, which is the only place it + // can, because the refresh cannot avoid moving the mtime. + expect(after.provenanceUnknown).toBe(true); + expect(await pairsAfterwards()).toBe(false); + + const impact = await runImpact(); + expect(impact).toMatchObject({ + truncated: true, + truncationReason: 'incomplete-sync', + riskEpistemic: 'lower-bound', + }); + }); + + it('never writes either reader-side field back into meta.json', async () => { + // `repoListsUnreadable` and `pairedWithDatabase` are things a READER + // computes ABOUT a file; both are documented NEVER PERSISTED. This path is + // the first code to read metadata and write it back, so a naive + // `writeBridgeMeta(await readBridgeMeta(dir))` persists whichever of them + // the read produced. A stale `pairedWithDatabase: true` on disk is actively + // poisonous: it tells every future reader the pair was verified. + await seedPriorRegistry(); + await fsp.writeFile(dbPath, 'db'); + await fsp.writeFile( + metaPath, + JSON.stringify({ + version: BRIDGE_SCHEMA_VERSION, + generatedAt: PRIOR_META_GENERATED_AT, + // Not a list of repo paths, so `readBridgeMeta` answers with + // `repoListsUnreadable: true` on the object this path then rewrites. + missingRepos: { 'app/backend': true }, + // A foreign writer, a hand-edit, or an earlier naive round-trip. + pairedWithDatabase: true, + }), + ); + + await runTotalFailureSync(); + + const raw = JSON.parse(await fsp.readFile(metaPath, 'utf8')) as Record; + expect(raw).not.toHaveProperty('pairedWithDatabase'); + expect(raw).not.toHaveProperty('repoListsUnreadable'); + // ...and the unusable list was replaced by this run's real measurement, + // rather than being carried forward as garbage. + expect(raw.missingRepos).toEqual([]); + expect(raw.unreadableRepos).toEqual(['app/backend']); + }); + + it('completes when there is no bridge.lbug for the metadata to describe', async () => { + // `writeBridge` can leave this behind: the old database is renamed aside + // and the new one never arrives. The refresh must not stat a file that is + // not there, and must not vouch for one either. + await seedPriorRegistry(); + await writeBridgeMeta(groupDir, { + version: BRIDGE_SCHEMA_VERSION, + generatedAt: PRIOR_META_GENERATED_AT, + bridgeSize: 4096, + bridgeMtimeMs: 1_700_000_000_000, + missingRepos: [], + unreadableRepos: [], + }); + + const result = await runTotalFailureSync(); + + expect(result.registryOutcome).toBe('preserved'); + expect(fs.existsSync(dbPath)).toBe(false); + const after = await readBridgeMeta(groupDir); + expect(after.provenanceUnknown).toBe(true); + // Carried through verbatim: the stamp still records which database this + // metadata was written for, which is information, not a claim about what + // is on disk now. + expect(after.bridgeSize).toBe(4096); + expect(after.bridgeMtimeMs).toBe(1_700_000_000_000); + expect(after.unreadableRepos).toEqual(['app/backend']); + + // The query that follows answers rather than throwing. With no database + // there is nothing to answer FROM, so it names the missing file and sends + // the operator to `group sync` — never a confident "nothing depends on + // this". (The lower-bound answer is the shape above, where a database IS + // present and the marker is what stops it being trusted.) + const impact = await runImpact(); + expect(impact).toMatchObject({ error: expect.stringContaining('No bridge.lbug') }); + }); + + it('does not manufacture metadata for a bridge that has never existed', async () => { + // Neither file is on disk, so there is no pair that could disagree with + // anything and nothing to keep honest. `readBridgeMeta` already answers + // `version: 0` — provenance unknown — for an absent file, and writing a + // `version: 0` file that says the same thing only invents state. + await seedPriorRegistry(); + + await runTotalFailureSync(); + + expect(fs.existsSync(metaPath)).toBe(false); + expect(fs.existsSync(dbPath)).toBe(false); + }); + + it('records this run against a database whose metadata is missing entirely', async () => { + // The other half of the pair being absent. The database is real and the + // metadata is gone — provenance is already unknown, and staying silent + // costs the operator the NAMES of the repos this run could not read. + await seedPriorRegistry(); + await fsp.writeFile(dbPath, 'a database with no metadata beside it'); + + await runTotalFailureSync(); + + const after = await readBridgeMeta(groupDir); + expect(after.provenanceUnknown).toBe(true); + expect(after.unreadableRepos).toEqual(['app/backend']); + expect(await pairsAfterwards()).toBe(false); + }); + + it('does not launder the pair it already marked on a second preserve run', async () => { + // The invariant in its strongest form: no preserve run ever increases the + // number of pairs that pass the check. The second run reads metadata that + // now carries the marker, and the marker has to survive its own rewrite. + await seedPriorRegistry(); + await seedUnstampedPairWithNewerDatabase(); + + await runTotalFailureSync(); + expect(await pairsAfterwards()).toBe(false); + + await runTotalFailureSync(); + + const after = await readBridgeMeta(groupDir); + expect(after.provenanceUnknown).toBe(true); + expect(await pairsAfterwards()).toBe(false); + }); + + it('clears the marker on the next successful sync', async () => { + // Nothing clears the marker deliberately: a successful `writeBridge` + // builds fresh metadata from a literal and simply never sets the field. + // That is what keeps a marked bridge from being marked forever. + await seedPriorRegistry(); + await seedUnstampedPairWithNewerDatabase(); + await runTotalFailureSync(); + expect((await readBridgeMeta(groupDir)).provenanceUnknown).toBe(true); + + const ok = await runSuccessfulSync(); + + expect(ok.registryOutcome).toBe('written'); + const after = await readBridgeMeta(groupDir); + expect(after.provenanceUnknown).toBeUndefined(); + expect(await pairsAfterwards()).toBe(true); + }); + + it('control: a successful sync writes a pair that passes the check, unmarked', async () => { + // Without this, "the marker is absent after a good sync" could be true + // because the marker is absent from everything. + const ok = await runSuccessfulSync(); + + expect(ok.registryOutcome).toBe('written'); + const after = await readBridgeMeta(groupDir); + expect(after.provenanceUnknown).toBeUndefined(); + expect(after.unreadableRepos).toEqual([]); + expect(await pairsAfterwards()).toBe(true); + }); +}); + +/** + * The branch taken when `writeBridge` throws. `contracts.json` has already been + * written and is canonical by then, so the failure is a recoverable degradation + * — and the warning is the ONLY thing this branch produces. The return value, + * `registryOutcome` and every file on disk are identical whether the sentence + * is true or not, which is why the text needs an assertion of its own instead of + * borrowing a state assertion from elsewhere in this file. + */ +describe('the warning after a failed bridge write', () => { + let groupDir: string; + + beforeEach(() => { + initLbugMock.mockReset(); + readRegistryStrictMock.mockReset(); + readRegistryStrictMock.mockResolvedValue(REGISTRY); + writeBridgeFailure = null; + groupDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-bridge-write-')); + }); + + afterEach(async () => { + writeBridgeFailure = null; + await closeAllCachedBridges(); + fs.rmSync(groupDir, { recursive: true, force: true }); + }); + + const runSync = () => + syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { + groupDir, + resolveRepoHandle: handleTable(['backend-repo']), + }); + + /** The bridge-failure warning is the only record carrying `groupDir`. */ + const bridgeWarning = (cap: ReturnType) => + cap.records().find((r) => r.level === 40 && typeof r.groupDir === 'string'); + + it('names the registry as intact and does not promise a truncation this branch never reports', async () => { + writeBridgeFailure = new Error('ENOSPC: no space left on device'); + const cap = _captureLogger(); + + let result; + try { + result = await runSync(); + } finally { + cap.restore(); + } + + // The registry write happens before the bridge write and is not rolled back + // — the reason this failure is survivable at all. + expect(result.registryOutcome).toBe('written'); + + const warning = bridgeWarning(cap); + expect(warning).toBeDefined(); + expect(String(warning?.msg)).toContain('contracts.json is intact'); + // The claim the code does not keep. A failed `writeBridge` leaves the + // PREVIOUS sync's database and the metadata stamped for it untouched, so the + // next cross-repo query reads a pair that checks out, finds no unreadable + // repos recorded in it, and answers `truncated: false` — from contracts this + // sync has already superseded. Nothing on this branch marks the bridge at + // all, so telling the operator to wait for `truncated` is telling them to + // wait for a signal that is never coming. + expect(String(warning?.msg)).not.toMatch(/truncat/i); + // What the code does guarantee instead: the registry is the good copy, the + // bridge may still answer from the previous sync, and only another sync + // replaces it. + expect(String(warning?.msg)).toMatch(/previous sync/i); + expect(String(warning?.msg)).toContain('group sync'); + // ...and the underlying failure still reaches the operator. + expect(String(warning?.err)).toContain('ENOSPC'); + }); + + it('control: a sync whose bridge write succeeds emits no such warning', async () => { + // Without this, "the warning does not promise a truncation" could be true + // because no warning is emitted on any run at all. + const cap = _captureLogger(); + + let result; + try { + result = await runSync(); + } finally { + cap.restore(); + } + + expect(result.registryOutcome).toBe('written'); + expect(bridgeWarning(cap)).toBeUndefined(); + }); +}); + +/** + * R9, the half a lock does not fix: serializing is not ordering. + * + * Both syncs run EXTRACTION outside the lock and only the persist section + * inside it, so a total-failure sync that queues behind a healthy one arrives at + * the critical section holding a snapshot of a group it read minutes ago. The + * preserve path then re-reads `contracts.json` as `prior` — and the file it + * finds is the winner's, written while this run waited — and stamps its own + * all-unreadable lists over it. That is not a rare interleave: it is what + * happens every time the total-failure sync loses the race, and it downgrades a + * registry that describes repos that were readable seconds earlier. + * + * The guard is a compare-and-swap on the prior file's own identity: stat it + * BEFORE acquiring, re-stat AFTER, and skip the diagnostic refresh when the two + * differ. Keyed on the file, not on `generatedAt`: that field is stamped when + * the registry object is built (before the lock), so a winner that waited writes + * one OLDER than the loser's start, and the preserve path carries it forward + * verbatim by design — after any preserve sync it does not date the write at + * all. File identity also needs no cross-process clock agreement and has no + * undefined case for an absent or unparseable timestamp. + */ +describe('a total-failure sync that reaches the group lock second', () => { + let groupDir: string; + let contractsPath: string; + let dbPath: string; + let metaPath: string; + + /** What the sync that won the lock wrote while this one was still waiting. */ + const WINNER_REGISTRY = { + version: 1, + generatedAt: '2026-02-02T00:00:00.000Z', + repoSnapshots: {}, + missingRepos: [], + unreadableRepos: [], + contracts: [{ contractId: 'http::GET::/api/users' }, { contractId: 'http::POST::/api/users' }], + crossLinks: [{ contractId: 'http::GET::/api/users' }], + }; + + beforeEach(() => { + initLbugMock.mockReset(); + readRegistryStrictMock.mockReset(); + readRegistryStrictMock.mockResolvedValue(REGISTRY); + whileWaitingForTheGroupLock = null; + groupDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-sync-second-')); + contractsPath = path.join(groupDir, 'contracts.json'); + dbPath = path.join(groupDir, 'bridge.lbug'); + metaPath = path.join(groupDir, 'meta.json'); + }); + + afterEach(async () => { + whileWaitingForTheGroupLock = null; + await closeAllCachedBridges(); + fs.rmSync(groupDir, { recursive: true, force: true }); + }); + + const runTotalFailureSync = () => { + initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR)); + return syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir }); + }; + + const seedPriorRegistry = (): void => + fs.writeFileSync(contractsPath, JSON.stringify(PRIOR_REGISTRY)); + + /** The winner's write, landing while this sync waits on the lock. */ + const winnerWritesTheRegistry = async (): Promise => { + fs.writeFileSync(contractsPath, JSON.stringify(WINNER_REGISTRY)); + }; + + const readOnDisk = (): Record => + JSON.parse(fs.readFileSync(contractsPath, 'utf8')) as Record; + + it('leaves the registry the winning sync wrote exactly as it found it, and reports superseded', async () => { + seedPriorRegistry(); + whileWaitingForTheGroupLock = winnerWritesTheRegistry; + + const result = await runTotalFailureSync(); + + // Byte-identical to what the winner wrote. NOT the winner's contracts with + // this run's all-unreadable list stamped over them, which is what re-reading + // `prior` inside the lock produces — a registry that says every repo in the + // group is unreadable, written on top of a sync that had just read them. + expect(fs.readFileSync(contractsPath, 'utf8')).toBe(JSON.stringify(WINNER_REGISTRY)); + expect(readOnDisk().unreadableRepos).toEqual([]); + // The existing outcome, not a new one: nothing was written and a prior + // registry was kept, which is exactly what `preserved` already means. A new + // value would fall through `cli/group.ts`'s outcome chain, which has no + // fallback branch, and falsify the guard asserting the sync tool's + // description names every reachable outcome. + expect(result.registryOutcome).toBe('superseded'); + // ...and the caller still learns what THIS run could not read. + expect(result.unreadableRepos).toEqual(['app/backend']); + }); + + it('treats a registry that was absent before the lock and present after as changed', async () => { + // No prior file at all when this sync stat'd: on its own reading it was + // heading for `no-prior-registry` (write nothing), and then found a registry + // to "refresh" — one belonging to a sync it never overlapped in extraction. + whileWaitingForTheGroupLock = winnerWritesTheRegistry; + + const result = await runTotalFailureSync(); + + expect(fs.readFileSync(contractsPath, 'utf8')).toBe(JSON.stringify(WINNER_REGISTRY)); + expect(readOnDisk().unreadableRepos).toEqual([]); + expect(result.registryOutcome).toBe('superseded'); + }); + + it('does not stamp this run into the bridge metadata beside the registry it skipped', async () => { + // `meta.json` is where `runGroupImpact` reads completeness from, so writing + // this run's lists there is the same downgrade one file over: it would + // report repos as unaccounted for that the winning sync had just accounted + // for. The refresh describes THIS run, and on this path this run is the + // stale one — and `refreshPreservedBridgeMeta` moves meta.json's mtime and + // can mark a pair `provenanceUnknown`, so it can only degrade a pair the + // winner left consistent. Nothing is written, which is what makes + // `preserved` an honest answer here. + fs.writeFileSync(dbPath, 'the winning sync database'); + const dbStat = fs.statSync(dbPath); + await writeBridgeMeta(groupDir, { + version: BRIDGE_SCHEMA_VERSION, + generatedAt: '2026-02-02T00:00:00.000Z', + bridgeSize: dbStat.size, + bridgeMtimeMs: dbStat.mtimeMs, + missingRepos: [], + unreadableRepos: [], + }); + const metaBefore = await snapshotFile(metaPath); + seedPriorRegistry(); + whileWaitingForTheGroupLock = winnerWritesTheRegistry; + + await runTotalFailureSync(); + + const metaAfter = await snapshotFile(metaPath); + expect(metaAfter.text).toBe(metaBefore.text); + expect(metaAfter.mtimeMs).toBe(metaBefore.mtimeMs); + const meta = await readBridgeMeta(groupDir); + expect(meta.unreadableRepos).toEqual([]); + expect(meta.provenanceUnknown).toBeUndefined(); + }); + + it('refreshes as usual when it is the sync that got to the lock first', async () => { + // The same interleaving in the other order: the other sync is still + // extracting and has written nothing, so this run's stats match across the + // acquisition and the diagnostic refresh — the entire point of the preserve + // path — must still happen. A guard that fired on "a second sync exists" + // rather than on "the file changed" would freeze the diagnostics of every + // contended group. + seedPriorRegistry(); + let otherSyncStillExtracting = false; + whileWaitingForTheGroupLock = async () => { + otherSyncStillExtracting = true; + }; + + const result = await runTotalFailureSync(); + + expect(otherSyncStillExtracting).toBe(true); + expect(result.registryOutcome).toBe('preserved'); + const onDisk = readOnDisk(); + expect(onDisk.contracts).toEqual(PRIOR_REGISTRY.contracts); + expect(onDisk.unreadableRepos).toEqual(['app/backend']); + }); + + it('control: an uncontended sync sees identical stats and refreshes as usual', async () => { + // Nothing armed at all, so the compare-and-swap runs over a file no one + // else touched. Without this, every assertion above could be satisfied by a + // guard that skipped the refresh on every run. + seedPriorRegistry(); + + const result = await runTotalFailureSync(); + + expect(result.registryOutcome).toBe('preserved'); + const onDisk = readOnDisk(); + expect(onDisk.contracts).toEqual(PRIOR_REGISTRY.contracts); + expect(onDisk.unreadableRepos).toEqual(['app/backend']); + }); +}); diff --git a/gitnexus/test/unit/group/sync-windowed-resolution.test.ts b/gitnexus/test/unit/group/sync-windowed-resolution.test.ts index 6827376f1..4c44ef778 100644 --- a/gitnexus/test/unit/group/sync-windowed-resolution.test.ts +++ b/gitnexus/test/unit/group/sync-windowed-resolution.test.ts @@ -154,10 +154,11 @@ vi.mock('../../../src/core/lbug/sidecar-recovery.js', () => ({ statIfExists: vi.fn().mockResolvedValue(null), })); -// readRegistry is called in syncGroup's else branch; resolveRepoHandle is +// The registry read happens in syncGroup's else branch; resolveRepoHandle is // supplied, so an empty registry is fine (only the meta.json fallback reads it). vi.mock('../../../src/storage/repo-manager.js', () => ({ readRegistry: vi.fn().mockResolvedValue([]), + readRegistryStrict: vi.fn().mockResolvedValue([]), })); const { syncGroup } = await import('../../../src/core/group/sync.js'); @@ -214,6 +215,7 @@ describe('syncGroup windowed resolution bounds pool residency (real pool, #2189) topics: false, shared_libs: false, embedding_fallback: false, + includes: false, workspace_deps: false, }, matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, diff --git a/gitnexus/test/unit/group/sync.test.ts b/gitnexus/test/unit/group/sync.test.ts index 061f5cfc4..51140d775 100644 --- a/gitnexus/test/unit/group/sync.test.ts +++ b/gitnexus/test/unit/group/sync.test.ts @@ -28,6 +28,7 @@ describe('syncGroup', () => { topics: false, shared_libs: false, embedding_fallback: false, + includes: false, workspace_deps: false, }, matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, @@ -224,6 +225,7 @@ describe('syncGroup', () => { topics: false, shared_libs: false, embedding_fallback: false, + includes: false, workspace_deps: false, }, matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, @@ -678,6 +680,7 @@ service OrderService { topics: false, shared_libs: false, embedding_fallback: false, + includes: false, workspace_deps: false, }, matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, @@ -748,6 +751,7 @@ service OrderService { topics: false, shared_libs: false, embedding_fallback: false, + includes: false, workspace_deps: workspaceDeps, }, matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, @@ -905,6 +909,7 @@ service OrderService { topics: false, shared_libs: false, embedding_fallback: false, + includes: false, workspace_deps: true, }, matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, @@ -996,6 +1001,7 @@ service OrderService { topics: false, shared_libs: false, embedding_fallback: false, + includes: false, workspace_deps: false, }, matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, @@ -1080,6 +1086,7 @@ service OrderService { topics: false, shared_libs: false, embedding_fallback: false, + includes: false, workspace_deps: false, }, matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, @@ -1124,6 +1131,7 @@ describe('syncGroup windowed manifest resolution (issue #2189 / PR #2191 review) topics: false, shared_libs: false, embedding_fallback: false, + includes: false, workspace_deps: false, }, matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, diff --git a/gitnexus/test/unit/group/types.test.ts b/gitnexus/test/unit/group/types.test.ts index 025ed4bbb..e1ffb77d7 100644 --- a/gitnexus/test/unit/group/types.test.ts +++ b/gitnexus/test/unit/group/types.test.ts @@ -25,6 +25,8 @@ describe('Group types', () => { topics: true, shared_libs: true, embedding_fallback: true, + includes: true, + workspace_deps: true, }, matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, }; @@ -93,6 +95,8 @@ describe('Group types', () => { topics: true, shared_libs: true, embedding_fallback: true, + includes: true, + workspace_deps: true, }, matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, }; diff --git a/gitnexus/test/unit/repo-manager-registry-strict-read.test.ts b/gitnexus/test/unit/repo-manager-registry-strict-read.test.ts new file mode 100644 index 000000000..d992b0083 --- /dev/null +++ b/gitnexus/test/unit/repo-manager-registry-strict-read.test.ts @@ -0,0 +1,315 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { inspect } from 'node:util'; +import { readRegistry, readRegistryStrict } from '../../src/storage/repo-manager.js'; +import { _captureLogger, type LoggerCapture } from '../../src/core/logger.js'; +import { createTempDir } from '../helpers/test-db.js'; +import { syncGroup } from '../../src/core/group/sync.js'; +import type { GroupConfig } from '../../src/core/group/types.js'; + +// `syncGroup` is driven here through the REAL registry file and the REAL +// `defaultResolveHandle`, which is the pairing under test; only the pool is +// stubbed, so no LadybugDB index has to exist for a resolved repo to sync. +vi.mock('../../src/core/lbug/pool-adapter.js', () => ({ + initLbug: vi.fn(async () => {}), + executeParameterized: vi.fn(async () => []), + pinRepo: vi.fn(() => () => {}), + getMaxResidentRepos: vi.fn(() => 5), +})); + +/** + * `readRegistry` used to answer every failure with `[]`. + * + * For a listing that is harmless — an unreadable registry and an empty one look + * the same in `gitnexus list`, and both print nothing. For a caller that *acts* + * on emptiness it is not: `syncGroup` derives `missingRepos` from this list, and + * an all-missing sync is allowed to write, so an EACCES after a + * `sudo gitnexus analyze`, a truncated registry.json, or an $HOME-on-NFS blip + * turned "I could not read the registry" into the factual claim "no repo is + * registered" — and replaced a good contracts.json with an empty one at exit 0. + * + * That is an unreadable condition reported as missing: the same conflation + * #3011 removes one stack frame further down, which is why `readRegistryStrict` + * exists and why syncGroup is the only caller that uses it. It is a separate + * export rather than an option on `readRegistry` so that every lenient call + * site keeps a provably untouched signature. + * + * ENOENT stays lenient in both modes. No file genuinely means nothing has been + * registered yet, and every first-run path depends on that. + */ + +describe('readRegistryStrict', () => { + let tmpHome: Awaited>; + let savedGitnexusHome: string | undefined; + let registryPath: string; + + /** A row every field of which the resolution path can use. */ + const resolvableRow = () => ({ + name: 'backend-repo', + // Deliberately inside the temp home: `syncGroup` joins `storagePath` with + // `meta.json`, and a stray real file there would make the snapshot + // assertion below depend on the host. + path: path.join(tmpHome.dbPath, 'repos', 'backend'), + storagePath: path.join(tmpHome.dbPath, 'repos', 'backend', '.gitnexus'), + indexedAt: '2026-01-01T00:00:00.000Z', + lastCommit: 'abc123', + }); + + const makeConfig = (repos: Record): GroupConfig => ({ + version: 1, + name: 'test', + description: '', + repos, + links: [], + packages: {}, + detect: { + http: false, + grpc: false, + thrift: false, + topics: false, + shared_libs: false, + embedding_fallback: false, + includes: false, + workspace_deps: false, + }, + matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, + }); + + beforeEach(async () => { + tmpHome = await createTempDir('gitnexus-registry-strict-'); + savedGitnexusHome = process.env.GITNEXUS_HOME; + process.env.GITNEXUS_HOME = tmpHome.dbPath; + registryPath = path.join(tmpHome.dbPath, 'registry.json'); + }); + + afterEach(async () => { + if (savedGitnexusHome === undefined) delete process.env.GITNEXUS_HOME; + else process.env.GITNEXUS_HOME = savedGitnexusHome; + await tmpHome.cleanup(); + }); + + it('returns [] for a registry that does not exist, strict or not', async () => { + await expect(readRegistry()).resolves.toEqual([]); + await expect(readRegistryStrict()).resolves.toEqual([]); + }); + + it('reads a valid registry identically in both modes', async () => { + const entries = [ + { + name: 'backend-repo', + path: '/repos/backend', + storagePath: '/repos/backend/.gitnexus', + indexedAt: '2026-01-01T00:00:00.000Z', + lastCommit: 'abc123', + }, + ]; + await fs.writeFile(registryPath, JSON.stringify(entries)); + + await expect(readRegistry()).resolves.toEqual(entries); + await expect(readRegistryStrict()).resolves.toEqual(entries); + }); + + it('throws on a corrupt registry instead of reporting an empty one', async () => { + await fs.writeFile(registryPath, '{"truncated": '); + + // Lenient stays lenient — existing callers keep the contract they have. + await expect(readRegistry()).resolves.toEqual([]); + await expect(readRegistryStrict()).rejects.toThrow(); + }); + + it('throws when a row is missing the fields the resolver needs', async () => { + // `[{}]` is a JSON array, so an array-shape check alone waved it through. + // Every configured repo then failed to resolve and landed in missingRepos; + // because none produced a load ERROR the total-failure guard stayed off, + // and a good contracts.json was replaced with an empty one at exit 0. Same + // fail-open as an unreadable file, one level down. + await fs.writeFile(registryPath, JSON.stringify([{}])); + + await expect(readRegistry()).resolves.toEqual([{}]); + await expect(readRegistryStrict()).rejects.toThrow('registry is corrupt'); + }); + + it('throws on a row whose required fields are the wrong type', async () => { + await fs.writeFile( + registryPath, + JSON.stringify([{ name: 'backend-repo', path: 42, storagePath: '/s' }]), + ); + + await expect(readRegistryStrict()).rejects.toThrow('registry is corrupt'); + }); + + it('rejects the whole registry rather than dropping the bad row', async () => { + // Filtering would report the repos the surviving rows do not name as + // unregistered — the unreadable-as-missing answer this mode exists to + // refuse, reintroduced as a silent partial read. + await fs.writeFile( + registryPath, + JSON.stringify([ + { + name: 'good-repo', + path: '/repos/good', + storagePath: '/repos/good/.gitnexus', + indexedAt: '2026-01-01T00:00:00.000Z', + lastCommit: 'abc123', + }, + {}, + ]), + ); + + await expect(readRegistryStrict()).rejects.toThrow('entry 1'); + }); + + it('accepts a legacy row that omits indexedAt and lastCommit', async () => { + // Those two are defaulted by every caller (`e?.indexedAt || ''`), so + // demanding them would turn a fail-open into a fail-shut on real data. + const legacy = [ + { name: 'backend-repo', path: '/repos/backend', storagePath: '/repos/backend/.gitnexus' }, + ]; + await fs.writeFile(registryPath, JSON.stringify(legacy)); + + await expect(readRegistryStrict()).resolves.toEqual(legacy); + }); + + it('rejects a row whose `name` is blank, and names the offending index', async () => { + // `typeof e.name === 'string'` is true of `''`, so a blank name walked + // straight past the shape check and then failed to match ANY configured + // repo in `defaultResolveHandle` — every repo landed in missingRepos, no + // load ERROR was produced, the total-failure guard stayed off, and a good + // contracts.json was replaced by an empty one. A field the resolution path + // matches on cannot be blank and still identify a repo. + const rows = [resolvableRow(), { ...resolvableRow(), name: '' }]; + await fs.writeFile(registryPath, JSON.stringify(rows)); + + // Lenient keeps the contract it has: it hands the row back untouched. + await expect(readRegistry()).resolves.toEqual(rows); + await expect(readRegistryStrict()).rejects.toThrow('entry 1'); + }); + + it('rejects a row whose `name` is whitespace only', async () => { + await fs.writeFile(registryPath, JSON.stringify([{ ...resolvableRow(), name: ' ' }])); + + await expect(readRegistryStrict()).rejects.toThrow('registry is corrupt'); + }); + + it('rejects a row whose `storagePath` is whitespace only', async () => { + // `storagePath` is what the handle carries to `path.join(storagePath, + // 'lbug')`. Blank, that joins to a relative `lbug` under the CWD — an + // index that is not this repo's, opened without anyone saying so. + await fs.writeFile(registryPath, JSON.stringify([{ ...resolvableRow(), storagePath: ' ' }])); + + await expect(readRegistry()).resolves.toHaveLength(1); + await expect(readRegistryStrict()).rejects.toThrow('registry is corrupt'); + }); + + it('accepts a row whose unused `path` is blank, and syncs the repo it names', async () => { + // The counter-case that fixes the width of the rule. `path` is not what + // identifies a repo, so tightening it too would trade this fail-open for a + // fail-shut: one blank `path` anywhere in the MACHINE-WIDE registry would + // reject the whole file and break every group sync on the machine, + // including groups whose repos all resolve. Same principle as indexedAt / + // lastCommit — require only what the resolution path depends on. + const row = { ...resolvableRow(), path: ' ' }; + await fs.writeFile(registryPath, JSON.stringify([row])); + + await expect(readRegistryStrict()).resolves.toEqual([row]); + + const result = await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { + skipWrite: true, + }); + + // Resolved: not reported missing, and the snapshot carries THIS row's + // registry metadata, which only a successful name match could supply. + expect(result.missingRepos).toEqual([]); + expect(result.unreadableRepos).toEqual([]); + expect(result.repoSnapshots['app/backend']).toEqual({ + indexedAt: '2026-01-01T00:00:00.000Z', + lastCommit: 'abc123', + }); + }); + + /** + * A credential-shaped secret, distinctive enough that the whole failure + * surface can be grepped for it. Synthetic — not a real token. + */ + const REGISTRY_SECRET = 'LEAKCAN4RY'; + + /** + * A corrupt registry whose break sits directly on a credential. + * + * The shape is a short write landing over a longer one: the head is the new + * content, and the tail is what was left of the old file — which resumes in + * the middle of a remote URL's HTTPS userinfo. `registry.json` is the one + * file every gitnexus process on the machine writes and `withRegistryLock` + * degrades to unlocked on timeout, so two writers really can produce this. + * + * What matters is where the parser stops: the first byte it rejects is the + * first byte of the credential, and V8 quotes a ten-character window either + * side of that position into `SyntaxError.message`. Registry rows carry + * remote URLs with userinfo verbatim (a pre-existing capture-side issue), so + * the bytes in that window are a live secret. + */ + const corruptRegistryOnACredential = (): string => + `[{"name":"backend-repo","path":"/repos/backend","storagePath":"/repos/backend/.gitnexus",` + + `"remoteUrl":"https://gnx-bot:${REGISTRY_SECRET}@github.com/acme/backend.git"},` + + `${REGISTRY_SECRET}@github.com/acme/backend.git"}]`; + + it('names a corrupt registry without quoting its bytes, on the throw or the log', async () => { + // One test, every channel. A rejection an operator never sees the message + // of is still rendered somewhere: `groupStatus` interpolates it verbatim + // into `unresolvableReason` for an MCP client, the CLI prints it, and any + // `logger.error({ err }, …)` on the way would serialise message, stack and + // `cause` into the MCP client's log file on disk. So assert on all of them. + await fs.writeFile(registryPath, corruptRegistryOnACredential()); + + let cap: LoggerCapture | undefined; + let thrown: unknown; + try { + cap = _captureLogger('trace'); + await readRegistryStrict(); + } catch (err) { + thrown = err; + } finally { + cap?.restore(); + } + const logged = cap?.text() ?? ''; + + expect(thrown).toBeInstanceOf(Error); + const error = thrown as Error; + + // Channel 1: the message every renderer above reads. + expect(error.message).not.toContain(REGISTRY_SECRET); + // Channel 2: `cause`, which pino's error serialiser and `util.inspect` + // both walk. Discarding the parser error means there is nothing to walk. + expect(error.cause).toBeUndefined(); + // Channel 3: whatever a generic stringifier reaches — own properties, + // stack, and the cause chain in one shot. + expect(inspect(error, { depth: null })).not.toContain(REGISTRY_SECRET); + // Channel 4: the log. Nothing is logged here at all, and the assertion + // holds the line against "log the Error object" being added later. + expect(logged).not.toContain(REGISTRY_SECRET); + + // And it still says what failed. Host-independent: the raw parser error + // names neither the path nor the failure class, on any V8. + expect(error.message).toContain(registryPath); + expect(error.message).toContain('registry is corrupt'); + }); + + it('still reports that same credential-bearing registry as empty on the lenient path', async () => { + // The guarded parse must not change what lenient callers see: `gitnexus + // list` and the eight other lenient sites still get `[]`, not a throw. + await fs.writeFile(registryPath, corruptRegistryOnACredential()); + + await expect(readRegistry()).resolves.toEqual([]); + }); + + it('throws when the registry parses but is not an array', async () => { + // A JSON object here is corruption too, and it is the shape most likely to + // survive a partial write: `[]` is what the lenient path would return, which + // is indistinguishable from a registry that really has no entries. + await fs.writeFile(registryPath, '{"repos": []}'); + + await expect(readRegistry()).resolves.toEqual([]); + await expect(readRegistryStrict()).rejects.toThrow('not a JSON array'); + }); +}); diff --git a/gitnexus/test/unit/source-control-bytes.test.ts b/gitnexus/test/unit/source-control-bytes.test.ts new file mode 100644 index 000000000..aa855d54d --- /dev/null +++ b/gitnexus/test/unit/source-control-bytes.test.ts @@ -0,0 +1,505 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * Guard: no tracked source file may carry a raw control byte. + * + * A NUL written as a literal 0x00 rather than the `\0` escape is invisible in + * an editor and identical at runtime, but it makes the file test as BINARY: + * git shows `Bin` instead of a diff, so the change cannot be read on the PR, + * cannot take an inline comment, and cannot be three-way merged; `file(1)` + * reports `data`; `ugrep` returns empty with exit 1 (indistinguishable from + * "no match", with no message); and BSD grep replaces the matching lines with + * `Binary file … matches`. A search that should hit comes back as a confident + * "not present", which is the worst way for a file to be unreadable. + * + * The byte class is deliberately split, because the two halves are not the + * same rule: + * + * - 0x00 is checked across EVERY tracked source file. git's binary heuristic + * keys on NUL alone, so NUL is the byte that actually costs a file its + * text status. Both recurrences in this repo landed outside `src/` — + * b620773b1 in `gitnexus/bench/cpp-qualified-ns/measure.mjs`, and + * 38d737bb5 in a `gitnexus/test/integration/` fixture — so a guard scoped + * to `src/` would have caught neither, and one of the two was not even a + * `.ts` file. + * - The wider C0 class (everything except tab, LF and CR) stays scoped to + * `gitnexus/src`. Those bytes only *look* binary to some tools; they do not + * flip git's own classification, and outside `src/` they have a legitimate + * user: `test/unit/logger.test.ts` feeds a real 0x1b ANSI escape through the + * NDJSON encoder, which is the entire point of that test. Widening this half + * repo-wide would go red on that fixture the day it landed. + * + * The file list comes from `git ls-files` at the repository root rather than a + * directory walk: it is exactly the set git applies its binary heuristic to, it + * never descends into `node_modules` or `dist`, and it honours `.gitignore` for + * free. The tradeoff is that a brand-new file is only covered once git knows + * about it — `git add -N` is enough. What it does NOT skip is vendored code, + * which is tracked here; that is the one deliberate exclusion, and it is named + * in {@link UNSCANNED_ROOT} below. + * + * Files are read as Buffers and scanned byte-wise. Decoding each one to a + * string first bought nothing: LOCATING the byte is ~14 ms for the whole repo + * (1.6 ms of `Buffer.indexOf` across the 33 MB NUL set, 12 ms of the + * byte-at-a-time C0 loop across the 11 MB `src/` subset), and the READS dominate + * it by two orders of magnitude — 4893 files, ~0.3 s warm on a local disk and + * several seconds on a virtualised or network one. That ratio is why the reads + * go through a small concurrency pool, and why {@link UNSCANNED_ROOT} is worth + * having: without it the same scan pulls in 4969 files and 97 MB, because four + * generated `parser.c` files under the vendored grammar tree are 62 MB between + * them. + */ + +const HERE = path.dirname(fileURLToPath(import.meta.url)); + +/** + * Asking git rather than resolving `../../..` keeps this correct inside a + * linked worktree, and fails loudly (instead of silently scanning nothing) if + * this test is ever run outside a checkout. + */ +const REPO_ROOT = execFileSync('git', ['rev-parse', '--show-toplevel'], { + cwd: HERE, + encoding: 'utf8', +}).trim(); + +/** + * Every tracked text format a raw NUL would silently turn binary. + * + * Not just the JS/TS family, and not just code: git's heuristic does not care + * what a file is for. This repo tracks Python, Java, Go, Rust, C/C++, Ruby, + * PHP, Kotlin, Swift, C#, COBOL, shell and Dart sources as resolver fixtures, + * and it hand-edits far more configuration than source — `package.json`, the + * workflow YAML, `go.mod` and `*.csproj` fixtures, the docs, and the vitest + * `.snap` files that are regenerated on demand and reviewed as diffs. A NUL + * costs any of them its diff on exactly the same terms. + * + * `.scm` (tree-sitter queries) and `.gyp` are listed for the same reason, even + * though every tracked instance of both today sits inside the vendored + * grammar tree: the day a first-party query file lands outside it, it is + * covered without a second round of this. + * + * The list stays an ALLOWLIST rather than "everything git tracks" because the + * index also names the tree-sitter `.node` prebuilds and a `.png`, and those 31 + * files are genuinely binary — they are the whole reason a NUL scan cannot just + * read the index. + */ +const SOURCE_EXTENSIONS = + /\.(?:ts|tsx|js|jsx|mjs|cjs|mts|cts|py|pyi|java|kt|kts|go|rs|c|h|cc|cpp|hpp|cs|rb|php|swift|scala|dart|lua|pl|sh|bash|zsh|cbl|cpy|jcl|sql|vue|svelte|html|htm|css|scss|less|jinja|toml|cfg|ini|properties|env|example|json|jsonc|jsonl|yml|yaml|xml|csv|txt|md|mdc|mdm|mdx|snap|scm|proto|lock|mod|sum|csproj|props|targets|sln|gradle|gyp|gypi|ps1|bat|cmd)$/; + +/** + * Tracked text files whose whole NAME is the format — the half no extension + * regex can reach. + * + * {@link SOURCE_EXTENSIONS} is end-anchored on a dot, so `Dockerfile`, + * `CODEOWNERS`, `LICENSE`, `SHA256SUMS` and the husky hook never match it at + * any width, and neither does a bare dotfile like `.gitignore` or + * `.prettierrc`, whose entire name reads as an extension. Every one of them is + * hand-edited here, and a NUL would cost each of them its diff. + * + * Matched against the BASENAME, so one entry covers every directory the name + * appears in, and matched case-sensitively, which is how git stores the path. + */ +const SOURCE_BASENAMES = + /^(?:Dockerfile(?:\..+)?|CODEOWNERS|LICENSE|SHA256SUMS|pre-commit|\.(?:cursorrules|dockerignore|git-blame-ignore-revs|gitattributes|gitignore|gitkeep|gitleaksignore|npmignore|prettierignore|prettierrc|windsurfrules))$/; + +/** Scope of the wider control-byte rule. git paths are always `/`-separated. */ +const STRICT_SOURCE_ROOT = 'gitnexus/src/'; + +/** + * The one tracked root this guard deliberately does not scan. + * + * `gitnexus/vendor/` is upstream tree-sitter grammars, vendored wholesale. It + * is never hand-edited, so the mistake this guard exists to catch cannot happen + * there — and it is where the whole cost is: four generated `parser.c` files + * are 62 MB of the 97 MB the allowlist would otherwise read, two thirds of the + * scan for 76 of its 4969 files. + * + * An ANCHORED PREFIX, deliberately, and deliberately case-SENSITIVE. Matching a + * `vendor` path SEGMENT, or matching case-insensitively, would also drop three + * tracked paths that live outside this root and are reviewed as diffs like any + * other source here: `gitnexus-web/src/vendor/leiden/`, the Kotlin + * `vendor/Assert.kt` resolver fixture, and the PHP `src/Vendor/Utils/Format.php` + * one. That loss would be silent — the guard would simply stop covering them — + * which is why the exclusion case below pins both halves. + */ +const UNSCANNED_ROOT = 'gitnexus/vendor/'; + +/** Enough to hide per-file I/O latency without risking EMFILE. */ +const READ_CONCURRENCY = 16; + +interface ScanTarget { + /** Absolute path to read. */ + readonly abs: string; + /** Path as reported in failures — repo-root-relative for tracked files. */ + readonly rel: string; +} + +interface Offender { + readonly rel: string; + readonly line: number; + readonly byte: number; +} + +/** The one byte git's binary heuristic keys on. */ +function findNulByte(buf: Buffer): number { + return buf.indexOf(0); +} + +/** C0 controls minus the three that are legitimate in source: tab, LF, CR. */ +function findControlByte(buf: Buffer): number { + for (let i = 0; i < buf.length; i += 1) { + const byte = buf[i]; + if (byte > 0x1f) continue; + if (byte === 0x09 || byte === 0x0a || byte === 0x0d) continue; + return i; + } + return -1; +} + +/** Only ever called for an actual offender, so the O(offset) count is free. */ +function lineOfOffset(buf: Buffer, offset: number): number { + let line = 1; + for (let i = 0; i < offset; i += 1) { + if (buf[i] === 0x0a) line += 1; + } + return line; +} + +/** + * `git ls-files` reports the index, which can name a path that is not on disk + * (a staged deletion, a sparse checkout). Those are not offenders. Any other + * read failure propagates rather than quietly shrinking the scanned set. + */ +async function readTrackedFile(abs: string): Promise { + try { + return await fsp.readFile(abs); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } +} + +async function scanTarget( + target: ScanTarget, + locate: (buf: Buffer) => number, +): Promise { + const buf = await readTrackedFile(target.abs); + if (buf === null) return null; + const offset = locate(buf); + if (offset === -1) return null; + return { rel: target.rel, line: lineOfOffset(buf, offset), byte: buf[offset] }; +} + +/** + * Reads run concurrently, so the completion order is not the input order — the + * result is sorted before it is returned so the assertion never depends on it. + */ +async function scanTargets( + targets: readonly ScanTarget[], + locate: (buf: Buffer) => number, +): Promise { + const offenders: Offender[] = []; + let cursor = 0; + + const worker = async (): Promise => { + for (;;) { + const index = cursor; + cursor += 1; + if (index >= targets.length) return; + const offender = await scanTarget(targets[index], locate); + if (offender !== null) offenders.push(offender); + } + }; + + const workers = Math.min(READ_CONCURRENCY, targets.length); + await Promise.all(Array.from({ length: workers }, () => worker())); + + return offenders.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : a.line - b.line)); +} + +/** + * The collector's whole filter, in one predicate. + * + * Exposed as a function so the planted-fixture cases below can put a fixture + * name through the SAME decision the repo-wide scan makes. Asserting on + * `scanTargets` alone proves only that the byte locator works; it says nothing + * about whether the collector would ever hand that file to the locator, and + * that second half is the one that has been too narrow. + */ +function isScannedTextFile(rel: string): boolean { + if (rel.startsWith(UNSCANNED_ROOT)) return false; + return SOURCE_EXTENSIONS.test(rel) || SOURCE_BASENAMES.test(path.posix.basename(rel)); +} + +function listTrackedSourceFiles(): ScanTarget[] { + const stdout = execFileSync('git', ['ls-files', '-z'], { + cwd: REPO_ROOT, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + }); + // `-z` emits raw, NUL-terminated paths, so nothing is quoted or escaped and + // the trailing empty segment is dropped by the filter (it has neither a + // matching extension nor a matching basename). + return stdout + .split('\u0000') + .filter((rel) => isScannedTextFile(rel)) + .map((rel) => ({ abs: path.join(REPO_ROOT, rel), rel })); +} + +const TRACKED_SOURCE_FILES = listTrackedSourceFiles(); +const STRICT_SOURCE_FILES = TRACKED_SOURCE_FILES.filter((target) => + target.rel.startsWith(STRICT_SOURCE_ROOT), +); + +function describeOffender(offender: Offender): string { + const byte = `0x${offender.byte.toString(16).padStart(2, '0')}`; + return `${offender.rel}:${offender.line} contains ${byte}`; +} + +function failureMessage(lead: readonly string[], offenders: readonly Offender[]): string { + return [...lead, ...offenders.map((offender) => ` - ${describeOffender(offender)}`)].join('\n'); +} + +/** Line 3 carries the raw NUL; the two lines above it prove the line count. */ +const PLANTED_NUL_SOURCE = ['const a = 1;', 'const b = 2;', "const sep = '\u0000';", ''].join('\n'); + +/** Line 2 carries a raw ESC — the byte the repo-wide half deliberately allows. */ +const PLANTED_ESCAPE_SOURCE = ['const a = 1;', "const red = '\u001b[31m';", ''].join('\n'); + +/** + * The same defect in a non-JS source file. git classifies this as binary for + * exactly the same reason, and an allowlist that stops at `.cts` would collect + * neither the file nor the byte. + */ +const PLANTED_PY_NUL_SOURCE = ['a = 1', 'b = 2', "sep = '\u0000'", ''].join('\n'); + +/** + * The same defect again, in the four shapes the JS/TS extension list could not + * reach. The last two are why a second, basename filter has to exist at all: + * `Dockerfile` has no extension, and `.gitignore` is a name that IS its + * extension, so an end-anchored `\.(…)$` regex can never match either, + * however far its alternation is widened. + */ +const PLANTED_JSON_NUL_SOURCE = ['{', ' "a": 1,', ' "sep": "\u0000"', '}', ''].join('\n'); +const PLANTED_MD_NUL_SOURCE = ['# Heading', 'separator: \u0000', ''].join('\n'); +const PLANTED_DOCKERFILE_NUL_SOURCE = ['FROM node:22-bookworm', 'RUN echo \u0000', ''].join('\n'); +const PLANTED_DOTFILE_NUL_SOURCE = ['dist/', 'sep-\u0000/', ''].join('\n'); + +function writeFixture(dir: string, name: string, source: string): ScanTarget { + const abs = path.join(dir, name); + // Written as a Buffer so the escapes above land as single raw bytes on disk, + // which is the shape the guard has to catch. + fs.writeFileSync(abs, Buffer.from(source, 'utf8')); + return { abs, rel: name }; +} + +function removeDir(dir: string | null): void { + if (dir === null) return; + fs.rmSync(dir, { recursive: true, force: true }); +} + +describe('source hygiene', () => { + let fixtureDir: string | null = null; + + afterEach(() => { + removeDir(fixtureDir); + fixtureDir = null; + }); + + it('has no raw NUL byte in any tracked source file', async () => { + const offenders = await scanTargets(TRACKED_SOURCE_FILES, findNulByte); + + expect( + offenders.map(describeOffender), + failureMessage( + [ + 'A raw NUL makes git classify the whole file as binary: it shows as `Bin`', + 'with no diff, takes no inline review comment, and will not three-way', + 'merge. Write the character as an escape instead (e.g. `\\0` or', + '`\\u0000`), which is identical at runtime and keeps the file text:', + ], + offenders, + ), + ).toEqual([]); + }); + + it('has no other raw control byte under gitnexus/src', async () => { + const offenders = await scanTargets(STRICT_SOURCE_FILES, findControlByte); + + expect( + offenders.map(describeOffender), + failureMessage( + [ + 'Raw control bytes make a source file test as binary to `file(1)`, `less`', + 'and several greps, so those tools skip it silently. Write the character', + 'as an escape instead, which is identical at runtime and keeps the file', + 'text. If the raw byte is the subject of the code (an ANSI-escape', + 'fixture, say), it belongs in the test tree, not in src/:', + ], + offenders, + ), + ).toEqual([]); + }); + + it('scans past gitnexus/src and past .ts, where both recurrences landed', () => { + const outsideSrc = TRACKED_SOURCE_FILES.map((target) => target.rel).filter( + (rel) => !rel.startsWith(STRICT_SOURCE_ROOT), + ); + + // Narrowing the collector back to src/, or back to .ts only, is what let + // this defect land twice. Each of these would go red on that narrowing. + expect(outsideSrc.length).toBeGreaterThan(0); + expect(outsideSrc.filter((rel) => rel.startsWith('gitnexus/bench/')).length).toBeGreaterThan(0); + expect(outsideSrc.filter((rel) => rel.startsWith('gitnexus/test/')).length).toBeGreaterThan(0); + expect(outsideSrc.filter((rel) => rel.endsWith('.mjs')).length).toBeGreaterThan(0); + }); + + it('reports the path, line and byte value of a planted control byte', async () => { + fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'source-control-bytes-')); + const planted = [ + writeFixture(fixtureDir, 'planted-escape.ts', PLANTED_ESCAPE_SOURCE), + writeFixture(fixtureDir, 'planted-nul.py', PLANTED_PY_NUL_SOURCE), + writeFixture(fixtureDir, 'planted-nul.ts', PLANTED_NUL_SOURCE), + ]; + + const nulOffenders = await scanTargets(planted, findNulByte); + const controlOffenders = await scanTargets(planted, findControlByte); + + // Without this the guard above is unfalsifiable: a collector that returns + // an empty list, or a locator that never matches, passes it forever. + expect(nulOffenders.map(describeOffender)).toEqual([ + 'planted-nul.py:3 contains 0x00', + 'planted-nul.ts:3 contains 0x00', + ]); + expect(controlOffenders.map(describeOffender)).toEqual([ + 'planted-escape.ts:2 contains 0x1b', + 'planted-nul.py:3 contains 0x00', + 'planted-nul.ts:3 contains 0x00', + ]); + }); + + it('collects tracked sources outside the JS/TS family', () => { + // The allowlist is the collector's only filter, so a language missing from + // it is a language the NUL rule silently does not cover. This goes red if + // the list is ever narrowed back to JS/TS. + const collected = TRACKED_SOURCE_FILES.map((target) => target.rel); + const byExtension = (ext: string): number => + collected.filter((rel) => rel.endsWith(ext)).length; + + expect(byExtension('.py')).toBeGreaterThan(0); + expect(byExtension('.java')).toBeGreaterThan(0); + expect(byExtension('.go')).toBeGreaterThan(0); + expect(byExtension('.rs')).toBeGreaterThan(0); + }); + + it('reports a planted NUL in the shapes the JS/TS extension list never reached', async () => { + fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'source-control-bytes-')); + const planted = [ + writeFixture(fixtureDir, '.gitignore', PLANTED_DOTFILE_NUL_SOURCE), + writeFixture(fixtureDir, 'Dockerfile', PLANTED_DOCKERFILE_NUL_SOURCE), + writeFixture(fixtureDir, 'planted-nul.json', PLANTED_JSON_NUL_SOURCE), + writeFixture(fixtureDir, 'planted-nul.md', PLANTED_MD_NUL_SOURCE), + ]; + + // Through the collector's own predicate, not straight into the locator: a + // file the collector never yields is a file the guard never reads, and that + // is the failure mode both halves of the filter exist to close. Dropping + // either half deletes entries from this list. + const scanned = planted.filter((target) => isScannedTextFile(target.rel)); + const offenders = await scanTargets(scanned, findNulByte); + + expect(offenders.map(describeOffender)).toEqual([ + '.gitignore:2 contains 0x00', + 'Dockerfile:2 contains 0x00', + 'planted-nul.json:3 contains 0x00', + 'planted-nul.md:2 contains 0x00', + ]); + }); + + it('collects every tracked text format, files with no extension included', () => { + const collected = TRACKED_SOURCE_FILES.map((target) => target.rel); + const byExtension = (ext: string): number => + collected.filter((rel) => rel.endsWith(ext)).length; + + // Data and configuration formats. git's heuristic does not care that these + // are not code: a NUL costs `package.json` its diff exactly as it costs a + // `.ts` file, and every format below is hand-edited in this repo. + expect(byExtension('.json')).toBeGreaterThan(0); + expect(byExtension('.yml')).toBeGreaterThan(0); + expect(byExtension('.yaml')).toBeGreaterThan(0); + expect(byExtension('.md')).toBeGreaterThan(0); + expect(byExtension('.snap')).toBeGreaterThan(0); + expect(byExtension('.txt')).toBeGreaterThan(0); + expect(byExtension('.csproj')).toBeGreaterThan(0); + expect(byExtension('go.mod')).toBeGreaterThan(0); + expect(byExtension('.properties')).toBeGreaterThan(0); + expect(byExtension('.cbl')).toBeGreaterThan(0); + + // The basename half. An end-anchored EXTENSION regex cannot reach any of + // these however far its alternation is widened, so widening alone would + // have left all of them outside the guard. + expect(collected).toContain('.devcontainer/Dockerfile'); + expect(collected).toContain('.github/CODEOWNERS'); + expect(collected).toContain('.husky/pre-commit'); + expect(collected).toContain('LICENSE'); + expect(byExtension('.gitignore')).toBeGreaterThan(0); + expect(byExtension('.prettierrc')).toBeGreaterThan(0); + }); + + it('still leaves tracked binary formats out of the scan', () => { + const collected = TRACKED_SOURCE_FILES.map((target) => target.rel); + + // Why this stays an allowlist rather than "everything git tracks". The + // index also names the tree-sitter prebuilds and one docs screenshot, and + // those 31 files are the only tracked files here that really do carry a + // NUL — scanning them would report all 31 forever. + expect(collected.filter((rel) => rel.endsWith('.node'))).toEqual([]); + expect(collected.filter((rel) => rel.endsWith('.png'))).toEqual([]); + expect(isScannedTextFile('gitnexus/prebuilds/linux-x64/tree-sitter-kotlin.node')).toBe(false); + expect(isScannedTextFile('Documentation/docs-asset/kilo-code-mcp.png')).toBe(false); + }); + + it('skips the vendored grammar tree without dropping first-party `vendor` paths', () => { + const collected = TRACKED_SOURCE_FILES.map((target) => target.rel); + + expect(collected.filter((rel) => rel.startsWith(UNSCANNED_ROOT))).toEqual([]); + + // Both halves in one assertion, because the cheap way to write the + // exclusion — a `vendor` path SEGMENT, or a case-insensitive match — passes + // the line above and silently drops these three. Nothing else would notice: + // a file that leaves the collected set just stops being guarded. + expect(collected).toContain('gitnexus-web/src/vendor/leiden/index.js'); + expect(collected).toContain( + 'gitnexus/test/fixtures/lang-resolution/kotlin-import-package-evidence/vendor/Assert.kt', + ); + expect(collected).toContain( + 'gitnexus/test/fixtures/lang-resolution/php-namespace-fallback-isolation/src/Vendor/Utils/Format.php', + ); + + // Case-sensitivity, pinned on the predicate rather than on the tracked set, + // because nothing tracked today is named `gitnexus/Vendor/` — so a + // case-insensitive prefix would cost this repo nothing YET, and only the + // predicate can say the rule out loud. The repo already proves the casing + // distinction is live: `src/Vendor/Utils/Format.php` above is first-party. + expect(isScannedTextFile('gitnexus/Vendor/tree-sitter-c/src/parser.c')).toBe(true); + // ...and the anchor itself, for the same reason: only the leading path is + // vendored, not every directory that happens to be called `vendor`. + expect(isScannedTextFile('gitnexus/src/core/vendor/adapter.ts')).toBe(true); + expect(isScannedTextFile(`${UNSCANNED_ROOT}tree-sitter-c/src/parser.c`)).toBe(false); + + // And the first-party half of the formats the vendored tree also uses is + // still collected, so the exclusion cost coverage of nothing. + const outsideRoot = (ext: string): number => + collected.filter((rel) => rel.endsWith(ext) && !rel.startsWith(UNSCANNED_ROOT)).length; + expect(outsideRoot('.c')).toBeGreaterThan(0); + expect(outsideRoot('.h')).toBeGreaterThan(0); + expect(outsideRoot('.js')).toBeGreaterThan(0); + expect(outsideRoot('.json')).toBeGreaterThan(0); + expect(outsideRoot('.md')).toBeGreaterThan(0); + }); +}); diff --git a/gitnexus/test/unit/tools.test.ts b/gitnexus/test/unit/tools.test.ts index 8c6c04071..d9a2890d9 100644 --- a/gitnexus/test/unit/tools.test.ts +++ b/gitnexus/test/unit/tools.test.ts @@ -13,6 +13,8 @@ import { LIST_REPOS_DEFAULT_LIMIT, LIST_REPOS_MAX_LIMIT, } from '../../src/mcp/tools.js'; +import { getResourceTemplates, type ResourceTemplate } from '../../src/mcp/resources.js'; +import { GROUP_IMPACT_TRUNCATION_REASONS } from '../../src/core/group/types.js'; const GROUP_TOOLS = new Set(['group_list', 'group_sync']); const MUTATING_TOOLS = new Set(['rename', 'group_sync']); @@ -290,6 +292,40 @@ describe('GITNEXUS_TOOLS', () => { } }); + // U27: `RegistryWriteOutcome` has four members, three of which a group_sync + // MCP call can actually return (`not-attempted` needs `skipWrite`/no + // `groupDir`, neither reachable through this tool). An agent that only knows + // 'written' and 'preserved' reads the third — nothing readable AND no prior + // registry — as "your previous contracts survived", which is a claim about a + // file that does not exist (R4). + it('group_sync description names every registry outcome reachable through the tool', () => { + const syncTool = GITNEXUS_TOOLS.find((t) => t.name === 'group_sync')!; + const d = syncTool.description; + expect(d).toContain('registryOutcome'); + expect(d).toContain("'written'"); + expect(d).toContain("'preserved'"); + expect(d).toContain("'superseded'"); + expect(d).toContain("'no-prior-registry'"); + // Naming the value is not describing it: the outcome an agent has to act on + // differently is "there is no contracts.json on disk at all". + expect(d).toMatch(/no previous contracts\.json|no contracts\.json (exists|was written)/i); + // `not-attempted` is unreachable through this tool; documenting it would + // advertise an outcome no caller can observe. + expect(d).not.toContain('not-attempted'); + // 'preserved' rewrites contracts.json (keeping the previous contracts and + // cross-links, refreshing the diagnostic lists). ITS clause may not say the + // file was left alone — that sent an operator reading an unchanged mtime to + // conclude the sync never ran. Scoped to that clause rather than the whole + // description, because 'superseded' genuinely does leave the file untouched + // and describing it accurately must not trip this. + const preservedClause = d.slice(d.indexOf("'preserved'"), d.indexOf("'superseded'")); + expect(preservedClause).not.toMatch(/did NOT write|untouched|left alone|unwritten/i); + // ...and the superseded clause must say exactly that, or the two collapse + // back into one word for two different things on disk. + const supersededClause = d.slice(d.indexOf("'superseded'"), d.indexOf("'no-prior-registry'")); + expect(supersededClause).toMatch(/untouched|not recorded/i); + }); + it('impact, query, and context expose optional service with minLength', () => { for (const n of ['impact', 'query', 'context'] as const) { const tool = GITNEXUS_TOOLS.find((t) => t.name === n)!; @@ -394,3 +430,58 @@ describe('GITNEXUS_TOOLS', () => { expect(shapeCheckTool.description).toContain('pre-change analysis'); }); }); + +// U28: a cross-repo answer that is a floor says so with `truncated` + +// `truncationReason`, and the agent-facing surfaces have to teach that +// vocabulary — an agent that cannot tell a retryable runtime limit from a +// structural one retries a query that will return the same floor forever (R8). +describe('cross-repo incompleteness vocabulary', () => { + const impactDescription = (): string => + GITNEXUS_TOOLS.find((t) => t.name === 'impact')!.description; + + const groupStatusTemplate = (): ResourceTemplate => + getResourceTemplates().find((t) => t.uriTemplate === 'gitnexus://group/{name}/status')!; + + it('impact description names every truncation reason the group surfaces can return', () => { + const d = impactDescription(); + expect(d).toContain('truncationReason'); + // Iterates the RUNTIME array on purpose: a hand-listed expectation here + // would keep passing after a fourth reason is added and left undescribed, + // which is the only failure this guard exists to catch. + for (const reason of GROUP_IMPACT_TRUNCATION_REASONS) { + expect( + d, + `truncationReason '${reason}' is not explained in the impact description`, + ).toContain(`'${reason}'`); + } + }); + + it("impact description gives 'incomplete-sync' a re-sync remedy, not a retry", () => { + const d = impactDescription(); + // The structural cause and its remedy: the bridge is missing those repos' + // contracts, so the same query returns the same floor until a sync fixes it. + expect(d).toContain('group_sync'); + expect(d).toMatch(/'incomplete-sync'[\s\S]{0,600}group_sync/); + // ...and truncated:true must no longer read as "the fan-out ran out of + // room": on 'incomplete-sync' zero crossings may have been attempted. + expect(d).toMatch(/truncated:true does NOT always mean/i); + }); + + it('group status resource description explains the absent / empty / populated tri-state', () => { + const d = groupStatusTemplate().description; + expect(d).toContain('unreadableRepos'); + // Three states, one vocabulary (R8): absent = the sync never recorded which + // repos it could read, empty = it measured none, populated = it named them. + // Describing it as a two-state turns "unknown" into "none". + expect(d).toMatch(/absent/i); + expect(d).toMatch(/empty/i); + expect(d).toMatch(/populated/i); + }); + + it('group status resource description tells an absent repo from an unresolvable one', () => { + const d = groupStatusTemplate().description; + expect(d).toContain('missing'); + expect(d).toContain('unresolvable'); + expect(d).toContain('unresolvableReason'); + }); +}); From 9d4f02900197eb25e228479dc738eaaaabeb65a8 Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Wed, 26 Aug 2026 12:54:00 +0100 Subject: [PATCH 02/17] fix(impact): mark Convex caller results incomplete (#3044) * fix(impact): mark Convex caller results incomplete * fix(storage): align Convex Const persistence --- ARCHITECTURE.md | 1 + .../bench/emit-persistence/baselines.json | 3 +- .../src/core/ingestion/language-provider.ts | 42 ++++ .../core/ingestion/languages/typescript.ts | 3 + .../typescript/convex-endpoint-metadata.ts | 113 +++++++++ .../core/ingestion/workers/parse-worker.ts | 38 ++- gitnexus/src/core/lbug/csv-generator.ts | 11 +- gitnexus/src/core/lbug/lbug-adapter.ts | 26 +++ gitnexus/src/core/lbug/schema.ts | 14 +- gitnexus/src/mcp/local/convex-metadata.ts | 72 ++++++ gitnexus/src/mcp/local/local-backend.ts | 60 +++-- gitnexus/src/mcp/tools.ts | 9 +- gitnexus/src/storage/parse-cache.ts | 11 +- .../convex-impact-epistemic-e2e.test.ts | 218 ++++++++++++++++++ .../impact-epistemic-lower-bound.test.ts | 49 ++++ .../unit/convex-dispatch-metadata.test.ts | 53 +++++ ...nvex-metadata-persistence-contract.test.ts | 123 ++++++++++ gitnexus/test/unit/convex-metadata.test.ts | 125 ++++++++++ .../test/unit/definition-properties.test.ts | 68 ++++++ .../test/unit/incremental-parse-cache.test.ts | 6 +- 20 files changed, 1007 insertions(+), 38 deletions(-) create mode 100644 gitnexus/src/core/ingestion/languages/typescript/convex-endpoint-metadata.ts create mode 100644 gitnexus/src/mcp/local/convex-metadata.ts create mode 100644 gitnexus/test/integration/convex-impact-epistemic-e2e.test.ts create mode 100644 gitnexus/test/unit/convex-dispatch-metadata.test.ts create mode 100644 gitnexus/test/unit/convex-metadata-persistence-contract.test.ts create mode 100644 gitnexus/test/unit/convex-metadata.test.ts create mode 100644 gitnexus/test/unit/definition-properties.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 357d337ae..7ae30a56b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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` — missing a language is a compile error. diff --git a/gitnexus/bench/emit-persistence/baselines.json b/gitnexus/bench/emit-persistence/baselines.json index 1d295bd19..5eeceb02b 100644 --- a/gitnexus/bench/emit-persistence/baselines.json +++ b/gitnexus/bench/emit-persistence/baselines.json @@ -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_` 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`." } diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 71802100d..2775cf07b 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -51,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; +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> | 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> | 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>, +>( + providerProperties: Readonly>, + canonicalProperties: TCanonical, +): Record & TCanonical { + return { ...providerProperties, ...canonicalProperties } as Record & 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 @@ -276,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. */ diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index 40b91cd8d..e6106df69 100644 --- a/gitnexus/src/core/ingestion/languages/typescript.ts +++ b/gitnexus/src/core/ingestion/languages/typescript.ts @@ -126,6 +126,7 @@ import { } from './javascript/index.js'; import { extractDispatchGuardRoutes } from '../route-extractors/dispatch-guard.js'; import { extractDataRouteTableRoutes } from '../route-extractors/data-route-table.js'; +import { extractConvexEndpointProperties } from './typescript/convex-endpoint-metadata.js'; const extractJsTsRoutes = (...args: Parameters) => [ ...extractDispatchGuardRoutes(...args), @@ -418,6 +419,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`. ── @@ -505,6 +507,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`. ── diff --git a/gitnexus/src/core/ingestion/languages/typescript/convex-endpoint-metadata.ts b/gitnexus/src/core/ingestion/languages/typescript/convex-endpoint-metadata.ts new file mode 100644 index 000000000..b31a079d1 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/typescript/convex-endpoint-metadata.ts @@ -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 = new Set([ + 'query', + 'mutation', + 'action', + 'internalQuery', + 'internalMutation', + 'internalAction', + 'httpAction', +]); + +const GENERIC_ENDPOINT_FACTORIES: ReadonlyMap = 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> | 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 }); +} diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index b8417c7a6..1d1f3dab4 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -141,7 +141,11 @@ import { templateConstraintsIdTag, } from '../utils/template-arguments.js'; import type { LanguageProvider } from '../language-provider.js'; -import { shouldHarvestModuleConstants } from '../language-provider.js'; +import { + mergeCanonicalDefinitionProperties, + runDefinitionPropertiesExtractor, + shouldHarvestModuleConstants, +} from '../language-provider.js'; import type { ParsedFile } from 'gitnexus-shared'; import { extractParsedFile, type ScopeCaptureSourceKind } from '../scope-extractor-bridge.js'; import { @@ -2821,19 +2825,38 @@ const processFileGroup = ( } } + const isExported = + language === SupportedLanguages.Vue && isVueSetup + ? isVueSetupTopLevel(nameNode || definitionNode) + : cachedExportCheck(provider.exportChecker, nameNode || definitionNode, nodeName); + if (definitionNode && provider.definitionPropertiesExtractor) { + const definitionProperties = runDefinitionPropertiesExtractor( + provider.definitionPropertiesExtractor, + { + nodeLabel, + nodeName, + definitionNode, + parsedImports: parsedFile?.parsedImports ?? [], + isExported, + }, + (error) => + reportWarning( + `Definition property extraction failed for ${file.path}:${nodeName}: ${error instanceof Error ? error.message : String(error)}`, + ), + ); + if (definitionProperties !== undefined) Object.assign(methodProps, definitionProperties); + } + result.nodes.push({ id: nodeId, label: nodeLabel, - properties: { + properties: mergeCanonicalDefinitionProperties(methodProps, { name: nodeName, filePath: file.path, startLine, endLine: definitionNode ? definitionNode.endPosition.row + lineOffset : startLine, language: language, - isExported: - language === SupportedLanguages.Vue && isVueSetup - ? isVueSetupTopLevel(nameNode || definitionNode) - : cachedExportCheck(provider.exportChecker, nameNode || definitionNode, nodeName), + isExported, ...(qualifiedTypeName !== undefined ? { qualifiedName: qualifiedTypeName } : {}), ...(classTemplateArguments !== undefined && classTemplateArguments.length > 0 ? { templateArguments: classTemplateArguments } @@ -2848,10 +2871,9 @@ const processFileGroup = ( } : {}), ...(description !== undefined ? { description } : {}), - ...methodProps, ...(declaredType !== undefined ? { declaredType } : {}), ...(returnShapeProperty ? { fromReturnShape: true, isDetail: true } : {}), - }, + }), }); // enclosingClassId already computed above (before nodeId generation) diff --git a/gitnexus/src/core/lbug/csv-generator.ts b/gitnexus/src/core/lbug/csv-generator.ts index 398ae0db9..d0d9a70bb 100644 --- a/gitnexus/src/core/lbug/csv-generator.ts +++ b/gitnexus/src/core/lbug/csv-generator.ts @@ -483,7 +483,7 @@ export const streamAllCSVsToDisk = async ( const codeElementHeader = 'id,name,filePath,startLine,endLine,isExported,content,description'; const functionWriter = new BufferedCSVWriter( path.join(csvDir, 'function.csv'), - codeElementHeader, + `${codeElementHeader},convexEndpointFactory`, ); const classWriter = new BufferedCSVWriter( path.join(csvDir, 'class.csv'), @@ -536,6 +536,7 @@ export const streamAllCSVsToDisk = async ( // Multi-language node types share the same CSV shape (no isExported column) const multiLangHeader = 'id,name,filePath,startLine,endLine,content,description'; + const constHeader = `${multiLangHeader},convexEndpointFactory`; const MULTI_LANG_TYPES = [ 'Struct', 'Enum', @@ -565,7 +566,7 @@ export const streamAllCSVsToDisk = async ( t, new BufferedCSVWriter( path.join(csvDir, `${t.toLowerCase()}.csv`), - t === 'Property' ? propertyHeader : multiLangHeader, + t === 'Property' ? propertyHeader : t === 'Const' ? constHeader : multiLangHeader, ), ); } @@ -734,6 +735,8 @@ export const streamAllCSVsToDisk = async ( ]; if (node.label === 'Class') { row.push(escapeCSVField(formatCSVStringArray(node.properties.frameworkAnnotations))); + } else if (node.label === 'Function') { + row.push(escapeCSVField(String(node.properties.convexEndpointFactory ?? ''))); } pending = writer.addRow(row.join(',')); } else { @@ -758,7 +761,9 @@ export const streamAllCSVsToDisk = async ( // empty BOOLEAN cell fails the COPY. node.properties.isDetail === true ? 'true' : 'false', ] - : []), + : node.label === 'Const' + ? [escapeCSVField(String(node.properties.convexEndpointFactory ?? ''))] + : []), ].join(','), ); } else { diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index 3eeeaaee9..3058c3d81 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -1547,9 +1547,15 @@ export const getCopyQuery = (table: NodeTableName, filePath: string): string => if (table === 'Method') { return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, description, parameterCount, returnType) FROM "${filePath}" ${COPY_CSV_OPTS}`; } + if (table === 'Function') { + return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, description, convexEndpointFactory) FROM "${filePath}" ${COPY_CSV_OPTS}`; + } if (table === 'Property') { return `COPY ${t}(id, name, filePath, startLine, endLine, content, description, declaredType, isDetail) FROM "${filePath}" ${COPY_CSV_OPTS}`; } + if (table === 'Const') { + return `COPY ${t}(id, name, filePath, startLine, endLine, content, description, convexEndpointFactory) FROM "${filePath}" ${COPY_CSV_OPTS}`; + } // TypeScript/JS code element tables have isExported; multi-language tables do not if (TABLES_WITH_EXPORTED.has(table)) { return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, description) FROM "${filePath}" ${COPY_CSV_OPTS}`; @@ -1602,6 +1608,16 @@ export const insertNodeToLbug = async ( ? `, description: ${formatCypherValue(properties.description)}` : ''; query = `CREATE (n:Class {id: ${formatCypherValue(properties.id)}, name: ${formatCypherValue(properties.name)}, filePath: ${formatCypherValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, isExported: ${!!properties.isExported}, content: ${formatCypherValue(properties.content || '')}${descPart}, frameworkAnnotations: ${formatCypherStringArray(properties.frameworkAnnotations)}})`; + } else if (label === 'Function') { + const descPart = properties.description + ? `, description: ${formatCypherValue(properties.description)}` + : ''; + query = `CREATE (n:Function {id: ${formatCypherValue(properties.id)}, name: ${formatCypherValue(properties.name)}, filePath: ${formatCypherValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, isExported: ${!!properties.isExported}, content: ${formatCypherValue(properties.content || '')}${descPart}, convexEndpointFactory: ${formatCypherValue(properties.convexEndpointFactory ?? '')}})`; + } else if (label === 'Const') { + const descPart = properties.description + ? `, description: ${formatCypherValue(properties.description)}` + : ''; + query = `CREATE (n:Const {id: ${formatCypherValue(properties.id)}, name: ${formatCypherValue(properties.name)}, filePath: ${formatCypherValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, content: ${formatCypherValue(properties.content || '')}${descPart}, convexEndpointFactory: ${formatCypherValue(properties.convexEndpointFactory ?? '')}})`; } else if (TABLES_WITH_EXPORTED.has(label)) { const descPart = properties.description ? `, description: ${formatCypherValue(properties.description)}` @@ -1692,6 +1708,16 @@ export const batchInsertNodesToLbug = async ( ? `, n.description = ${formatCypherValue(properties.description)}` : ''; query = `MERGE (n:Class {id: ${formatCypherValue(properties.id)}}) SET n.name = ${formatCypherValue(properties.name)}, n.filePath = ${formatCypherValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.isExported = ${!!properties.isExported}, n.content = ${formatCypherValue(properties.content || '')}${descPart}, n.frameworkAnnotations = ${formatCypherStringArray(properties.frameworkAnnotations)}`; + } else if (label === 'Function') { + const descPart = properties.description + ? `, n.description = ${formatCypherValue(properties.description)}` + : ''; + query = `MERGE (n:Function {id: ${formatCypherValue(properties.id)}}) SET n.name = ${formatCypherValue(properties.name)}, n.filePath = ${formatCypherValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.isExported = ${!!properties.isExported}, n.content = ${formatCypherValue(properties.content || '')}${descPart}, n.convexEndpointFactory = ${formatCypherValue(properties.convexEndpointFactory ?? '')}`; + } else if (label === 'Const') { + const descPart = properties.description + ? `, n.description = ${formatCypherValue(properties.description)}` + : ''; + query = `MERGE (n:Const {id: ${formatCypherValue(properties.id)}}) SET n.name = ${formatCypherValue(properties.name)}, n.filePath = ${formatCypherValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.content = ${formatCypherValue(properties.content || '')}${descPart}, n.convexEndpointFactory = ${formatCypherValue(properties.convexEndpointFactory ?? '')}`; } else if (TABLES_WITH_EXPORTED.has(label)) { const descPart = properties.description ? `, n.description = ${formatCypherValue(properties.description)}` diff --git a/gitnexus/src/core/lbug/schema.ts b/gitnexus/src/core/lbug/schema.ts index 11471f2e6..058ae1308 100644 --- a/gitnexus/src/core/lbug/schema.ts +++ b/gitnexus/src/core/lbug/schema.ts @@ -51,6 +51,7 @@ CREATE NODE TABLE Function ( isExported BOOLEAN, content STRING, description STRING, + convexEndpointFactory STRING, PRIMARY KEY (id) )`; @@ -170,7 +171,18 @@ export const NAMESPACE_SCHEMA = CODE_ELEMENT_BASE('Namespace'); export const TRAIT_SCHEMA = CODE_ELEMENT_BASE('Trait'); export const IMPL_SCHEMA = CODE_ELEMENT_BASE('Impl'); export const TYPE_ALIAS_SCHEMA = CODE_ELEMENT_BASE('TypeAlias'); -export const CONST_SCHEMA = CODE_ELEMENT_BASE('Const'); +export const CONST_SCHEMA = ` +CREATE NODE TABLE \`Const\` ( + id STRING, + name STRING, + filePath STRING, + startLine INT64, + endLine INT64, + content STRING, + description STRING, + convexEndpointFactory STRING, + PRIMARY KEY (id) +)`; export const STATIC_SCHEMA = CODE_ELEMENT_BASE('Static'); export const VARIABLE_SCHEMA = CODE_ELEMENT_BASE('Variable'); export const PROPERTY_SCHEMA = ` diff --git a/gitnexus/src/mcp/local/convex-metadata.ts b/gitnexus/src/mcp/local/convex-metadata.ts new file mode 100644 index 000000000..82f711198 --- /dev/null +++ b/gitnexus/src/mcp/local/convex-metadata.ts @@ -0,0 +1,72 @@ +import { executeParameterized } from '../../core/lbug/pool-adapter.js'; +import { logger } from '../../core/logger.js'; + +export interface ConvexDispatchMetadata { + readonly factory?: string; + readonly boundary: string; + readonly staleIndex?: true; + readonly probeFailed?: true; +} + +function isMissingConvexMetadataProperty(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error ?? ''); + return /cannot find property\s+convexEndpointFactory|property[^\n]*convexEndpointFactory[^\n]*not (?:defined|found)/i.test( + message, + ); +} + +export async function queryConvexDispatchMetadata( + lbugPath: string, + symbolId: string, + symbolName: string, + symbolType: string, + runQuery: typeof executeParameterized = executeParameterized, +): Promise { + if (symbolType !== 'Const' && symbolType !== 'Function') return undefined; + + const nodeLabel = symbolType === 'Function' ? 'Function' : 'Const'; + + try { + const rows = await runQuery( + lbugPath, + `MATCH (n:${nodeLabel} {id: $symbolId}) + RETURN n.convexEndpointFactory AS factory`, + { symbolId }, + ); + const row = rows[0]; + if (row === undefined) return undefined; + + const factory = String(row.factory ?? row[0] ?? ''); + if (factory.length === 0) return undefined; + + return { + factory, + boundary: + `${symbolName} is exported through Convex ${factory}({...}) and can be addressed through ` + + `the anyApi runtime proxy; callers across that dynamic-dispatch boundary leave no static ` + + `edge, so actual impact may be higher.`, + }; + } catch (error) { + if (isMissingConvexMetadataProperty(error)) { + return { + staleIndex: true, + boundary: + 'Convex runtime-proxy metadata is unavailable because this index predates ' + + 'convexEndpointFactory; re-index before treating impact as exact.', + }; + } + logger.warn( + { + context: 'impact:convex-metadata', + err: error instanceof Error ? error.message : String(error), + }, + 'GitNexus Convex metadata probe failed (degraded)', + ); + return { + probeFailed: true, + boundary: + 'Convex runtime-proxy metadata could not be checked; impact remains a lower bound ' + + 'until the metadata probe succeeds.', + }; + } +} diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 47cb50efd..854b7f267 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -20,6 +20,7 @@ import { } from '../../core/lbug/pool-adapter.js'; import { queryClassBeanMetadata } from './bean-metadata.js'; import { querySpringAopMetadata } from './aop-metadata.js'; +import { queryConvexDispatchMetadata } from './convex-metadata.js'; import { isValidQueryParams } from '../../core/lbug/query-params.js'; import { toDisplayLine } from './line-display.js'; import { LBUG_ID_PROBE_BATCH_SIZE, LBUG_QUERY_BATCH_SIZE } from '../../core/lbug/query-batch.js'; @@ -659,9 +660,8 @@ export interface EpistemicCauses { */ readonly receiverTyping: number; /** - * Symbols on the far side of a dispatch boundary that the traversal could not - * attribute to the queried symbol: implementations plus interface-level - * consumers, summed over the boundary nodes that were flagged. + * Symbols on or beyond a dispatch boundary that the traversal could not + * attribute statically: implementations plus interface-level consumers. * * Unit: SYMBOLS, not call sites — deliberately, because a call-site count is * not derivable on this side. The graph does not retain per-site multiplicity @@ -671,6 +671,10 @@ export interface EpistemicCauses { * symbol reachable through two flagged boundary nodes is counted once per * node, so this is itself a lower bound. * + * Framework runtime-proxy metadata can prove that impact is incomplete but + * cannot provide this magnitude, so it contributes a boundary note while + * leaving this count unchanged. + * * It is still directly comparable in magnitude with `receiverTyping` — both * answer "how much is missing" — which `boundaries.length` was not. */ @@ -711,6 +715,7 @@ function epistemicFrom(dropped: { sites: number; external: number; undecided: number; + dispatch: number; }): { epistemic: 'exact' | 'lower-bound'; boundaries?: string[]; @@ -725,7 +730,7 @@ function epistemicFrom(dropped: { epistemic: 'exact', causes: { receiverTyping: 0, - dispatchBoundary: 0, + dispatchBoundary: dropped.dispatch, externalBoundary: dropped.external, undecidedSatisfaction: 0, }, @@ -740,7 +745,7 @@ function epistemicFrom(dropped: { // would read a different magnitude than the human reading the text. causes: { receiverTyping: dropped.sites, - dispatchBoundary: 0, + dispatchBoundary: dropped.dispatch, externalBoundary: dropped.external, undecidedSatisfaction: dropped.undecided, }, @@ -6775,6 +6780,7 @@ export class LocalBackend { symId: string, symType: string, symName: string, + direction?: 'upstream' | 'downstream', ): Promise<{ epistemic: 'exact' | 'lower-bound'; boundaries?: string[]; @@ -6811,6 +6817,19 @@ export class LocalBackend { // the owning-type hop below would be a graph round-trip per method query in // every index that has no such record — which is every non-Go one, since Go // is the only language with a structural-satisfaction hook. + const convexDispatchPromise = + direction === 'downstream' + ? Promise.resolve(undefined) + : queryConvexDispatchMetadata(repo.lbugPath, symId, symName, symType); + const interfaceRowsPromise = executeParameterized( + repo.lbugPath, + `MATCH (x)-[r:CodeRelation]->(iface) + WHERE x.id = $symId AND r.type IN $heritage + RETURN DISTINCT iface.id AS id, iface.name AS name, labels(iface)[0] AS label + ORDER BY id + LIMIT 25`, + { symId, heritage: HERITAGE_TYPES }, + ).catch(() => []); const undecidedSummary = meta?.undecidedInterfaceSatisfaction; const undecidedDrops = undecidedSummary === undefined @@ -6821,10 +6840,19 @@ export class LocalBackend { ? await this.owningTypeNames(repo, symId) : []), ]); + const convexDispatch = await convexDispatchPromise; const droppedBoundaries = { ...receiverDrops, - notes: [...receiverDrops.notes, ...undecidedDrops.notes], + notes: [ + ...receiverDrops.notes, + ...undecidedDrops.notes, + ...(convexDispatch === undefined ? [] : [convexDispatch.boundary]), + ], undecided: undecidedDrops.undecided, + // Endpoint/probe evidence proves incompleteness but does not expose a + // count of omitted symbols. Keep the magnitude at zero rather than + // inventing one from the presence of a note. + dispatch: 0, }; try { // Discover the interface / abstract supertypes on the target's boundary. @@ -6833,15 +6861,7 @@ export class LocalBackend { if (symType === 'Interface') { boundary.set(symId, { name: symName || '', label: 'Interface' }); } - const ifaceRows = await executeParameterized( - repo.lbugPath, - `MATCH (x)-[r:CodeRelation]->(iface) - WHERE x.id = $symId AND r.type IN $heritage - RETURN DISTINCT iface.id AS id, iface.name AS name, labels(iface)[0] AS label - ORDER BY id - LIMIT 25`, - { symId, heritage: HERITAGE_TYPES }, - ).catch(() => []); + const ifaceRows = await interfaceRowsPromise; for (const r of ifaceRows) { const id = (r.id ?? r[0]) as string; if (id && !boundary.has(id)) { @@ -6920,7 +6940,7 @@ export class LocalBackend { boundaries: [...droppedBoundaries.notes, ...boundaries], causes: { receiverTyping: droppedBoundaries.sites, - dispatchBoundary: dispatchBoundarySymbols, + dispatchBoundary: droppedBoundaries.dispatch + dispatchBoundarySymbols, externalBoundary: droppedBoundaries.external, undecidedSatisfaction: droppedBoundaries.undecided, }, @@ -7027,7 +7047,13 @@ export class LocalBackend { causes?: EpistemicCauses; }> = opts.skipEpistemic ? Promise.resolve({}) - : this.computeEpistemicBoundary(repo, symId, symType, (sym.name || sym[1]) as string); + : this.computeEpistemicBoundary( + repo, + symId, + symType, + (sym.name || sym[1]) as string, + direction, + ); const beanMetadataPromise = opts.skipEpistemic || summaryOnly ? Promise.resolve(undefined) diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 4d59f3ffd..7fea547a5 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -290,9 +290,10 @@ COMPLETENESS OF incoming: alongside symbol/incoming/outgoing the result carries - causes: { receiverTyping, dispatchBoundary, externalBoundary, undecidedSatisfaction } — machine-readable WHY. Every field counts MISSING THINGS, never sentences: - causes.receiverTyping (unit: call sites) > 0 — RESOLVER GAP: the analyzer dropped that many call sites on this name because it could not type the receiver, so they are missing from incoming. Do not read an absent caller as proof none exists. - causes.externalBoundary (unit: call sites) > 0 — the calls left the indexed program (System.out.println, fetch(...)). NOT a defect: no in-graph node could have been reached. An epistemic:'exact' result can carry this. - - causes.dispatchBoundary (unit: symbols) > 0 — DI / interface dispatch: implementations plus interface-level consumers behind a boundary static analysis cannot cross. Irreducible. + - causes.dispatchBoundary (unit: symbols) > 0 — DI or interface dispatch: that many symbols sit on or beyond a boundary static analysis cannot cross. Irreducible. A symbol count, not a site count — per-site multiplicity is not retained for these edges — so compare its magnitude with receiverTyping, not its exact value. A framework runtime-proxy boundary can make epistemic lower-bound while this value remains 0 because endpoint metadata proves the gap but cannot count omitted symbols. + - causes.undecidedSatisfaction (unit: unjudged interface/type pairs) > 0 — the analyzer could not decide whether a type satisfies an interface, so no IMPLEMENTS edge exists and no dispatch boundary was left for the walk to notice. Usually fixable by making the missing dependency available to analysis. -REQUIRES RE-INDEX: causes.receiverTyping and causes.externalBoundary come from index-time metadata only a current analyzer writes; against an older index they read as absent/0, which is indistinguishable from "nothing was dropped". Re-run \`gitnexus analyze\` before trusting a zero there. +REQUIRES RE-INDEX: causes.receiverTyping, causes.externalBoundary, causes.undecidedSatisfaction, and framework runtime-proxy boundary detection depend on index-time metadata that only a current analyzer writes. Against an older index the metadata can be absent, which is indistinguishable from "nothing was dropped" unless the schema probe detects the stale index — re-run \`gitnexus analyze\` before trusting a zero or an apparently exact result. GROUP MODE: set "repo" to "@" to run context in each member repo (aggregated list), or "@/" for one member. If you use "@" only, the member defaults to the lexicographically first key in group.yaml "repos". @@ -484,11 +485,11 @@ Output includes: - causes: { receiverTyping, dispatchBoundary, externalBoundary, undecidedSatisfaction } — the machine-readable split of WHY, so an agent gating its own edits can tell a fixable analyzer gap from an irreducible one. Every field counts MISSING THINGS, never sentences: - causes.receiverTyping (unit: call sites) > 0 — the RESOLVER GAP signal: the analyzer dropped that many call sites because it could not establish the receiver's type (unresolved constructor, factory, chained expression). Those callers are absent from byDepth. Treat the result as incomplete: grep the symbol name before deleting or renaming. - causes.externalBoundary (unit: call sites) > 0 — those calls left the indexed program (System.out.println, fetch(...), os.environ.*). NOT a defect and NOT a reason the count is short: there is no in-graph node any edge could have reached. An epistemic:'exact' result can carry this. - - causes.dispatchBoundary (unit: symbols) > 0 — DI / interface dispatch: that many implementations plus interface-level consumers sit on the far side of a boundary a static walk cannot cross. Irreducible; a compiler refuses here too. A symbol count, not a site count — per-site multiplicity is not retained for these edges — so compare its magnitude with receiverTyping, not its exact value. + - causes.dispatchBoundary (unit: symbols) > 0 — DI or interface dispatch: that many symbols sit on or beyond a boundary a static walk cannot cross. Irreducible. A symbol count, not a site count — per-site multiplicity is not retained for these edges — so compare its magnitude with receiverTyping, not its exact value. A framework runtime-proxy boundary can make epistemic lower-bound while this value remains 0 because endpoint metadata proves the gap but cannot count omitted symbols. - causes.undecidedSatisfaction (unit: unjudged interface/type pairs) > 0 — the analyzer could not DECIDE whether a type satisfies an interface (a type in a required signature named a package it could not resolve), so no IMPLEMENTS edge exists and no dispatch boundary was left for the walk to notice. Distinct from every cause above, which count decided facts that could not be attributed; this one counts questions never answered. It is the only cause that shortens a result WITHOUT leaving a trace in the graph, so an unhedged zero on a symbol reached only through such an interface would otherwise read as 'nobody calls this'. Usually fixable: it most often means a dependency is missing from the analyzed tree. -REQUIRES RE-INDEX: causes.receiverTyping, causes.externalBoundary and causes.undecidedSatisfaction are read from index-time metadata that only a current analyzer writes. Against an older index they read as absent/0, which is indistinguishable from "nothing was dropped" — re-run \`gitnexus analyze\` before trusting a zero there. +REQUIRES RE-INDEX: causes.receiverTyping, causes.externalBoundary, causes.undecidedSatisfaction, and framework runtime-proxy boundary detection depend on index-time metadata that only a current analyzer writes. Against an older index the metadata can be absent, which is indistinguishable from "nothing was dropped" unless the schema probe detects the stale index — re-run \`gitnexus analyze\` before trusting a zero or an apparently exact result. Depth groups: - d=1: WILL BREAK (direct callers/importers) diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 6de50176a..903a57016 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -569,7 +569,16 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // IN-FLIGHT claim, not above origin/main. Every open PR touching gitnexus/ was // scanned; #3017 is the only other claimant. // RE-CHECK AGAINST origin/main AND OPEN PRs IMMEDIATELY BEFORE MERGING. -const SCHEMA_BUMP = 72; +// +// 72 -> 74 adds import-proven Convex endpoint metadata to Const/Function worker +// output. A warm v72 cache has no convexEndpointFactory property, so the MCP +// impact probe would keep claiming exact results for unchanged endpoints. The +// parse-cache bump makes unchanged files re-parse; analyzer runner identity +// drift separately forces the graph re-emit (run-analyze.ts), and an id/schema +// migration needs both guarantees. Version 73 is intentionally skipped because +// concurrent PR #3046 (fixes #3041) claims it. Re-check main and open PRs +// immediately before merge. +const SCHEMA_BUMP = 74; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/integration/convex-impact-epistemic-e2e.test.ts b/gitnexus/test/integration/convex-impact-epistemic-e2e.test.ts new file mode 100644 index 000000000..74e1f1073 --- /dev/null +++ b/gitnexus/test/integration/convex-impact-epistemic-e2e.test.ts @@ -0,0 +1,218 @@ +import fs from 'fs'; +import path from 'path'; +import { beforeAll, expect, it, vi } from 'vitest'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; +import { + loadParseCache, + PARSE_CACHE_VERSION, + pruneCache, + saveParseCache, + type ParseCache, +} from '../../src/storage/parse-cache.js'; +import { + getDurableParsedFileDir, + pruneAndSaveDurableParsedFileStore, +} from '../../src/storage/parsedfile-store.js'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { listRegisteredRepos } from '../../src/storage/repo-manager.js'; +import { withTestLbugDB } from '../helpers/test-indexed-db.js'; + +vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => ({ + ...(await importOriginal()), + listRegisteredRepos: vi.fn().mockResolvedValue([]), + cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }), + findSiblingClones: vi.fn().mockResolvedValue([]), +})); + +let repoDir = ''; +let warmReplayUsedWorkers = true; +const replayProperties = new Map(); +let bareHandlerFunctionId = ''; + +withTestLbugDB( + 'convex-impact-epistemic-e2e', + (handle) => { + let backend: LocalBackend; + + beforeAll(() => { + backend = (handle as typeof handle & { _backend: LocalBackend })._backend; + }); + + it('persists import-proven endpoint metadata through a warm parse cache', () => { + expect(warmReplayUsedWorkers).toBe(false); + expect(replayProperties).toEqual( + new Map([ + ['aliasedWrite', 'mutation'], + ['generatedAction', 'internalAction'], + ['javascriptQuery', 'query'], + ['bareHandler', 'query'], + ['publicQuery', 'query'], + ]), + ); + }); + + it.each([ + ['publicQuery', 'endpoints.ts', 'query'], + ['aliasedWrite', 'endpoints.ts', 'mutation'], + ['generatedAction', 'endpoints.ts', 'internalAction'], + ['javascriptQuery', 'endpoints.js', 'query'], + ])( + 'marks real indexed Convex endpoint %s as lower-bound', + async (target, filePath, factory) => { + const result = await backend.callTool('impact', { + target, + file_path: filePath, + direction: 'upstream', + }); + + expect(result.epistemic).toBe('lower-bound'); + expect(result.boundaries.join(' ')).toContain(`Convex ${factory}`); + expect(result.causes.dispatchBoundary).toBe(0); + }, + ); + + it('marks a bare Function handler as lower-bound', async () => { + expect(bareHandlerFunctionId).not.toBe(''); + const result = await backend.callTool('impact', { + target_uid: bareHandlerFunctionId, + direction: 'upstream', + }); + + expect(result.epistemic).toBe('lower-bound'); + expect(result.boundaries.join(' ')).toContain('Convex query'); + expect(result.causes.dispatchBoundary).toBe(0); + }); + + it.each([ + ['unrelatedQuery', 'endpoints.ts'], + ['localQuery', 'local.ts'], + ])('keeps non-Convex same-shape control %s exact', async (target, filePath) => { + const result = await backend.callTool('impact', { + target, + file_path: filePath, + direction: 'upstream', + }); + + expect(result.epistemic).toBe('exact'); + expect(result.boundaries).toBeUndefined(); + }); + + it('does not apply the inbound Convex boundary to downstream impact', async () => { + const result = await backend.callTool('impact', { + target: 'publicQuery', + file_path: 'endpoints.ts', + direction: 'downstream', + }); + + expect(result.epistemic).toBe('exact'); + expect(result.boundaries).toBeUndefined(); + }); + + it('carries the Convex boundary through context()', async () => { + const result = await backend.callTool('context', { + name: 'publicQuery', + file_path: 'endpoints.ts', + }); + + expect(result.status).toBe('found'); + expect(result.epistemic).toBe('lower-bound'); + expect(result.boundaries.join(' ')).toContain('Convex query'); + }); + + it('keeps a non-Convex same-shape context exact', async () => { + const result = await backend.callTool('context', { + name: 'localQuery', + file_path: 'local.ts', + }); + + expect(result.status).toBe('found'); + expect(result.epistemic).toBe('exact'); + expect(result.boundaries).toBeUndefined(); + }); + }, + { + beforeFTS: async (dbPath) => { + const storageDir = path.dirname(dbPath); + repoDir = path.join(storageDir, 'repo'); + const cacheDir = path.join(storageDir, 'parse-cache'); + fs.mkdirSync(repoDir, { recursive: true }); + fs.writeFileSync( + path.join(repoDir, 'endpoints.ts'), + `import { queryGeneric as query, mutationGeneric as write } from 'convex/server'; +import { query as generatedQuery, internalAction as internalRun } from './_generated/server'; +import { query as dbQuery } from './database'; + +export const publicQuery = // legal line-comment trivia + query({ handler: async () => null }); +export const aliasedWrite = write({ handler: async () => null }); +export const generatedAction = internalRun({ handler: async () => null }); +export const bareHandler = generatedQuery(async () => null); +export const unrelatedQuery = dbQuery({ handler: async () => null }); +`, + ); + fs.writeFileSync( + path.join(repoDir, 'local.ts'), + `function query(config: unknown) { return config; } +export const localQuery = query({ handler: async () => null }); +`, + ); + fs.writeFileSync( + path.join(repoDir, 'endpoints.js'), + `import { query } from './_generated/server.js'; +export const javascriptQuery = query({ handler: async () => null }); +`, + ); + + const cold: ParseCache = { + version: PARSE_CACHE_VERSION, + entries: new Map(), + usedKeys: new Set(), + storagePath: cacheDir, + onDiskKeys: new Set(), + }; + await runPipelineFromRepo(repoDir, () => {}, { parseCache: cold, workerPoolSize: 1 }); + pruneCache(cold, cold.usedKeys); + const savedKeys = await saveParseCache(cacheDir, cold); + await pruneAndSaveDurableParsedFileStore( + getDurableParsedFileDir(cacheDir), + PARSE_CACHE_VERSION, + new Set(savedKeys), + ); + + const warm = await loadParseCache(cacheDir); + const replay = await runPipelineFromRepo(repoDir, () => {}, { + parseCache: warm ?? undefined, + workerPoolSize: 1, + }); + warmReplayUsedWorkers = replay.usedWorkerPool; + replay.graph.forEachNode((node) => { + if (node.properties.convexEndpointFactory !== undefined) { + replayProperties.set(node.properties.name, node.properties.convexEndpointFactory); + if (node.label === 'Function' && node.properties.name === 'bareHandler') { + bareHandlerFunctionId = node.id; + } + } + }); + + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + await adapter.loadGraphToLbug(replay.graph, repoDir, storageDir); + }, + poolAdapter: true, + afterSetup: async (handle) => { + vi.mocked(listRegisteredRepos).mockResolvedValue([ + { + name: 'convex-e2e', + path: repoDir, + storagePath: handle.tmpHandle.dbPath, + indexedAt: new Date().toISOString(), + lastCommit: 'convex-e2e', + stats: { files: 3, nodes: 6, communities: 0, processes: 0 }, + }, + ]); + const backend = new LocalBackend(); + await backend.init(); + (handle as typeof handle & { _backend?: LocalBackend })._backend = backend; + }, + timeout: 180_000, + }, +); diff --git a/gitnexus/test/integration/impact-epistemic-lower-bound.test.ts b/gitnexus/test/integration/impact-epistemic-lower-bound.test.ts index 58557daad..6ca1d6dde 100644 --- a/gitnexus/test/integration/impact-epistemic-lower-bound.test.ts +++ b/gitnexus/test/integration/impact-epistemic-lower-bound.test.ts @@ -42,6 +42,21 @@ const SEED = [ `CREATE (leaf:Function {id: 'Function:src/util.ts:formatDate', name: 'formatDate', filePath: 'src/util.ts', startLine: 1, endLine: 3, isExported: true, content: '', description: ''})`, `CREATE (caller:Function {id: 'Function:src/page.ts:renderHeader', name: 'renderHeader', filePath: 'src/page.ts', startLine: 1, endLine: 10, isExported: true, content: '', description: ''})`, `MATCH (a:Function {id:'Function:src/page.ts:renderHeader'}), (b:Function {id:'Function:src/util.ts:formatDate'}) CREATE (a)-[:CodeRelation {type:'CALLS', confidence:0.9, reason:'direct', step:0}]->(b)`, + + ...[ + ['listOrders', 'query'], + ['createOrder', 'mutation'], + ['syncOrders', 'action'], + ['readInternal', 'internalQuery'], + ['writeInternal', 'internalMutation'], + ['runInternal', 'internalAction'], + ].map( + ([name, factory]) => + `CREATE (:Const {id: 'Const:src/convex.ts:${name}', name: '${name}', filePath: 'src/convex.ts', startLine: 1, endLine: 3, content: '', description: '', convexEndpointFactory: '${factory}'})`, + ), + `CREATE (:Const {id: 'Const:src/negative.ts:localQuery', name: 'localQuery', filePath: 'src/negative.ts', startLine: 1, endLine: 1, content: '', description: '', convexEndpointFactory: ''})`, + `CREATE (:Const {id: 'Const:src/negative.ts:nestedQuery', name: 'nestedQuery', filePath: 'src/negative.ts', startLine: 2, endLine: 2, content: '', description: '', convexEndpointFactory: ''})`, + `CREATE (:Const {id: 'Const:src/negative.ts:memberQuery', name: 'memberQuery', filePath: 'src/negative.ts', startLine: 3, endLine: 3, content: '', description: '', convexEndpointFactory: ''})`, ]; withTestLbugDB( @@ -101,6 +116,40 @@ withTestLbugDB( expect(result.impactedCount).toBeGreaterThanOrEqual(1); }); + it.each([ + ['listOrders', 'query'], + ['createOrder', 'mutation'], + ['syncOrders', 'action'], + ['readInternal', 'internalQuery'], + ['writeInternal', 'internalMutation'], + ['runInternal', 'internalAction'], + ])('marks Convex %s/%s runtime dispatch as a lower bound', async (target, factory) => { + const result = await backend.callTool('impact', { + target, + file_path: 'src/convex.ts', + direction: 'upstream', + }); + + expect(result.epistemic).toBe('lower-bound'); + expect(result.boundaries.join(' ')).toContain(`Convex ${factory}`); + expect(result.boundaries.join(' ')).toContain('anyApi'); + expect(result.causes.dispatchBoundary).toBe(0); + }); + + it.each(['localQuery', 'nestedQuery', 'memberQuery'])( + 'keeps non-wrapper control %s exact', + async (target) => { + const result = await backend.callTool('impact', { + target, + file_path: 'src/negative.ts', + direction: 'upstream', + }); + + expect(result.epistemic).toBe('exact'); + expect(result.boundaries).toBeUndefined(); + }, + ); + it('context() carries the same epistemic signal', async () => { const result = await backend.callTool('context', { name: 'EmailLogger', diff --git a/gitnexus/test/unit/convex-dispatch-metadata.test.ts b/gitnexus/test/unit/convex-dispatch-metadata.test.ts new file mode 100644 index 000000000..f192e7550 --- /dev/null +++ b/gitnexus/test/unit/convex-dispatch-metadata.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { queryConvexDispatchMetadata } from '../../src/mcp/local/convex-metadata.js'; + +describe('Convex dispatch metadata compatibility', () => { + it('marks a pre-property index as conservatively incomplete', async () => { + const missingProperty = async (): Promise => { + throw new Error('Cannot find property convexEndpointFactory for n'); + }; + + const result = await queryConvexDispatchMetadata( + '/tmp/old-index', + 'Const:x', + 'x', + 'Const', + missingProperty, + ); + + expect(result?.staleIndex).toBe(true); + expect(result?.boundary).toContain('re-index'); + }); + + it('marks unrelated query failures as conservatively incomplete', async () => { + const transientFailure = async (): Promise => { + throw new Error('database busy'); + }; + + const result = await queryConvexDispatchMetadata( + '/tmp/index', + 'Const:x', + 'x', + 'Const', + transientFailure, + ); + + expect(result?.probeFailed).toBe(true); + expect(result?.boundary).toContain('could not be checked'); + }); + + it('queries Function metadata without an undeclared deterministic LIMIT', async () => { + let cypher = ''; + const runQuery = async (_path: string, query: string) => { + cypher = query; + return [{ factory: 'query' }]; + }; + + await expect( + queryConvexDispatchMetadata('/tmp/index', 'Function:x', 'x', 'Function', runQuery), + ).resolves.toMatchObject({ factory: 'query' }); + expect(cypher).toContain('MATCH (n:Function'); + expect(cypher).not.toContain('LIMIT'); + }); +}); diff --git a/gitnexus/test/unit/convex-metadata-persistence-contract.test.ts b/gitnexus/test/unit/convex-metadata-persistence-contract.test.ts new file mode 100644 index 000000000..8fd89e820 --- /dev/null +++ b/gitnexus/test/unit/convex-metadata-persistence-contract.test.ts @@ -0,0 +1,123 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CONST_SCHEMA, FUNCTION_SCHEMA } from '../../src/core/lbug/schema.js'; + +interface FakeQueryResult { + getAll: () => Promise; + close: () => void; +} + +function makeConfigMock() { + const queries: string[] = []; + const queryResult: FakeQueryResult = { getAll: async () => [], close: vi.fn() }; + const conn = { + query: vi.fn(async (cypher: string) => { + queries.push(cypher); + return queryResult; + }), + close: vi.fn(async () => {}), + }; + const db = { close: vi.fn(async () => {}) }; + return { + queries, + mock: { + openLbugConnection: vi.fn(async () => ({ db, conn })), + closeLbugConnection: async () => { + await conn.close(); + await db.close(); + }, + isDbBusyError: vi.fn(() => false), + isOpenRetryExhausted: vi.fn(() => false), + isWalCorruptionError: vi.fn(() => false), + toNativeSafePath: (value: string) => value, + resolveNativeSafeStorageDir: (value: string) => value, + WAL_RECOVERY_SUGGESTION: 'run analyze --force', + waitForWindowsHandleRelease: vi.fn(async () => true), + }, + }; +} + +const endpoint = { + id: 'Const:src/endpoints.ts:getUser', + name: 'getUser', + filePath: 'src/endpoints.ts', + startLine: 1, + endLine: 3, + isExported: true, + content: 'query({ handler: getUser })', + convexEndpointFactory: 'query', +}; + +describe('Convex endpoint metadata persistence contract', () => { + afterEach(() => { + vi.doUnmock('../../src/core/lbug/lbug-config.js'); + vi.resetModules(); + vi.clearAllMocks(); + }); + + it('keeps the Const schema and COPY column list aligned', async () => { + const { getCopyQuery } = await import('../../src/core/lbug/lbug-adapter.js'); + const copyQuery = getCopyQuery('Const', '/tmp/const.csv'); + const functionCopyQuery = getCopyQuery('Function', '/tmp/function.csv'); + + expect(CONST_SCHEMA).toContain('convexEndpointFactory STRING'); + expect(FUNCTION_SCHEMA).toContain('convexEndpointFactory STRING'); + expect(CONST_SCHEMA).not.toContain('isExported BOOLEAN'); + expect(copyQuery).toContain('content, description, convexEndpointFactory'); + expect(functionCopyQuery).toContain('isExported, content, description, convexEndpointFactory'); + expect(copyQuery).not.toContain('isExported'); + }); + + it('persists the property through single-node CREATE', async () => { + const { mock, queries } = makeConfigMock(); + vi.doMock('../../src/core/lbug/lbug-config.js', () => mock); + const { insertNodeToLbug } = await import('../../src/core/lbug/lbug-adapter.js'); + + await expect(insertNodeToLbug('Const', endpoint, '/tmp/convex-create/lbug')).resolves.toBe( + true, + ); + + const createQuery = queries.find((query) => query.startsWith('CREATE (n:Const')); + expect(createQuery).toContain("convexEndpointFactory: 'query'"); + expect(createQuery).not.toContain('isExported'); + + await expect( + insertNodeToLbug( + 'Function', + { ...endpoint, id: 'Function:src/endpoints.ts:getUser' }, + '/tmp/convex-create/lbug', + ), + ).resolves.toBe(true); + const functionQuery = queries.find((query) => query.startsWith('CREATE (n:Function')); + expect(functionQuery).toContain("convexEndpointFactory: 'query'"); + expect(functionQuery).toContain('isExported: true'); + }); + + it('persists the property through incremental MERGE', async () => { + const { mock, queries } = makeConfigMock(); + vi.doMock('../../src/core/lbug/lbug-config.js', () => mock); + const { batchInsertNodesToLbug } = await import('../../src/core/lbug/lbug-adapter.js'); + + await expect( + batchInsertNodesToLbug([{ label: 'Const', properties: endpoint }], '/tmp/convex-merge/lbug'), + ).resolves.toEqual({ inserted: 1, failed: 0 }); + + const mergeQuery = queries.find((query) => query.startsWith('MERGE (n:Const')); + expect(mergeQuery).toContain("n.convexEndpointFactory = 'query'"); + expect(mergeQuery).not.toContain('isExported'); + + await expect( + batchInsertNodesToLbug( + [ + { + label: 'Function', + properties: { ...endpoint, id: 'Function:src/endpoints.ts:getUser' }, + }, + ], + '/tmp/convex-merge/lbug', + ), + ).resolves.toEqual({ inserted: 1, failed: 0 }); + const functionQuery = queries.find((query) => query.startsWith('MERGE (n:Function')); + expect(functionQuery).toContain("n.convexEndpointFactory = 'query'"); + expect(functionQuery).toContain('n.isExported = true'); + }); +}); diff --git a/gitnexus/test/unit/convex-metadata.test.ts b/gitnexus/test/unit/convex-metadata.test.ts new file mode 100644 index 000000000..59961c946 --- /dev/null +++ b/gitnexus/test/unit/convex-metadata.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; +import Parser from 'tree-sitter'; +import TypeScript from 'tree-sitter-typescript'; +import type { ParsedImport } from 'gitnexus-shared'; +import { extractConvexEndpointProperties } from '../../src/core/ingestion/languages/typescript/convex-endpoint-metadata.js'; +import type { SyntaxNode } from '../../src/core/ingestion/utils/ast-helpers.js'; + +const parser = new Parser(); +parser.setLanguage(TypeScript.typescript as Parameters[0]); + +function nodeOfType(source: string, type: string): SyntaxNode { + const root = parser.parse(source).rootNode as unknown as SyntaxNode; + const stack = [root]; + while (stack.length > 0) { + const node = stack.pop()!; + if (node.type === type) return node; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child) stack.push(child); + } + } + throw new Error(`fixture has no ${type}`); +} + +const namedImport = ( + targetRaw: string, + importedName: string, + localName = importedName, +): ParsedImport => ({ + kind: localName === importedName ? 'named' : 'alias', + targetRaw, + importedName, + localName, + ...(localName === importedName ? {} : { alias: localName }), +}); + +function extract( + source: string, + imports: readonly ParsedImport[], + isExported = true, + nodeLabel = 'Const', + definitionType = 'export_statement', +) { + return extractConvexEndpointProperties({ + nodeLabel, + nodeName: 'updateDraft', + definitionNode: nodeOfType(source, definitionType), + parsedImports: imports, + isExported, + }); +} + +describe('Convex endpoint metadata extraction', () => { + it('canonicalizes a generic convex/server factory across line-comment trivia', () => { + expect( + extract( + `export const updateDraft = // legal trivia\n mutation({ handler: async () => null });`, + [namedImport('convex/server', 'mutationGeneric', 'mutation')], + ), + ).toEqual({ convexEndpointFactory: 'mutation' }); + }); + + it('preserves the canonical factory through a generated-server import alias', () => { + expect( + extract(`export const updateDraft = write({ handler: async () => null });`, [ + namedImport('../_generated/server', 'internalMutation', 'write'), + ]), + ).toEqual({ convexEndpointFactory: 'internalMutation' }); + }); + + it('accepts generated-server package paths and httpAction', () => { + expect( + extract(`export const updateDraft = route(async () => null);`, [ + namedImport('convex/_generated/server', 'httpAction', 'route'), + ]), + ).toEqual({ convexEndpointFactory: 'httpAction' }); + }); + + it.each(['arrow_function', 'function_expression'])('stamps a bare %s handler capture', (type) => { + const expression = + type === 'arrow_function' ? 'async () => null' : 'async function () { return null; }'; + expect( + extract( + `export const updateDraft = query(${expression});`, + [namedImport('./_generated/server', 'query')], + true, + 'Function', + 'export_statement', + ), + ).toEqual({ convexEndpointFactory: 'query' }); + }); + + it.each([ + ['unrelated import', [namedImport('./database', 'query')], true], + ['non-generic convex/server API', [namedImport('convex/server', 'query')], true], + ['unexported declaration', [namedImport('./_generated/server', 'query')], false], + ] as const)('rejects %s', (_case, imports, isExported) => { + expect( + extract(`export const updateDraft = query({ handler: () => null });`, imports, isExported), + ).toBeUndefined(); + }); + + it.each([ + 'export const updateDraft = sdk.query({ handler: () => null });', + 'export const updateDraft = wrap(query({ handler: () => null }));', + 'export const updateDraft = query(buildConfig());', + ])('rejects unsupported wrapper shape: %s', (source) => { + expect(extract(source, [namedImport('./_generated/server', 'query')])).toBeUndefined(); + }); + + it('does not search into a nested same-name declarator', () => { + expect( + extract( + `export function updateDraft() { + const updateDraft = query({ handler: () => null }); + return updateDraft; + }`, + [namedImport('./_generated/server', 'query')], + true, + 'Function', + 'function_declaration', + ), + ).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/unit/definition-properties.test.ts b/gitnexus/test/unit/definition-properties.test.ts new file mode 100644 index 000000000..bf7446864 --- /dev/null +++ b/gitnexus/test/unit/definition-properties.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + mergeCanonicalDefinitionProperties, + runDefinitionPropertiesExtractor, + type DefinitionPropertiesContext, +} from '../../src/core/ingestion/language-provider.js'; + +const context = { + nodeLabel: 'Const', + nodeName: 'endpoint', + definitionNode: {}, + parsedImports: [], + isExported: true, +} as unknown as DefinitionPropertiesContext; + +describe('definition property provider guardrails', () => { + it('isolates a throwing extractor and permits the next definition to continue', () => { + const failure = new Error('provider failed'); + const onError = vi.fn(); + + expect( + runDefinitionPropertiesExtractor( + () => { + throw failure; + }, + context, + onError, + ), + ).toBeUndefined(); + expect(onError).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledWith(failure); + + expect( + runDefinitionPropertiesExtractor( + () => ({ convexEndpointFactory: 'query' }), + context, + onError, + ), + ).toEqual({ convexEndpointFactory: 'query' }); + expect(onError).toHaveBeenCalledOnce(); + }); + + it('keeps canonical identity and location fields authoritative', () => { + const properties = mergeCanonicalDefinitionProperties( + { + name: 'spoofed', + filePath: 'wrong.ts', + startLine: 999, + isExported: false, + convexEndpointFactory: 'query', + }, + { + name: 'endpoint', + filePath: 'src/endpoints.ts', + startLine: 7, + isExported: true, + }, + ); + + expect(properties).toEqual({ + name: 'endpoint', + filePath: 'src/endpoints.ts', + startLine: 7, + isExported: true, + convexEndpointFactory: 'query', + }); + }); +}); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index cd353e800..57a76437b 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -230,14 +230,14 @@ describe('PARSE_CACHE_VERSION', () => { // replayed pre-feature captures and the feature was inert. 71 is the next // free value above every claim at this merge — origin/main is 70 and open // PR #3017 already claims 71, so 71 would have collided. - it('pins SCHEMA_BUMP to 72 so concurrent bumps cannot silently collide (#2766)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(72); + it('pins SCHEMA_BUMP to 74 so concurrent bumps cannot silently collide (#2766)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(74); // The PREVIOUS version must fail the reuse gate, not merely differ from the // current one — a hardcoded number outside the conflict hunk rebases cleanly // while being wrong, which is exactly how the 37/38 exact clashes landed. // Every nearby historical or in-flight value is rejected, including 69, // which carried the route-table payload before this merge. - for (const taken of [59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71]) { + for (const taken of [59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73]) { expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken); } }); From 88df18b8294aac2f9232a5c6aed5bf44deadd287 Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Wed, 26 Aug 2026 13:24:39 +0100 Subject: [PATCH 03/17] fix(ingestion): discover nested source directories (#3043) --- gitnexus/src/config/ignore-service.ts | 45 ++++++- .../extractors/python-workspace-extractor.ts | 10 +- .../src/core/ingestion/filesystem-walker.ts | 32 ++++- .../node-workspace-packages.ts | 7 +- .../languages/typescript/tsconfig.ts | 8 +- .../integration/filesystem-walker.test.ts | 126 ++++++++++++++++++ .../integration/ignore-and-skip-e2e.test.ts | 63 +++++++++ .../group/python-workspace-extractor.test.ts | 25 ++++ gitnexus/test/unit/ignore-service.test.ts | 47 ++++++- .../test/unit/node-workspace-scope.test.ts | 16 +++ gitnexus/test/unit/tsconfig-index.test.ts | 15 +++ 11 files changed, 374 insertions(+), 20 deletions(-) diff --git a/gitnexus/src/config/ignore-service.ts b/gitnexus/src/config/ignore-service.ts index 163998a8e..645754427 100644 --- a/gitnexus/src/config/ignore-service.ts +++ b/gitnexus/src/config/ignore-service.ts @@ -1,4 +1,5 @@ import ignore, { type Ignore } from 'ignore'; +import { existsSync } from 'fs'; import fs from 'fs/promises'; import nodePath from 'path'; import type { Path } from 'path-scurry'; @@ -31,12 +32,15 @@ const DEFAULT_IGNORE_LIST = new Set([ // 'packages' removed - commonly used for monorepo source code (lerna, pnpm, yarn workspaces) 'venv', '.venv', - 'env', '.env', + // Bare `env/` can be application source or a Python virtual environment. + // Path-aware rules below prune it at the root and wherever pyvenv.cfg marks + // a virtual environment, while preserving ordinary nested source folders. '__pycache__', '.pytest_cache', '.mypy_cache', 'site-packages', + 'dist-packages', '.tox', 'eggs', '.eggs', @@ -86,8 +90,9 @@ const DEFAULT_IGNORE_LIST = new Set([ // Generated/Compiled '.generated', - 'generated', 'auto-generated', + // Bare `generated/` can contain tracked source-of-truth code. Build output + // remains covered by .gitignore/.gitnexusignore and the unambiguous names. 'monaco-workers', // Monaco editor web-worker bundles generated for browser runtime '.terraform', '.serverless', @@ -106,6 +111,14 @@ const DEFAULT_IGNORE_LIST = new Set([ '__snapshots__', ]); +// Ambiguous names that conventionally denote generated artifacts only at the +// repository root. Nested directories with these names are frequently source +// modules (for example apps/web/src/env or packages/api/generated). +const ROOT_ARTIFACT_DIRECTORIES = new Set(['env', 'generated']); + +const isRootArtifactDirectory = (relativePath: string, name: string): boolean => + !relativePath.includes('/') && ROOT_ARTIFACT_DIRECTORIES.has(name); + const IGNORED_EXTENSIONS = new Set([ // Images '.png', @@ -290,6 +303,10 @@ export const shouldIgnorePath = (filePath: string): boolean => { const fileName = parts[parts.length - 1]; const fileNameLower = fileName.toLowerCase(); + if (parts.length > 0 && isRootArtifactDirectory(parts[0], parts[0])) { + return true; + } + // Laravel compiles Blade templates into generated PHP cache files under // storage/framework/views. Source templates live in resources/views and are // handled separately; compiled cache should not become source-of-truth. Keep @@ -329,10 +346,8 @@ export const shouldIgnorePath = (filePath: string): boolean => { if ( fileNameLower.includes('.bundle.') || fileNameLower.includes('.chunk.') || - fileNameLower.includes('.generated.') || - fileNameLower.endsWith('.d.ts') + fileNameLower.includes('.generated.') ) { - // TypeScript declaration files return true; } @@ -344,6 +359,20 @@ export const isHardcodedIgnoredDirectory = (name: string): boolean => { return DEFAULT_IGNORE_LIST.has(name); }; +/** Apply directory ignore rules that depend on repository-relative depth. */ +export const isHardcodedIgnoredDirectoryAtPath = ( + repoRoot: string, + directoryPath: string, +): boolean => { + const name = nodePath.basename(directoryPath); + if (isHardcodedIgnoredDirectory(name)) return true; + + const relative = nodePath.relative(repoRoot, directoryPath).replace(/\\/g, '/'); + if (isRootArtifactDirectory(relative, name)) return true; + + return name === 'env' && existsSync(nodePath.join(directoryPath, 'pyvenv.cfg')); +}; + /** * Load .gitignore and .gitnexusignore rules from the repo root. * Returns an `ignore` instance with all patterns, or null if no files found. @@ -496,8 +525,10 @@ export const createIgnoreFilter = async (repoPath: string, options?: IgnoreOptio // last-match-wins: `!__tests__/` + `__tests__/generated/` still // blocks descent into `__tests__/generated/`. if (ig && rel && hasExplicitUnignore(ig, rel) && !ig.ignores(rel + '/')) return false; - // Hardcoded list: block descent into well-known noise directories. - if (DEFAULT_IGNORE_LIST.has(p.name)) return true; + // Hardcoded and path-aware rules prune whole trees before glob walks them. + if (rel && isHardcodedIgnoredDirectoryAtPath(repoPath, nodePath.join(repoPath, rel))) { + return true; + } // Check against .gitignore / .gitnexusignore patterns. // Since childrenIgnored is only called for directories, always test with // a trailing slash. This ensures directory-only negation patterns (e.g. diff --git a/gitnexus/src/core/group/extractors/python-workspace-extractor.ts b/gitnexus/src/core/group/extractors/python-workspace-extractor.ts index 4453852a6..c07e5d8cc 100644 --- a/gitnexus/src/core/group/extractors/python-workspace-extractor.ts +++ b/gitnexus/src/core/group/extractors/python-workspace-extractor.ts @@ -2,7 +2,11 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import type { CypherExecutor } from '../contract-extractor.js'; import type { GroupManifestLink, ContractRole } from '../types.js'; -import { shouldIgnorePath, loadIgnoreRules } from '../../../config/ignore-service.js'; +import { + shouldIgnorePath, + loadIgnoreRules, + isHardcodedIgnoredDirectoryAtPath, +} from '../../../config/ignore-service.js'; import { logger } from '../../logger.js'; interface PythonPackageMeta { @@ -161,9 +165,11 @@ async function findPythonFiles(repoPath: string): Promise { for (const entry of entries) { const childRel = rel ? `${rel}/${entry.name}` : entry.name; if (entry.isDirectory()) { + const childPath = path.join(dir, entry.name); if (shouldIgnorePath(childRel)) continue; + if (isHardcodedIgnoredDirectoryAtPath(repoPath, childPath)) continue; if (ig && ig.ignores(childRel + '/')) continue; - await walk(path.join(dir, entry.name), childRel); + await walk(childPath, childRel); } else if (entry.name.endsWith('.py')) { if (shouldIgnorePath(childRel)) continue; if (ig && ig.ignores(childRel)) continue; diff --git a/gitnexus/src/core/ingestion/filesystem-walker.ts b/gitnexus/src/core/ingestion/filesystem-walker.ts index fc65cda09..804921a62 100644 --- a/gitnexus/src/core/ingestion/filesystem-walker.ts +++ b/gitnexus/src/core/ingestion/filesystem-walker.ts @@ -22,6 +22,27 @@ export interface FilePath { const READ_CONCURRENCY = 32; const ANALYZE_PROGRESS_ACTIVE_ENV = 'GITNEXUS_ANALYZE_PROGRESS_ACTIVE'; +const DECLARATION_COMPANION_SUFFIXES = [ + { declaration: '.d.ts', implementations: ['.ts', '.tsx'] }, + { declaration: '.d.mts', implementations: ['.mts'] }, + { declaration: '.d.cts', implementations: ['.cts'] }, +] as const; + +const hasImplementationSibling = ( + declarationPath: string, + scannedPaths: ReadonlySet, +): boolean => { + const companion = DECLARATION_COMPANION_SUFFIXES.find(({ declaration }) => + declarationPath.endsWith(declaration), + ); + if (!companion) return false; + + // Keep standalone declarations. Only suppress declaration output that sits + // beside an implementation with the corresponding module suffix. + const stem = declarationPath.slice(0, -companion.declaration.length); + return companion.implementations.some((suffix) => scannedPaths.has(`${stem}${suffix}`)); +}; + const warnLargeFileSkip = (message: string): void => { if (process.env[ANALYZE_PROGRESS_ACTIVE_ENV] === '1') { // analyze.ts routes console.warn through the progress bar logger while @@ -84,10 +105,17 @@ export const walkRepositoryPaths = async ( } } + const scannedPaths = new Set(entries.map((entry) => entry.path)); + const deduplicatedEntries = entries.filter( + (entry) => !hasImplementationSibling(entry.path, scannedPaths), + ); + // Filesystem/glob traversal order is not stable across filesystems or repeated // scans. Canonicalize once at the scan boundary so every downstream phase sees // the same repository order. - entries.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)); + deduplicatedEntries.sort((left, right) => + left.path < right.path ? -1 : left.path > right.path ? 1 : 0, + ); if (skippedLarge > 0) { const isDefault = maxFileSizeBytes === DEFAULT_MAX_FILE_SIZE_BYTES; @@ -123,7 +151,7 @@ export const walkRepositoryPaths = async ( } } - return entries; + return deduplicatedEntries; }; /** diff --git a/gitnexus/src/core/ingestion/import-resolvers/node-workspace-packages.ts b/gitnexus/src/core/ingestion/import-resolvers/node-workspace-packages.ts index 712359a41..5750da917 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/node-workspace-packages.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/node-workspace-packages.ts @@ -19,7 +19,7 @@ import fs from 'fs/promises'; import path from 'path'; import { createRequire } from 'node:module'; -import { isHardcodedIgnoredDirectory } from '../../../config/ignore-service.js'; +import { isHardcodedIgnoredDirectoryAtPath } from '../../../config/ignore-service.js'; import { logger } from '../../logger.js'; import { resolveFile } from '../languages/typescript/file-candidates.js'; @@ -361,9 +361,10 @@ export async function loadNodeWorkspacePackages( for (const entry of entries) { if (entry.isDirectory()) { - if (isHardcodedIgnoredDirectory(entry.name)) continue; + const childDir = path.join(dir, entry.name); + if (isHardcodedIgnoredDirectoryAtPath(repoRoot, childDir)) continue; if (depth < SCAN_MAX_DEPTH) { - queue.push({ dir: path.join(dir, entry.name), depth: depth + 1 }); + queue.push({ dir: childDir, depth: depth + 1 }); } continue; } diff --git a/gitnexus/src/core/ingestion/languages/typescript/tsconfig.ts b/gitnexus/src/core/ingestion/languages/typescript/tsconfig.ts index a111f5752..0e9871c64 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/tsconfig.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/tsconfig.ts @@ -23,7 +23,7 @@ import fs from 'fs/promises'; import path from 'path'; -import { isHardcodedIgnoredDirectory } from '../../../../config/ignore-service.js'; +import { isHardcodedIgnoredDirectoryAtPath } from '../../../../config/ignore-service.js'; import { logger } from '../../../logger.js'; /** One `paths` entry, pattern and targets kept in declaration order. */ @@ -291,9 +291,9 @@ async function findTsconfigFiles(repoRoot: string): Promise { } for (const entry of entries) { if (entry.isDirectory()) { - if (isHardcodedIgnoredDirectory(entry.name)) continue; - if (depth < SCAN_MAX_DEPTH) - queue.push({ dir: path.join(dir, entry.name), depth: depth + 1 }); + const childDir = path.join(dir, entry.name); + if (isHardcodedIgnoredDirectoryAtPath(repoRoot, childDir)) continue; + if (depth < SCAN_MAX_DEPTH) queue.push({ dir: childDir, depth: depth + 1 }); continue; } if (!entry.isFile()) continue; diff --git a/gitnexus/test/integration/filesystem-walker.test.ts b/gitnexus/test/integration/filesystem-walker.test.ts index 031f446dd..4cf6ca5e4 100644 --- a/gitnexus/test/integration/filesystem-walker.test.ts +++ b/gitnexus/test/integration/filesystem-walker.test.ts @@ -187,6 +187,125 @@ describe('filesystem-walker', () => { }); }); + describe('ambiguous source-directory names (#3039)', () => { + let sourceDir: string; + + beforeAll(async () => { + sourceDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-source-names-')); + await fs.mkdir(path.join(sourceDir, 'apps', 'client', 'src', 'shared', 'env'), { + recursive: true, + }); + await fs.mkdir(path.join(sourceDir, 'packages', 'ai', 'src', 'generated'), { + recursive: true, + }); + await fs.mkdir(path.join(sourceDir, 'build-cache', 'generated'), { recursive: true }); + await fs.mkdir(path.join(sourceDir, 'env'), { recursive: true }); + await fs.mkdir(path.join(sourceDir, 'generated'), { recursive: true }); + await fs.mkdir(path.join(sourceDir, 'backend', 'env', 'Scripts'), { recursive: true }); + await fs.mkdir(path.join(sourceDir, 'backend', 'env', 'include'), { recursive: true }); + await fs.mkdir(path.join(sourceDir, 'backend', 'env', 'share'), { recursive: true }); + + await fs.writeFile( + path.join(sourceDir, 'apps', 'client', 'src', 'shared', 'env', 'getAppEnv.ts'), + 'export const getAppEnv = () => "test";\n', + ); + await fs.writeFile( + path.join(sourceDir, 'packages', 'ai', 'src', 'generated', 'bundle.ts'), + 'export const bundled = true;\n', + ); + await fs.writeFile( + path.join(sourceDir, 'apps', 'client', 'src', 'vite-env.d.ts'), + 'declare const APP_ENV: string;\n', + ); + await fs.writeFile( + path.join(sourceDir, 'apps', 'client', 'src', 'service.ts'), + 'export class UserService {}\n', + ); + await fs.writeFile( + path.join(sourceDir, 'apps', 'client', 'src', 'service.d.ts'), + 'export declare class UserService {}\n', + ); + await fs.writeFile( + path.join(sourceDir, 'apps', 'client', 'src', 'legacy.js'), + 'export class LegacyService {}\n', + ); + await fs.writeFile( + path.join(sourceDir, 'apps', 'client', 'src', 'legacy.d.ts'), + 'export declare class LegacyService {}\n', + ); + await fs.writeFile( + path.join(sourceDir, 'build-cache', 'generated', 'ignored.ts'), + 'export const ignored = true;\n', + ); + await fs.writeFile(path.join(sourceDir, '.gitignore'), 'build-cache/generated/\n'); + await fs.writeFile(path.join(sourceDir, 'env', 'pyvenv.cfg'), 'home = python\n'); + await fs.writeFile(path.join(sourceDir, 'env', 'settings.py'), 'VALUE = 1\n'); + await fs.writeFile(path.join(sourceDir, 'backend', 'env', 'pyvenv.cfg'), 'home = python\n'); + await fs.writeFile( + path.join(sourceDir, 'backend', 'env', 'Scripts', 'activate_this.py'), + 'VALUE = 1\n', + ); + await fs.writeFile( + path.join(sourceDir, 'backend', 'env', 'include', 'header.py'), + 'VALUE = 1\n', + ); + await fs.writeFile( + path.join(sourceDir, 'backend', 'env', 'share', 'manual.py'), + 'VALUE = 1\n', + ); + await fs.writeFile( + path.join(sourceDir, 'generated', 'client.ts'), + 'export const generatedClient = true;\n', + ); + }); + + afterAll(async () => { + await fs.rm(sourceDir, { recursive: true, force: true }); + }); + + it('discovers nested env/generated and .d.ts source while pruning root artifacts', async () => { + const files = await walkRepositoryPaths(sourceDir); + const paths = files.map((file) => file.path); + + expect(paths).toContain('apps/client/src/shared/env/getAppEnv.ts'); + expect(paths).toContain('packages/ai/src/generated/bundle.ts'); + expect(paths).toContain('apps/client/src/vite-env.d.ts'); + expect(paths).toContain('apps/client/src/service.ts'); + expect(paths).not.toContain('apps/client/src/service.d.ts'); + expect(paths).toContain('apps/client/src/legacy.js'); + expect(paths).toContain('apps/client/src/legacy.d.ts'); + expect(paths).not.toContain('build-cache/generated/ignored.ts'); + expect(paths).not.toContain('env/settings.py'); + expect(paths).not.toContain('backend/env/Scripts/activate_this.py'); + expect(paths).not.toContain('backend/env/include/header.py'); + expect(paths).not.toContain('backend/env/share/manual.py'); + expect(paths).not.toContain('generated/client.ts'); + }); + + it('preserves case variants that were not hardcoded ignore names', async () => { + const caseDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-source-case-')); + try { + await fs.mkdir(path.join(caseDir, 'Generated'), { recursive: true }); + await fs.mkdir(path.join(caseDir, 'Env'), { recursive: true }); + await fs.writeFile( + path.join(caseDir, 'Generated', 'client.cs'), + 'public class GeneratedClient {}\n', + ); + await fs.writeFile( + path.join(caseDir, 'Env', 'settings.ts'), + 'export const environment = "test";\n', + ); + + const paths = (await walkRepositoryPaths(caseDir)).map((file) => file.path); + + expect(paths).toContain('Generated/client.cs'); + expect(paths).toContain('Env/settings.ts'); + } finally { + await fs.rm(caseDir, { recursive: true, force: true }); + } + }); + }); + describe('.gitnexusignore support', () => { let nexusignoreDir: string; @@ -394,6 +513,7 @@ describe('filesystem-walker', () => { describe('large file skip threshold (#991)', () => { let sizeDir: string; const BIG_FILE = 'src/big.ts'; + const BIG_DECLARATION = 'src/big.d.ts'; const BIG_FILE_BYTES = 600 * 1024; const ORIGINAL_ENV = process.env.GITNEXUS_MAX_FILE_SIZE; let cap: ReturnType; @@ -403,6 +523,10 @@ describe('filesystem-walker', () => { await fs.mkdir(path.join(sizeDir, 'src'), { recursive: true }); await fs.writeFile(path.join(sizeDir, 'src', 'small.ts'), 'export const x = 1;'); await fs.writeFile(path.join(sizeDir, BIG_FILE), 'x'.repeat(BIG_FILE_BYTES)); + await fs.writeFile( + path.join(sizeDir, BIG_DECLARATION), + 'export declare const generatedTypes: string;\n', + ); }); afterAll(async () => { @@ -429,6 +553,7 @@ describe('filesystem-walker', () => { const paths = files.map((f) => f.path.replace(/\\/g, '/')); expect(paths).toContain('src/small.ts'); expect(paths).not.toContain(BIG_FILE); + expect(paths).toContain(BIG_DECLARATION); }); it('includes the 600KB file when GITNEXUS_MAX_FILE_SIZE=1024', async () => { @@ -436,6 +561,7 @@ describe('filesystem-walker', () => { const files = await walkRepositoryPaths(sizeDir); const paths = files.map((f) => f.path.replace(/\\/g, '/')); expect(paths).toContain(BIG_FILE); + expect(paths).not.toContain(BIG_DECLARATION); }); it('falls back to default and warns once on invalid GITNEXUS_MAX_FILE_SIZE', async () => { diff --git a/gitnexus/test/integration/ignore-and-skip-e2e.test.ts b/gitnexus/test/integration/ignore-and-skip-e2e.test.ts index ea65ec88c..465f90a9b 100644 --- a/gitnexus/test/integration/ignore-and-skip-e2e.test.ts +++ b/gitnexus/test/integration/ignore-and-skip-e2e.test.ts @@ -40,6 +40,36 @@ describe('ignore + language-skip E2E', () => { path.join(tmpDir, 'src', 'greet.ts'), "export function greet(): string {\n return 'hello';\n}\n", ); + await fs.writeFile( + path.join(tmpDir, 'src', 'service.ts'), + 'export class UserService { load(): string { return "loaded"; } }\n', + ); + await fs.writeFile( + path.join(tmpDir, 'src', 'service.d.ts'), + 'export declare class UserService { load(): string; }\n', + ); + await fs.writeFile( + path.join(tmpDir, 'src', 'vite-env.d.ts'), + 'declare const APP_ENV: string;\n', + ); + await fs.writeFile(path.join(tmpDir, 'src', 'esm-service.mts'), 'export class EsmService {}\n'); + await fs.writeFile( + path.join(tmpDir, 'src', 'esm-service.d.mts'), + 'export declare class EsmService {}\n', + ); + await fs.writeFile(path.join(tmpDir, 'src', 'cjs-service.cts'), 'export class CjsService {}\n'); + await fs.writeFile( + path.join(tmpDir, 'src', 'cjs-service.d.cts'), + 'export declare class CjsService {}\n', + ); + await fs.writeFile( + path.join(tmpDir, 'src', 'ambient.d.mts'), + 'export declare class AmbientEsmService {}\n', + ); + await fs.writeFile( + path.join(tmpDir, 'src', 'ambient.d.cts'), + 'export declare class AmbientCjsService {}\n', + ); // Swift file — triggers language skip when grammar unavailable await fs.writeFile( @@ -70,6 +100,15 @@ describe('ignore + language-skip E2E', () => { expect(paths).toContain('src/index.ts'); expect(paths).toContain('src/greet.ts'); + expect(paths).toContain('src/service.ts'); + expect(paths).not.toContain('src/service.d.ts'); + expect(paths).toContain('src/vite-env.d.ts'); + expect(paths).toContain('src/esm-service.mts'); + expect(paths).not.toContain('src/esm-service.d.mts'); + expect(paths).toContain('src/cjs-service.cts'); + expect(paths).not.toContain('src/cjs-service.d.cts'); + expect(paths).toContain('src/ambient.d.mts'); + expect(paths).toContain('src/ambient.d.cts'); }); it('includes .swift files (discovery does not filter by language)', async () => { @@ -130,6 +169,30 @@ describe('ignore + language-skip E2E', () => { expect(functionNames).toContain('main'); expect(functionNames).toContain('greet'); + const userServiceNodes = nodes.filter( + (node) => node.label === 'Class' && node.properties.name === 'UserService', + ); + expect(userServiceNodes).toHaveLength(1); + expect(userServiceNodes[0].properties.filePath).toBe('src/service.ts'); + expect(nodes.some((node) => node.properties.filePath === 'src/service.d.ts')).toBe(false); + + expect( + nodes.filter((node) => node.label === 'Class' && node.properties.name === 'EsmService'), + ).toHaveLength(1); + expect( + nodes.filter((node) => node.label === 'Class' && node.properties.name === 'CjsService'), + ).toHaveLength(1); + expect( + nodes.filter( + (node) => node.label === 'Class' && node.properties.name === 'AmbientEsmService', + ), + ).toHaveLength(1); + expect( + nodes.filter( + (node) => node.label === 'Class' && node.properties.name === 'AmbientCjsService', + ), + ).toHaveLength(1); + // Function nodes should reference the correct source files const fnFilePaths = functionNodes.map((n) => (n.properties.filePath as string).replace(/\\/g, '/'), diff --git a/gitnexus/test/unit/group/python-workspace-extractor.test.ts b/gitnexus/test/unit/group/python-workspace-extractor.test.ts index 96a8b5e3f..87b9ffcb3 100644 --- a/gitnexus/test/unit/group/python-workspace-extractor.test.ts +++ b/gitnexus/test/unit/group/python-workspace-extractor.test.ts @@ -52,6 +52,31 @@ describe('PythonWorkspaceExtractor', () => { }); }); + it('does not emit contracts from a nested Python virtual environment', async () => { + await writeFile( + 'provider/pyproject.toml', + '[project]\nname = "provider"\nversion = "0.1.0"\ndependencies = []\n', + ); + await writeFile('provider/provider/__init__.py', 'class SecretClient: pass\n'); + + await writeFile( + 'consumer/pyproject.toml', + '[project]\nname = "consumer"\nversion = "0.1.0"\ndependencies = ["provider"]\n', + ); + await writeFile('consumer/backend/env/pyvenv.cfg', 'home = python\n'); + await writeFile('consumer/backend/env/leaked.py', 'from provider import SecretClient\n'); + + const repos = { provider: 'provider', consumer: 'consumer' }; + const repoPaths = new Map([ + ['provider', path.join(tmpDir, 'provider')], + ['consumer', path.join(tmpDir, 'consumer')], + ]); + + const result = await extractPythonWorkspaceLinks(repos, repoPaths); + + expect(result.links).toHaveLength(0); + }); + it('discovers imports via setup.py', async () => { await writeFile( 'core/setup.py', diff --git a/gitnexus/test/unit/ignore-service.test.ts b/gitnexus/test/unit/ignore-service.test.ts index 42e1986df..4fb5536ef 100644 --- a/gitnexus/test/unit/ignore-service.test.ts +++ b/gitnexus/test/unit/ignore-service.test.ts @@ -204,8 +204,8 @@ describe('shouldIgnorePath', () => { expect(shouldIgnorePath('keep-ui/public/monaco-workers/125.js')).toBe(true); }); - it('ignores TypeScript declaration files', () => { - expect(shouldIgnorePath('types/index.d.ts')).toBe(true); + it('keeps tracked TypeScript declaration files discoverable', () => { + expect(shouldIgnorePath('types/index.d.ts')).toBe(false); }); it('ignores Laravel compiled Blade view cache files', () => { @@ -226,6 +226,12 @@ describe('shouldIgnorePath', () => { it.each([ 'src/index.ts', 'src/components/Button.tsx', + 'apps/client/src/shared/env/getAppEnv.ts', + 'packages/ai/src/generated/bundle.ts', + 'apps/client/src/vite-env.d.ts', + 'Generated/client.cs', + 'Env/settings.ts', + 'ENV/config.ts', 'lib/utils.py', 'cmd/server/main.go', 'src/main.rs', @@ -238,6 +244,13 @@ describe('shouldIgnorePath', () => { ])('does not ignore source file %s', (filePath) => { expect(shouldIgnorePath(filePath)).toBe(false); }); + + it.each(['env/pyvenv.cfg', 'env/settings.py', 'generated/client.ts'])( + 'prunes ambiguous artifact directories only at the repository root: %s', + (filePath) => { + expect(shouldIgnorePath(filePath)).toBe(true); + }, + ); }); }); @@ -248,6 +261,7 @@ describe('isHardcodedIgnoredDirectory', () => { expect(isHardcodedIgnoredDirectory('dist')).toBe(true); expect(isHardcodedIgnoredDirectory('monaco-workers')).toBe(true); expect(isHardcodedIgnoredDirectory('__pycache__')).toBe(true); + expect(isHardcodedIgnoredDirectory('dist-packages')).toBe(true); }); it('returns false for source directories', () => { @@ -255,6 +269,8 @@ describe('isHardcodedIgnoredDirectory', () => { expect(isHardcodedIgnoredDirectory('lib')).toBe(false); expect(isHardcodedIgnoredDirectory('app')).toBe(false); expect(isHardcodedIgnoredDirectory('local')).toBe(false); + expect(isHardcodedIgnoredDirectory('env')).toBe(false); + expect(isHardcodedIgnoredDirectory('generated')).toBe(false); }); }); @@ -308,6 +324,33 @@ describe('.gitnexusignore negation overrides hardcoded DEFAULT_IGNORE_LIST (#771 expect(filter.childrenIgnored(mkPath('__tests__'))).toBe(true); }); + it('prunes exact-case root artifacts while allowing nested source directories', async () => { + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.childrenIgnored(mkPath('generated'))).toBe(true); + expect(filter.childrenIgnored(mkPath('env'))).toBe(true); + expect(filter.childrenIgnored(mkPath('packages/api/generated'))).toBe(false); + expect(filter.childrenIgnored(mkPath('Generated'))).toBe(false); + expect(filter.childrenIgnored(mkPath('Env'))).toBe(false); + }); + + it('prunes a nested env directory only when pyvenv.cfg identifies a virtual environment', async () => { + await fs.mkdir(path.join(tmpDir, 'backend', 'env'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, 'backend', 'env', 'pyvenv.cfg'), 'home = python\n'); + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.childrenIgnored(mkPath('backend/env'))).toBe(true); + expect(filter.childrenIgnored(mkPath('services/api/env'))).toBe(false); + }); + + it('`!env/` negation unlocks the root artifact directory', async () => { + await fs.writeFile(path.join(tmpDir, '.gitnexusignore'), '!env/\n'); + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.childrenIgnored(mkPath('env'))).toBe(false); + expect(filter.ignored(mkPath('env/settings.py'))).toBe(false); + }); + it('`!__tests__/` negation unlocks the directory and its descendants', async () => { await fs.writeFile(path.join(tmpDir, '.gitnexusignore'), '!__tests__/\n'); const filter = await createIgnoreFilter(tmpDir); diff --git a/gitnexus/test/unit/node-workspace-scope.test.ts b/gitnexus/test/unit/node-workspace-scope.test.ts index b59243b18..a37515c51 100644 --- a/gitnexus/test/unit/node-workspace-scope.test.ts +++ b/gitnexus/test/unit/node-workspace-scope.test.ts @@ -90,6 +90,22 @@ describe('workspace boundary', () => { expect(packages?.byName.has('@repo/web')).toBe(true); }); + it('prunes root artifact workspaces while keeping nested source directories', async () => { + const root = repo({ + 'package.json': JSON.stringify({ + name: 'root', + workspaces: ['generated/*', 'packages/*/generated'], + }), + 'generated/apiclient/package.json': pkg('@repo/root-artifact'), + 'packages/api/generated/package.json': pkg('@repo/generated-source'), + }); + + const packages = await loadNodeWorkspacePackages(root); + + expect(packages?.byName.has('@repo/root-artifact')).toBe(false); + expect(packages?.byName.has('@repo/generated-source')).toBe(true); + }); + it('honours a `!` exclusion', async () => { const root = repo({ 'pnpm-workspace.yaml': 'packages:\n - "packages/*"\n - "!packages/internal"\n', diff --git a/gitnexus/test/unit/tsconfig-index.test.ts b/gitnexus/test/unit/tsconfig-index.test.ts index 55362c3e8..f711ae9cc 100644 --- a/gitnexus/test/unit/tsconfig-index.test.ts +++ b/gitnexus/test/unit/tsconfig-index.test.ts @@ -153,6 +153,21 @@ describe('extends chains', () => { }); describe('which config governs a file', () => { + it('prunes root artifact configs while keeping nested source directories', async () => { + const root = repo({ + 'generated/tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: 'root-artifact' } }), + 'packages/api/generated/tsconfig.json': JSON.stringify({ + compilerOptions: { baseUrl: 'src' }, + }), + }); + const index = await loadTsconfigIndex(root); + + expect(tsconfigFor(index, 'generated/main.ts')).toBeNull(); + expect(tsconfigFor(index, 'packages/api/generated/main.ts')?.baseUrl).toBe( + 'packages/api/generated/src', + ); + }); + it('lets a child config with no baseUrl shadow the root, rather than inheriting it', async () => { // The child project declares no `baseUrl`, which in TypeScript means its // non-relative specifiers are PACKAGE lookups. Dropping the empty child let From 09322d2d89382ed1a7d86faceeea3df622f9a284 Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Wed, 26 Aug 2026 13:57:56 +0100 Subject: [PATCH 04/17] fix(storage): load VECTOR only when needed (#3045) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(storage): load VECTOR only when needed * test(storage): verify VECTOR reopen lifecycle --------- Co-authored-by: Gergő Magyar --- gitnexus/src/core/lbug/pool-adapter.ts | 177 +++++++++++++++-- gitnexus/src/mcp/local/local-backend.ts | 82 ++++---- gitnexus/test/integration/lbug-pool.test.ts | 8 +- gitnexus/test/unit/calltool-dispatch.test.ts | 6 +- gitnexus/test/unit/lbug-pool-fts-load.test.ts | 183 ++++++++++++++++-- .../local-backend-embedding-dims-warn.test.ts | 4 + .../unit/local-backend-lazy-vector.test.ts | 119 ++++++++++++ 7 files changed, 509 insertions(+), 70 deletions(-) create mode 100644 gitnexus/test/unit/local-backend-lazy-vector.test.ts diff --git a/gitnexus/src/core/lbug/pool-adapter.ts b/gitnexus/src/core/lbug/pool-adapter.ts index a9a06aeb3..7f38e48ba 100644 --- a/gitnexus/src/core/lbug/pool-adapter.ts +++ b/gitnexus/src/core/lbug/pool-adapter.ts @@ -135,6 +135,10 @@ interface SharedDB { * scan (#2623 follow-up). Optional with `?? false` semantics so the * construction sites stay minimal. */ vectorLoaded?: boolean; + /** In-flight/completed lazy VECTOR probe for this Database lifecycle. + * Retaining a false result prevents every semantic request from retrying + * the same unavailable extension; teardown clears it before a reopen. */ + vectorLoadPromise?: Promise; /** File identity at open — used to detect reuse of a shared read-only handle * whose on-disk index was rebuilt/swapped since it opened (only reachable * when a second pool consumer shares this dbPath; #2614 F2). */ @@ -368,6 +372,7 @@ function closeOne(repoId: string): void { shared.refCount = 0; shared.ftsLoaded = false; shared.vectorLoaded = false; + shared.vectorLoadPromise = undefined; } else { shared.db.close().catch(() => {}); dbCache.delete(entry.dbPath); @@ -823,14 +828,6 @@ async function doInitLbug(repoId: string, dbPath: string): Promise { if (!shared.ftsLoaded) { shared.ftsLoaded = await loadFTSExtension(available[0], { policy: 'load-only' }); } - // VECTOR too — extension load scope is per-Database, so this one load - // makes QUERY_VECTOR_INDEX legal on every pooled connection. Same - // load-only contract as FTS above; on failure the semantic-query lane - // falls back to the exact scan with its own diagnostic (#2623 follow-up). - if (!shared.vectorLoaded) { - shared.vectorLoaded = await loadVectorExtension(available[0], { policy: 'load-only' }); - } - // Register pool entry only after all connections are pre-warmed and FTS is // loaded. Concurrent executeQuery calls see either "not initialized" // (and throw cleanly) or a fully ready pool — never a half-built one. @@ -900,12 +897,6 @@ export async function initLbugWithDb( if (!shared.ftsLoaded) { shared.ftsLoaded = await loadFTSExtension(available[0], { policy: 'load-only' }); } - // VECTOR too — same per-Database scope and load-only contract as the - // doInitLbug site above (#2623 follow-up). - if (!shared.vectorLoaded) { - shared.vectorLoaded = await loadVectorExtension(available[0], { policy: 'load-only' }); - } - pool.set(repoId, { db: existingDb, available, @@ -921,6 +912,143 @@ export async function initLbugWithDb( traceRss('init', repoId); } +/** + * Lazily load VECTOR for a semantic query. + * + * Exact graph reads never call this function, so opening their read pool does + * not probe or warn about an optional extension they do not use. The promise + * lives on SharedDB because extension scope is per Database, and also joins + * concurrent first semantic requests onto one LOAD attempt. + */ +export async function ensureVectorExtension(repoId: string): Promise { + const entry = pool.get(repoId); + if (!entry) { + throw new Error(`LadybugDB not initialized for repo "${repoId}". Call initLbug first.`); + } + + const shared = dbCache.get(entry.dbPath); + if (!shared) { + throw new Error(`LadybugDB shared handle is unavailable for repo "${repoId}".`); + } + if (shared.vectorLoaded) return true; + if (shared.vectorLoadPromise) return shared.vectorLoadPromise; + + const loadAttempt = (async () => { + const conn = await checkout(entry); + try { + const loaded = await loadVectorExtension(conn, { policy: 'load-only' }); + shared.vectorLoaded = loaded; + return loaded; + } finally { + checkin(entry, conn); + } + })(); + const cachedAttempt = loadAttempt.catch((err) => { + // A transient checkout/load failure must not poison this Database for the + // rest of its lifetime. Keep resolved false cached, but let a later + // semantic request retry a rejected attempt. + if (shared.vectorLoadPromise === cachedAttempt) { + shared.vectorLoadPromise = undefined; + } + throw err; + }); + shared.vectorLoadPromise = cachedAttempt; + + return shared.vectorLoadPromise; +} + +/** + * Detect an actual VECTOR procedure call without treating source text stored in + * Cypher literals or comments as executable syntax. + */ +function callsVectorIndex(cypher: string): boolean { + if (!/QUERY_VECTOR_INDEX/i.test(cypher)) return false; + + let code = ''; + let state: 'code' | 'single' | 'double' | 'backtick' | 'line-comment' | 'block-comment' = 'code'; + let backtickIdentifier = ''; + + for (let i = 0; i < cypher.length; i++) { + const ch = cypher[i]; + const next = cypher[i + 1]; + + if (state === 'code') { + if (ch === "'" || ch === '"' || ch === '`') { + state = ch === "'" ? 'single' : ch === '"' ? 'double' : 'backtick'; + if (state === 'backtick') backtickIdentifier = ''; + code += ' '; + } else if (ch === '/' && next === '/') { + state = 'line-comment'; + code += ' '; + i++; + } else if (ch === '/' && next === '*') { + state = 'block-comment'; + code += ' '; + i++; + } else { + code += ch; + } + continue; + } + + if (state === 'line-comment') { + if (ch === '\n' || ch === '\r') { + state = 'code'; + code += ch; + } else { + code += ' '; + } + continue; + } + + if (state === 'block-comment') { + if (ch === '*' && next === '/') { + state = 'code'; + code += ' '; + i++; + } else { + code += ch === '\n' || ch === '\r' ? ch : ' '; + } + continue; + } + + if (state === 'backtick') { + if (ch === '`' && next === '`') { + backtickIdentifier += '`'; + code += ' '; + i++; + } else if (ch === '`') { + state = 'code'; + code += + backtickIdentifier.toUpperCase() === 'QUERY_VECTOR_INDEX' ? 'QUERY_VECTOR_INDEX' : ' '; + } else if (ch === '\\' && next !== undefined) { + backtickIdentifier += next; + code += ' '; + i++; + } else { + backtickIdentifier += ch; + code += ch === '\n' || ch === '\r' ? ch : ' '; + } + continue; + } + + if (ch === '\\') { + code += ' '; + if (next !== undefined) { + code += next === '\n' || next === '\r' ? next : ' '; + i++; + } + continue; + } + + const closesLiteral = (state === 'single' && ch === "'") || (state === 'double' && ch === '"'); + if (closesLiteral) state = 'code'; + code += ch === '\n' || ch === '\r' ? ch : ' '; + } + + return /\bCALL\s+QUERY_VECTOR_INDEX\s*\(/i.test(code); +} + /** * Checkout a connection from the pool. * Returns an available connection, or creates a new one if under the cap. @@ -1028,14 +1156,31 @@ export const executeParameterized = async ( poolSidecarLogger.warn(message), ); - const entry = pool.get(repoId); + let entry = pool.get(repoId); if (!entry) { throw new Error(`LadybugDB not initialized for repo "${repoId}". Call initLbug first.`); } - entry.lastUsed = Date.now(); + // Exact reads must not pay for VECTOR, but an explicit raw vector procedure + // call is a semantic read. Preflight before taking the query connection: + // ensureVectorExtension performs its own checkout, so holding one here could + // make a saturated pool wait for a connection that every caller is holding. + // A load rejection must not replace the query's own diagnostic. + if (callsVectorIndex(cypher)) { + await ensureVectorExtension(repoId).catch(() => false); + + // The preflight suspends, so close/re-init may replace the pool entry. + // Re-read it before checkout to avoid querying through a stale handle. + entry = pool.get(repoId); + if (!entry) { + throw new Error( + `LadybugDB connection pool closed for repo "${repoId}" (re-init/teardown); retry the query.`, + ); + } + } const conn = await checkout(entry); + entry.lastUsed = Date.now(); silenceStdout(); activeQueryCount++; let queryResult: lbug.QueryResult | lbug.QueryResult[] | undefined; diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 854b7f267..9809a76e4 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -13,6 +13,7 @@ import { initLbug, executeQuery, executeParameterized, + ensureVectorExtension, closeLbug, isLbugReady, statDbIdentity, @@ -1253,12 +1254,12 @@ export class LocalBackend { private warnedSiblingDrift: Set = new Set(); /** - * One-shot stderr warning for the VECTOR-extension fallback. Without this - * guard the diagnostic would fire on every `semanticSearch()` call on - * platforms where the extension is unsupported (e.g. Windows), making MCP - * stderr noisy per DoD §2.8. + * One-shot stderr guards for distinct VECTOR load and index-query failures. + * Keeping them separate preserves both diagnostics across semanticSearch calls + * without repeating either on hot paths. */ - private warnedVectorUnsupported = false; + private warnedVectorLoadFailed = false; + private warnedVectorQueryFailed = false; /** * One-shot warning when a pruned or Node-unloadable optional embedding stack @@ -3219,21 +3220,31 @@ export class LocalBackend { this.lastQueryEmbeddingDims.set(repo.lbugPath, dims); const queryVecStr = `[${queryVec.join(',')}]`; const maxDistance = getVectorMaxDistance(DEFAULT_MCP_VECTOR_MAX_DISTANCE); + let vectorReady = false; + try { + vectorReady = await ensureVectorExtension(repo.lbugPath); + } catch (err) { + if (!this.warnedVectorLoadFailed) { + this.warnedVectorLoadFailed = true; + logger.warn( + { err }, + 'GitNexus [query:vector]: vector extension load failed; using exact scan fallback', + ); + } + } let bestChunks = new Map< string, { distance: number; chunkIndex: number; startLine: number; endLine: number } >(); - // Always TRY the vector lane — no platform gate. LadybugDB ships the - // VECTOR extension for every supported platform, Windows included - // (#2623 follow-up; the old `platform !== 'win32'` gate was stale), so - // whether the index is queryable is a per-machine runtime fact. The - // catch below is the fallback: any failure (extension unloadable, index - // absent, older DB) degrades to the exact scan with a once-per-backend - // diagnostic instead of being silently swallowed. - try { - bestChunks = await collectBestChunks(limit, async (fetchLimit) => { - const vectorQuery = ` + // Try the vector lane only after its lazy load succeeds. An unavailable + // extension is already reported by ExtensionManager; an index/query + // failure below gets its own once-per-backend diagnostic before the exact + // scan fallback. + if (vectorReady) { + try { + bestChunks = await collectBestChunks(limit, async (fetchLimit) => { + const vectorQuery = ` CALL QUERY_VECTOR_INDEX('${EMBEDDING_TABLE_NAME}', '${EMBEDDING_INDEX_NAME}', CAST(${queryVecStr} AS FLOAT[${dims}]), ${fetchLimit}) YIELD node AS emb, distance @@ -3244,26 +3255,27 @@ export class LocalBackend { ORDER BY distance `; - const embResults = await executeQuery(repo.lbugPath, vectorQuery); - return embResults.map((row) => ({ - nodeId: row.nodeId ?? row[0], - chunkIndex: row.chunkIndex ?? row[1] ?? 0, - startLine: row.startLine ?? row[2] ?? 0, - endLine: row.endLine ?? row[3] ?? 0, - distance: row.distance ?? row[4], - })); - }); - } catch (err) { - bestChunks = new Map(); - if (!this.warnedVectorUnsupported) { - // Rare diagnostic: surface why semantic search fell back to the - // exact scan. Emitted once per `LocalBackend` instance lifetime to - // avoid noisy stderr on hot semantic-search paths (DoD §2.8). - this.warnedVectorUnsupported = true; - logger.warn( - { err }, - 'GitNexus [query:vector]: vector index query failed; using exact scan fallback', - ); + const embResults = await executeQuery(repo.lbugPath, vectorQuery); + return embResults.map((row) => ({ + nodeId: row.nodeId ?? row[0], + chunkIndex: row.chunkIndex ?? row[1] ?? 0, + startLine: row.startLine ?? row[2] ?? 0, + endLine: row.endLine ?? row[3] ?? 0, + distance: row.distance ?? row[4], + })); + }); + } catch (err) { + bestChunks = new Map(); + if (!this.warnedVectorQueryFailed) { + // Rare diagnostic: surface why semantic search fell back to the + // exact scan. Emitted once per `LocalBackend` instance lifetime to + // avoid noisy stderr on hot semantic-search paths (DoD §2.8). + this.warnedVectorQueryFailed = true; + logger.warn( + { err }, + 'GitNexus [query:vector]: vector index query failed; using exact scan fallback', + ); + } } } diff --git a/gitnexus/test/integration/lbug-pool.test.ts b/gitnexus/test/integration/lbug-pool.test.ts index 083974674..063ab1dd2 100644 --- a/gitnexus/test/integration/lbug-pool.test.ts +++ b/gitnexus/test/integration/lbug-pool.test.ts @@ -341,7 +341,7 @@ withTestLbugDB( } }); - it('QUERY_VECTOR_INDEX works through the pool once the pre-warm loads VECTOR', async (ctx) => { + it('QUERY_VECTOR_INDEX works through the pool after its lazy query preflight', async (ctx) => { const core = await import('../../src/core/lbug/lbug-adapter.js'); const { batchInsertEmbeddings } = await import('../../src/core/embeddings/embedding-pipeline.js'); @@ -376,12 +376,12 @@ withTestLbugDB( // loads are per-Database, so a shared/injected Database would inherit // the VECTOR load from createVectorIndex above and pass even without // the pre-warm fix. A fresh Database has nothing loaded — only the - // pool's own pre-warm can make the vector lane legal. + // pool's own lazy query preflight can make the vector lane legal. await core.closeLbug(); // The regression: through the POOL, the vector lane must work without - // any caller loading the extension. Pre-fix this rejects with - // "Catalog exception: function QUERY_VECTOR_INDEX is not defined". + // any caller loading the extension. The query-specific preflight loads + // VECTOR here while exact reads remain untouched. await initLbug('vec-repo', handle.dbPath); const vec = `CAST([${embedding.join(',')}] AS FLOAT[${EMBEDDING_DIMS}])`; const rows = (await executeQuery( diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index aaf5a344d..e3f1892ce 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -23,6 +23,7 @@ const { lbugMocks } = vi.hoisted(() => ({ initLbug: vi.fn().mockResolvedValue(undefined), executeQuery: vi.fn().mockResolvedValue([]), executeParameterized: vi.fn().mockResolvedValue([]), + ensureVectorExtension: vi.fn().mockResolvedValue(true), closeLbug: vi.fn().mockResolvedValue(undefined), isLbugReady: vi.fn().mockReturnValue(true), }, @@ -684,9 +685,8 @@ describe('LocalBackend.callTool', () => { }); it('falls back to the exact scan with a once-per-backend warning when the vector index query fails', async () => { - // The platform gate is gone (#2623 follow-up): the vector lane is always - // ATTEMPTED, and a runtime failure (extension unloadable, index absent) is - // what routes semantic search onto the exact scan. + // Once the lazy extension preflight succeeds, a runtime index-query failure + // routes semantic search onto the exact scan. const cap = _captureLogger(); (executeQuery as any).mockImplementation(async (_repoId: string, cypher: string) => { if (cypher.includes('COUNT(*) AS cnt')) return [{ cnt: 1 }]; diff --git a/gitnexus/test/unit/lbug-pool-fts-load.test.ts b/gitnexus/test/unit/lbug-pool-fts-load.test.ts index 7f5b6ff87..920c8b994 100644 --- a/gitnexus/test/unit/lbug-pool-fts-load.test.ts +++ b/gitnexus/test/unit/lbug-pool-fts-load.test.ts @@ -1,14 +1,30 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -const { loadFTSExtensionMock, loadVectorExtensionMock } = vi.hoisted(() => ({ - loadFTSExtensionMock: vi.fn(), - loadVectorExtensionMock: vi.fn(), -})); +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +const { createLbugDatabaseMock, loadFTSExtensionMock, loadVectorExtensionMock } = vi.hoisted( + () => ({ + createLbugDatabaseMock: vi.fn(), + loadFTSExtensionMock: vi.fn(), + loadVectorExtensionMock: vi.fn(), + }), +); vi.mock('@ladybugdb/core', () => ({ default: { Database: vi.fn(), Connection: vi.fn(function (this: any) { + this.query = vi.fn(async () => ({ getAll: async () => [], close: vi.fn() })); + this.prepare = vi.fn(async () => ({ + isSuccess: () => true, + getErrorMessage: async () => '', + })); + this.execute = vi.fn(async () => ({ + getAll: async () => [], + close: vi.fn().mockResolvedValue(undefined), + })); this.close = vi.fn().mockResolvedValue(undefined); }), }, @@ -21,17 +37,22 @@ vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({ })); vi.mock('../../src/core/lbug/lbug-config.js', () => ({ - createLbugDatabase: vi.fn(), + createLbugDatabase: createLbugDatabaseMock, toNativeSafePath: vi.fn((p: string) => p), isWalCorruptionError: vi.fn(() => false), WAL_RECOVERY_SUGGESTION: '', })); -const { closeLbug, initLbugWithDb } = await import('../../src/core/lbug/pool-adapter.js'); +const { closeLbug, ensureVectorExtension, executeParameterized, initLbug, initLbugWithDb } = + await import('../../src/core/lbug/pool-adapter.js'); describe('read-pool FTS loading', () => { + const tempDirs: string[] = []; + afterEach(async () => { await closeLbug().catch(() => {}); + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); + createLbugDatabaseMock.mockReset(); loadFTSExtensionMock.mockReset(); loadVectorExtensionMock.mockReset(); loadVectorExtensionMock.mockResolvedValue(false); @@ -39,7 +60,6 @@ describe('read-pool FTS loading', () => { it('loads FTS with load-only policy and caches a successful load', async () => { loadFTSExtensionMock.mockResolvedValue(true); - loadVectorExtensionMock.mockResolvedValue(true); const db = {} as any; await initLbugWithDb('repo-a', db, '/tmp/shared-fts-db'); @@ -51,7 +71,6 @@ describe('read-pool FTS loading', () => { it('does not fake a successful load when FTS is unavailable', async () => { loadFTSExtensionMock.mockResolvedValue(false); - loadVectorExtensionMock.mockResolvedValue(false); const db = {} as any; await initLbugWithDb('repo-a', db, '/tmp/shared-fts-db'); @@ -66,7 +85,7 @@ describe('read-pool FTS loading', () => { }); }); - it('loads VECTOR with load-only policy and caches a successful load (#2623 follow-up)', async () => { + it('does not probe VECTOR while initializing exact-read pools (#3021)', async () => { loadFTSExtensionMock.mockResolvedValue(true); loadVectorExtensionMock.mockResolvedValue(true); const db = {} as any; @@ -74,20 +93,160 @@ describe('read-pool FTS loading', () => { await initLbugWithDb('repo-a', db, '/tmp/shared-vec-db'); await initLbugWithDb('repo-b', db, '/tmp/shared-vec-db'); + expect(loadVectorExtensionMock).not.toHaveBeenCalled(); + }); + + it('loads VECTOR lazily once for concurrent semantic reads on a shared Database', async () => { + loadFTSExtensionMock.mockResolvedValue(true); + loadVectorExtensionMock.mockResolvedValue(true); + const db = {} as any; + + await initLbugWithDb('repo-a', db, '/tmp/shared-vec-db'); + await initLbugWithDb('repo-b', db, '/tmp/shared-vec-db'); + + await expect( + Promise.all([ensureVectorExtension('repo-a'), ensureVectorExtension('repo-b')]), + ).resolves.toEqual([true, true]); + expect(loadVectorExtensionMock).toHaveBeenCalledTimes(1); expect(loadVectorExtensionMock).toHaveBeenCalledWith(expect.anything(), { policy: 'load-only', }); }); - it('retries the VECTOR load on the next open when it was unavailable', async () => { + it('caches an unavailable VECTOR result until a non-external Database is reopened', async () => { loadFTSExtensionMock.mockResolvedValue(true); loadVectorExtensionMock.mockResolvedValue(false); + const dir = await mkdtemp(path.join(tmpdir(), 'gitnexus-vector-reopen-')); + tempDirs.push(dir); + const dbPath = path.join(dir, 'index.lbug'); + await writeFile(dbPath, 'fixture'); + const firstDb = { init: vi.fn(), close: vi.fn().mockResolvedValue(undefined) }; + const secondDb = { init: vi.fn(), close: vi.fn().mockResolvedValue(undefined) }; + createLbugDatabaseMock.mockReturnValueOnce(firstDb).mockReturnValueOnce(secondDb); + + await initLbug('repo-a', dbPath); + await expect(ensureVectorExtension('repo-a')).resolves.toBe(false); + await expect(ensureVectorExtension('repo-a')).resolves.toBe(false); + + expect(loadVectorExtensionMock).toHaveBeenCalledTimes(1); + + await closeLbug('repo-a'); + await initLbug('repo-b', dbPath); + await expect(ensureVectorExtension('repo-b')).resolves.toBe(false); + + expect(createLbugDatabaseMock).toHaveBeenCalledTimes(2); + expect(firstDb.close).toHaveBeenCalledTimes(1); + expect(loadVectorExtensionMock).toHaveBeenCalledTimes(2); + }); + + it('retries VECTOR after a rejected lazy load', async () => { + loadFTSExtensionMock.mockResolvedValue(true); + loadVectorExtensionMock.mockRejectedValueOnce(new Error('transient load failure')); + loadVectorExtensionMock.mockResolvedValueOnce(true); const db = {} as any; - await initLbugWithDb('repo-a', db, '/tmp/shared-vec-db'); - await initLbugWithDb('repo-b', db, '/tmp/shared-vec-db'); + await initLbugWithDb('repo-a', db, '/tmp/shared-vec-retry-db'); + await expect(ensureVectorExtension('repo-a')).rejects.toThrow('transient load failure'); + await expect(ensureVectorExtension('repo-a')).resolves.toBe(true); expect(loadVectorExtensionMock).toHaveBeenCalledTimes(2); }); + + it('preflights VECTOR only for executable QUERY_VECTOR_INDEX calls', async () => { + loadFTSExtensionMock.mockResolvedValue(true); + loadVectorExtensionMock.mockResolvedValue(false); + await initLbugWithDb('repo-a', {} as any, '/tmp/vector-call-detection-db'); + + const exactReads = [ + "RETURN 'CALL QUERY_VECTOR_INDEX(' AS sourceText", + 'RETURN "CALL QUERY_VECTOR_INDEX(" AS sourceText', + 'RETURN `CALL QUERY_VECTOR_INDEX(` AS propertyName', + 'RETURN `QUERY_VECTOR_INDEX` AS propertyName', + "RETURN 'CALL `QUERY_VECTOR_INDEX`(' AS sourceText", + '// CALL QUERY_VECTOR_INDEX(\nRETURN 1 AS value', + '// CALL `QUERY_VECTOR_INDEX`(\nRETURN 1 AS value', + '/* CALL QUERY_VECTOR_INDEX( */ RETURN 1 AS value', + '/* CALL `QUERY_VECTOR_INDEX`( */ RETURN 1 AS value', + ]; + for (const cypher of exactReads) { + await expect(executeParameterized('repo-a', cypher, {})).resolves.toEqual([]); + } + expect(loadVectorExtensionMock).not.toHaveBeenCalled(); + + await expect( + executeParameterized( + 'repo-a', + "call query_vector_index\n('CodeEmbedding', 'embedding_idx', [0.1], 1)", + {}, + ), + ).resolves.toEqual([]); + expect(loadVectorExtensionMock).toHaveBeenCalledTimes(1); + }); + + it('preflights VECTOR for a backtick-escaped procedure identifier', async () => { + loadFTSExtensionMock.mockResolvedValue(true); + loadVectorExtensionMock.mockResolvedValue(false); + await initLbugWithDb('repo-a', {} as any, '/tmp/vector-quoted-call-detection-db'); + + await expect( + executeParameterized( + 'repo-a', + "CALL /* legal comment */ `QUERY_VECTOR_INDEX`('CodeEmbedding', 'embedding_idx', [0.1], 1)", + {}, + ), + ).resolves.toEqual([]); + expect(loadVectorExtensionMock).toHaveBeenCalledTimes(1); + }); + + it('does not hold query connections while a saturated VECTOR preflight loads', async () => { + loadFTSExtensionMock.mockResolvedValue(true); + let releaseVectorLoad: ((loaded: boolean) => void) | undefined; + loadVectorExtensionMock.mockImplementation( + () => + new Promise((resolve) => { + releaseVectorLoad = resolve; + }), + ); + await initLbugWithDb('repo-a', {} as any, '/tmp/vector-saturation-db'); + + const calls = Array.from({ length: 8 }, () => + executeParameterized( + 'repo-a', + "CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'embedding_idx', [0.1], 1)", + {}, + ), + ); + + try { + await vi.waitFor(() => expect(loadVectorExtensionMock).toHaveBeenCalledTimes(1), { + timeout: 500, + }); + releaseVectorLoad?.(true); + await expect(Promise.all(calls)).resolves.toHaveLength(8); + } finally { + if (releaseVectorLoad) { + releaseVectorLoad(true); + } else { + // Allows the old hold-one/wait-for-one ordering to unwind promptly + // instead of leaving its pool waiter alive until the 30-second timeout. + await closeLbug('repo-a'); + } + await Promise.allSettled(calls); + } + }); + + it('lets a direct vector query report its own error when preflight rejects', async () => { + loadFTSExtensionMock.mockResolvedValue(true); + loadVectorExtensionMock.mockRejectedValueOnce(new Error('transient load failure')); + await initLbugWithDb('repo-a', {} as any, '/tmp/vector-preflight-rejection-db'); + + await expect( + executeParameterized( + 'repo-a', + "CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'embedding_idx', [0.1], 1)", + {}, + ), + ).resolves.toEqual([]); + }); }); diff --git a/gitnexus/test/unit/local-backend-embedding-dims-warn.test.ts b/gitnexus/test/unit/local-backend-embedding-dims-warn.test.ts index 948c7cab8..b9a80f616 100644 --- a/gitnexus/test/unit/local-backend-embedding-dims-warn.test.ts +++ b/gitnexus/test/unit/local-backend-embedding-dims-warn.test.ts @@ -15,6 +15,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; const executeQueryMock = vi.fn(); const executeParameterizedMock = vi.fn(); +const ensureVectorExtensionMock = vi.fn(); const loadMetaMock = vi.fn(); const embedQueryMock = vi.fn(); const getEmbeddingDimsMock = vi.fn(); @@ -24,6 +25,7 @@ vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => ({ initLbug: vi.fn(), executeQuery: (...args: unknown[]) => executeQueryMock(...args), executeParameterized: (...args: unknown[]) => executeParameterizedMock(...args), + ensureVectorExtension: (...args: unknown[]) => ensureVectorExtensionMock(...args), closeLbug: vi.fn(), isLbugReady: vi.fn().mockReturnValue(true), })); @@ -121,6 +123,7 @@ describe('LocalBackend.query — index/server embedding width drift (#2798)', () beforeEach(() => { vi.clearAllMocks(); executeParameterizedMock.mockResolvedValue([]); + ensureVectorExtensionMock.mockResolvedValue(true); loadMetaMock.mockResolvedValue(null); }); @@ -225,6 +228,7 @@ describe('LocalBackend.semanticSearch — recorded query-embedding width (#2798) beforeEach(() => { vi.clearAllMocks(); executeParameterizedMock.mockResolvedValue([]); + ensureVectorExtensionMock.mockResolvedValue(true); loadMetaMock.mockResolvedValue(null); }); diff --git a/gitnexus/test/unit/local-backend-lazy-vector.test.ts b/gitnexus/test/unit/local-backend-lazy-vector.test.ts new file mode 100644 index 000000000..063c5e486 --- /dev/null +++ b/gitnexus/test/unit/local-backend-lazy-vector.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { _captureLogger } from '../../src/core/logger.js'; + +const executeQueryMock = vi.fn(); +const ensureVectorExtensionMock = vi.fn(); +const embedQueryMock = vi.fn(); + +vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => ({ + ...(await importOriginal()), + executeQuery: (...args: unknown[]) => executeQueryMock(...args), + ensureVectorExtension: (...args: unknown[]) => ensureVectorExtensionMock(...args), +})); + +vi.mock('../../src/mcp/core/embedder.js', () => ({ + embedQuery: (...args: unknown[]) => embedQueryMock(...args), + getEmbeddingDims: () => 3, +})); + +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; + +interface SemanticSearchable { + semanticSearch(repo: { lbugPath: string }, query: string, limit: number): Promise; +} + +const runSemanticSearch = (backend: LocalBackend): Promise => + (backend as unknown as SemanticSearchable).semanticSearch({ lbugPath: '/tmp/index' }, 'q', 5); + +describe('LocalBackend semantic search lazy VECTOR loading (#3021)', () => { + beforeEach(() => { + executeQueryMock.mockReset(); + ensureVectorExtensionMock.mockReset(); + embedQueryMock.mockReset(); + embedQueryMock.mockResolvedValue([0.1, 0.2, 0.3]); + }); + + it('does not probe VECTOR when the exact embedding count is zero', async () => { + executeQueryMock.mockResolvedValueOnce([{ cnt: 0 }]); + + await expect(runSemanticSearch(new LocalBackend())).resolves.toEqual([]); + + expect(ensureVectorExtensionMock).not.toHaveBeenCalled(); + expect(embedQueryMock).not.toHaveBeenCalled(); + }); + + it('probes VECTOR only after embeddings are found and keeps exact-scan fallback', async () => { + executeQueryMock.mockResolvedValueOnce([{ cnt: 2 }]).mockResolvedValueOnce([]); + ensureVectorExtensionMock.mockResolvedValue(false); + + await expect(runSemanticSearch(new LocalBackend())).resolves.toEqual([]); + + expect(ensureVectorExtensionMock).toHaveBeenCalledOnce(); + expect(ensureVectorExtensionMock).toHaveBeenCalledWith('/tmp/index'); + expect(executeQueryMock).toHaveBeenCalledTimes(2); + expect( + executeQueryMock.mock.calls.some(([, cypher]) => + String(cypher).includes('QUERY_VECTOR_INDEX'), + ), + ).toBe(false); + }); + + it('uses QUERY_VECTOR_INDEX only after the lazy VECTOR load succeeds', async () => { + executeQueryMock + .mockResolvedValueOnce([{ cnt: 1 }]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + ensureVectorExtensionMock.mockResolvedValue(true); + + await expect(runSemanticSearch(new LocalBackend())).resolves.toEqual([]); + + expect(ensureVectorExtensionMock).toHaveBeenCalledOnce(); + expect( + executeQueryMock.mock.calls.some(([, cypher]) => + String(cypher).includes('QUERY_VECTOR_INDEX'), + ), + ).toBe(true); + }); + + it('falls back to the exact scan when the lazy VECTOR load rejects', async () => { + executeQueryMock.mockResolvedValueOnce([{ cnt: 2 }]).mockResolvedValueOnce([]); + ensureVectorExtensionMock.mockRejectedValue(new Error('transient load failure')); + + await expect(runSemanticSearch(new LocalBackend())).resolves.toEqual([]); + + expect(executeQueryMock).toHaveBeenCalledTimes(2); + expect( + executeQueryMock.mock.calls.some(([, cypher]) => + String(cypher).includes('QUERY_VECTOR_INDEX'), + ), + ).toBe(false); + }); + + it('reports load and index-query failures independently', async () => { + executeQueryMock.mockImplementation(async (_repoId: string, cypher: string) => { + if (cypher.includes('COUNT(*) AS cnt')) return [{ cnt: 1 }]; + if (cypher.includes('QUERY_VECTOR_INDEX')) throw new Error('stale vector index'); + return []; + }); + ensureVectorExtensionMock + .mockRejectedValueOnce(new Error('transient load failure')) + .mockResolvedValueOnce(true); + const backend = new LocalBackend(); + const cap = _captureLogger(); + + try { + await expect(runSemanticSearch(backend)).resolves.toEqual([]); + await expect(runSemanticSearch(backend)).resolves.toEqual([]); + + const messages = cap.records().map((record) => String(record.msg ?? '')); + expect( + messages.filter((message) => message.includes('vector extension load failed')), + ).toHaveLength(1); + expect( + messages.filter((message) => message.includes('vector index query failed')), + ).toHaveLength(1); + } finally { + cap.restore(); + } + }); +}); From ac68f5254c34f5ee68ded30f4beba8516d185c03 Mon Sep 17 00:00:00 2001 From: azizur100389 Date: Wed, 26 Aug 2026 15:44:33 +0100 Subject: [PATCH 05/17] fix(ingestion): preserve object handler identity (#3046) * fix(ingestion): preserve object handler identity * fix(impact): cap object callable expansion --- .../scope-resolution/graph-bridge/ids.ts | 36 +- .../graph-bridge/node-lookup.ts | 15 + .../src/core/ingestion/utils/ast-helpers.ts | 35 ++ .../src/core/ingestion/workers/callable-id.ts | 51 ++- .../core/ingestion/workers/parse-worker.ts | 104 ++++-- gitnexus/src/mcp/local/local-backend.ts | 72 +++- gitnexus/src/storage/parse-cache.ts | 13 +- ...ast-helpers-object-literal-binding.test.ts | 12 + .../integration/object-literal-impact.test.ts | 185 ++++++++++ .../object-literal-owner-resolution.test.ts | 341 +++++++++++++++++- .../unit/call-summary-schema-version.test.ts | 2 +- .../unit/impact-batching-grouping.test.ts | 135 +++++++ .../test/unit/incremental-parse-cache.test.ts | 6 +- .../node-lookup-determinism.test.ts | 69 ++++ 14 files changed, 1025 insertions(+), 51 deletions(-) create mode 100644 gitnexus/test/integration/object-literal-impact.test.ts diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts index c0289d496..d0f99de15 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts @@ -22,6 +22,7 @@ import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexe import { generateId } from '../../../../lib/utils.js'; import { AMBIGUOUS_POSITION, + exactPositionKey, localNameKey, positionKey, qualifiedKey, @@ -168,14 +169,6 @@ function pickCallerCallableDef( * resolution working for languages that don't yet synthesize * qualifiers). */ -/** - * Extract the 1-based declaration line from a scope-resolution def id. - * Shape: `def:#::<...>`; `undefined` when it doesn't match. - */ -function defStartLine(nodeId: string | undefined, filePath: string): number | undefined { - return definitionIdPosition(nodeId, filePath)?.line; -} - /** * Trailing segment of a dotted qualified name (`Outer.inner` -> `inner`), * with any function-local `@line:col` identity suffix stripped @@ -256,9 +249,34 @@ export function resolveDefGraphId( // AST nodes (outer wrapper vs inner callable), but the graph node's // `startLine` follows the initializer (#2735) so this join matches even // when the binding is split across lines. - const line = defStartLine(def.nodeId, filePath); + const definitionPosition = definitionIdPosition(def.nodeId, filePath); + const line = definitionPosition?.line; if (line !== undefined && isPositionQualifiedLocalLabel(def.type)) { const simple = simpleNameOf(qn); + if (definitionPosition !== undefined) { + const exactHit = nodeLookup.get( + exactPositionKey( + filePath, + def.type, + definitionPosition.line - 1, + definitionPosition.column, + ), + ); + if (exactHit !== undefined && exactHit !== AMBIGUOUS_POSITION) return exactHit; + if (exactHit === undefined && siblingLabel !== undefined) { + const siblingExactHit = nodeLookup.get( + exactPositionKey( + filePath, + siblingLabel, + definitionPosition.line - 1, + definitionPosition.column, + ), + ); + if (siblingExactHit !== undefined && siblingExactHit !== AMBIGUOUS_POSITION) { + return siblingExactHit; + } + } + } const posHit = nodeLookup.get(positionKey(filePath, def.type, line - 1, simple)); if (posHit !== undefined && posHit !== AMBIGUOUS_POSITION) return posHit; // Retry under the sibling callable label when the def's OWN label diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts index 5099508e3..2658b1fd7 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts @@ -96,6 +96,16 @@ export function positionKey( return `

:${filePath}::${label}::${startLine}::${name}`; } +/** Exact source-position key used before the legacy line/name join. */ +export function exactPositionKey( + filePath: string, + label: NodeLabel, + startLine: number, + startColumn: number, +): string { + return `:${filePath}::${label}::${startLine}:${startColumn}`; +} + /** * Key recording that a FUNCTION-LOCAL callable with this simple name exists in the * file (#2699 follow-up). @@ -131,6 +141,7 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup { name?: string; qualifiedName?: string; templateArguments?: readonly string[]; + startColumn?: number; }; if (props.filePath === undefined || props.name === undefined) continue; if (!isLinkableLabel(node.label)) continue; @@ -139,6 +150,10 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup { // ambiguous rather than letting source order decide. const startLine = (props as { startLine?: number }).startLine; if (startLine !== undefined && isPositionQualifiedLocalLabel(node.label)) { + if (props.startColumn !== undefined) { + const exactK = exactPositionKey(props.filePath, node.label, startLine, props.startColumn); + lookup.set(exactK, lookup.has(exactK) ? AMBIGUOUS_POSITION : node.id); + } const posK = positionKey(props.filePath, node.label, startLine, props.name); lookup.set(posK, lookup.has(posK) ? AMBIGUOUS_POSITION : node.id); // A local-identity node carries `@:` on its last name segment. Record diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index 65f06bd31..1a88c9a15 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -1128,6 +1128,34 @@ export interface ObjectLiteralBindingInfo { ownerName?: string; } +/** + * True when an object-literal member is contained by an array before reaching + * another callable or class boundary. + * + * An array does not provide a stable named owner for its elements, so members + * below one cannot use `.` identity or ownership. They still + * need distinct graph identities, however; callers use this predicate to opt + * into source-position qualification while keeping ownership suppressed. + */ +export const isArrayContainedObjectLiteralMember = (node: SyntaxNode): boolean => { + let current: SyntaxNode | null = node; + let sawObject = false; + + while (current) { + if (current.type === 'object') sawObject = true; + if (current.type === 'array' && sawObject) return true; + if ( + current !== node && + (FUNCTION_NODE_TYPES.has(current.type) || CLASS_CONTAINER_TYPES.has(current.type)) + ) { + return false; + } + current = current.parent; + } + + return false; +}; + /** * Block-statement AST types that disqualify an object-literal binding from * carrying a HAS_METHOD edge. A `const` declared inside one of these is block- @@ -1283,6 +1311,13 @@ export const findObjectLiteralBindingInfo = ( objectDepth += 1; } + if (current !== node && current.type === 'array') { + // `const handlers = [{ run() {} }]` has no `handlers.run` member. + // Crossing the array would mint a confident but false owner edge; keep + // the existing conservative under-approximation used for nested objects. + return null; + } + if (current.type === 'variable_declarator' && objectDepth >= 1) { if (objectDepth > 1) { // Method belongs to a nested object literal; safe under-approximation. diff --git a/gitnexus/src/core/ingestion/workers/callable-id.ts b/gitnexus/src/core/ingestion/workers/callable-id.ts index cb8cea942..7672dd8c8 100644 --- a/gitnexus/src/core/ingestion/workers/callable-id.ts +++ b/gitnexus/src/core/ingestion/workers/callable-id.ts @@ -43,12 +43,12 @@ function containsPosition(node: SyntaxNode, row: number, column: number): boolea } /** - * Zero-based start row that keys the graph-to-scope position join for a bound - * callable (#2735). + * Zero-based start position that keys the graph-to-scope join for a bound + * callable (#2735/#3041). * * Graph-node queries may anchor on an outer binding wrapper while the scope - * channel anchors on the inner callable. The join is line-only, so a multi-line - * binding needs the graph node's `startLine` to follow the semantic definition. + * channel anchors on the inner callable. A bound graph node therefore follows + * the semantic definition's line and column rather than the outer wrapper. * * `ParsedFile.localDefs` is the language-agnostic source of that position. * Matching uses only the canonical label, name, and source range; shared worker @@ -58,21 +58,26 @@ function containsPosition(node: SyntaxNode, row: number, column: number): boolea * Missing or ambiguous semantic matches retain the wrapper row, preserving the * existing fail-closed behavior. */ -export function boundCallableStartRow( +export function boundCallableStartPosition( definitionNode: SyntaxNode, nodeName: string, nodeLabel: NodeLabel, localDefs: readonly SymbolDefinition[] | undefined, nameNode?: SyntaxNode | null, -): number { - if (localDefs === undefined) return definitionNode.startPosition.row; +): { readonly row: number; readonly column: number } { + if (localDefs === undefined) return definitionNode.startPosition; const origin = nameNode?.startPosition ?? definitionNode.startPosition; - let best: { row: number; distance: number } | undefined; + let best: { row: number; column: number; distance: number } | undefined; let tied = false; for (const def of localDefs) { - if (def.type !== nodeLabel || simpleDefinitionName(def) !== nodeName) continue; + if ( + def.type !== nodeLabel || + (simpleDefinitionName(def) !== nodeName && def.qualifiedName !== nodeName) + ) { + continue; + } const position = definitionIdPosition(def.nodeId, def.filePath); if (position === undefined) continue; @@ -82,14 +87,29 @@ export function boundCallableStartRow( const distance = Math.abs(row - origin.row) * 1_000_000 + Math.abs(position.column - origin.column); if (best === undefined || distance < best.distance) { - best = { row, distance }; + best = { row, column: position.column, distance }; tied = false; - } else if (distance === best.distance && row !== best.row) { + } else if ( + distance === best.distance && + (row !== best.row || position.column !== best.column) + ) { tied = true; } } - return best !== undefined && !tied ? best.row : definitionNode.startPosition.row; + return best !== undefined && !tied + ? { row: best.row, column: best.column } + : definitionNode.startPosition; +} + +export function boundCallableStartRow( + definitionNode: SyntaxNode, + nodeName: string, + nodeLabel: NodeLabel, + localDefs: readonly SymbolDefinition[] | undefined, + nameNode?: SyntaxNode | null, +): number { + return boundCallableStartPosition(definitionNode, nodeName, nodeLabel, localDefs, nameNode).row; } /** * A function-local callable's own name segment: its name plus its declaration @@ -114,8 +134,13 @@ export function boundCallableStartRow( * bare/class-qualified ids, which is what keeps this off the symbols other * files, saved queries and stored references actually address. */ +export const positionQualifiedCallableName = ( + name: string, + position: { readonly row: number; readonly column: number }, +): string => `${name}@${position.row}:${position.column}`; + export const localIdentity = (node: SyntaxNode, name: string): string => - `${name}@${node.startPosition.row}:${node.startPosition.column}`; + positionQualifiedCallableName(name, node.startPosition); /** * The qualified name of a callable nested inside another callable — THE single diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 1d1f3dab4..946d060a6 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -1,8 +1,9 @@ import { parentPort, threadId, workerData } from 'node:worker_threads'; import { - boundCallableStartRow, + boundCallableStartPosition, localIdentity, nestedCallableQualifiedName, + positionQualifiedCallableName, } from './callable-id.js'; import Parser from 'tree-sitter'; import JavaScript from 'tree-sitter-javascript'; @@ -91,6 +92,7 @@ import { getDefinitionNodeFromCaptures, findEnclosingClassInfo, findObjectLiteralBindingInfo, + isArrayContainedObjectLiteralMember, findReturnShapeOwnerInfo, isReturnShapeProperty, findMemberAssignmentOwnerInfo, @@ -840,6 +842,13 @@ const CALLABLE_PREFIX_BOUNDARY_TYPES: ReadonlySet = new Set([ 'anonymous_object_creation_expression', // C# ]); +/** + * Object-literal callables use the binding owner in their identity so spelling + * a member as a property or shorthand method cannot change its graph semantics. + */ +const shouldObjectOwnerQualifyCallable = (label: NodeLabel): boolean => + label === 'Function' || label === 'Method'; + const enclosingCallablePrefix = ( node: SyntaxNode, filePath: string, @@ -907,13 +916,27 @@ const callableOwnQualifiedName = ( // `ownName === null` branch below carries the position INSTEAD of a name, // never in addition to one, so the two spellings cannot stack. const ownName = efnResult?.funcName ?? genericFuncName(fnNode) ?? null; + let finalLabel = efnResult?.label ?? inferFunctionLabel(fnNode.type); + if (provider.labelOverride) { + const override = provider.labelOverride(fnNode, finalLabel); + if (override !== null) finalLabel = override; + } const prefix = enclosingCallablePrefix(fnNode, filePath, provider); const classInfo = prefix === undefined ? cachedFindEnclosingClassInfo(fnNode, filePath, provider.resolveEnclosingOwner) : null; - const owner = prefix ?? classInfo?.className; + const objectOwner = + prefix === undefined && classInfo === null && shouldObjectOwnerQualifyCallable(finalLabel) + ? findObjectLiteralBindingInfo(fnNode, filePath, { includeOwnerName: true })?.ownerName + : undefined; + const owner = prefix ?? classInfo?.className ?? objectOwner; + const needsArrayPosition = + owner === undefined && + ownName !== null && + shouldObjectOwnerQualifyCallable(finalLabel) && + isArrayContainedObjectLiteralMember(fnNode); const result = prefix !== undefined ? nestedCallableQualifiedName(prefix, fnNode, ownName ?? 'fn') @@ -921,7 +944,9 @@ const callableOwnQualifiedName = ( ? localIdentity(fnNode, 'fn') : owner ? `${owner}.${ownName}` - : ownName; + : needsArrayPosition + ? positionQualifiedCallableName(ownName, fnNode.startPosition) + : ownName; callableQualifiedNameCache.set(fnNode, result); return result; }; @@ -974,8 +999,21 @@ const findEnclosingFunctionId = ( // to the METHOD, not directly to the class, and a Go receiver method can // never itself be nested inside another callable. const nestedPrefix = enclosingCallablePrefix(current, filePath, provider); + const objectOwnerName = + nestedPrefix === undefined && + classInfo === null && + shouldObjectOwnerQualifyCallable(finalLabel) + ? findObjectLiteralBindingInfo(current, filePath, { includeOwnerName: true })?.ownerName + : undefined; const ownerName = - nestedPrefix ?? classInfo?.className ?? standaloneMethodInfo?.receiverType ?? undefined; + nestedPrefix ?? + classInfo?.className ?? + standaloneMethodInfo?.receiverType ?? + objectOwnerName; + const needsArrayPosition = + ownerName === undefined && + shouldObjectOwnerQualifyCallable(finalLabel) && + isArrayContainedObjectLiteralMember(current); // Lockstep with the other two id-building phases — see // `nestedCallableQualifiedName`, which is the shared rule. When a // nested prefix exists it IS `ownerName`, so this branch and the @@ -985,7 +1023,9 @@ const findEnclosingFunctionId = ( ? nestedCallableQualifiedName(nestedPrefix, current, funcName) : ownerName ? `${ownerName}.${funcName}` - : funcName; + : needsArrayPosition + ? positionQualifiedCallableName(funcName, current.startPosition) + : funcName; // Include # suffix to match definition-phase Method/Constructor IDs. // Use the same MethodExtractor (getMethodInfo) as the definition phase. // When same-arity collisions exist, also append ~type1,type2. @@ -2283,23 +2323,24 @@ const processFileGroup = ( // wrapper while scope-resolution anchors on the INNER expression. The // position join is line-only, so `startLine` must follow the initializer // (ids still use `definitionNode` via `localIdentity`). - const startRow = + const startPosition = definitionNode && (nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Constructor') - ? boundCallableStartRow( + ? boundCallableStartPosition( definitionNode, nodeName, nodeLabel, parsedFile?.localDefs, nameNode, ) - : definitionNode?.startPosition.row; + : definitionNode?.startPosition; const startLine = - startRow !== undefined - ? startRow + lineOffset + startPosition !== undefined + ? startPosition.row + lineOffset : nameNode ? nameNode.startPosition.row + lineOffset : lineOffset; + const startColumn = startPosition?.column ?? nameNode?.startPosition.column ?? 0; // Compute enclosing class BEFORE node ID — needed to qualify method IDs const needsOwner = @@ -2385,20 +2426,31 @@ const processFileGroup = ( // and COLLAPSE INTO ONE node — two distinct settings become one symbol, // and the merged name then looks workspace-unique to name inference, // which resolves reads of it to a node representing both. + const objectLiteralBindingInfo = + !enclosingClassId && + (nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Property') && + definitionNode + ? findObjectLiteralBindingInfo(definitionNode, file.path, { + includeOwnerName: + shouldObjectOwnerQualifyCallable(nodeLabel) || nodeLabel === 'Property', + }) + : null; const objectLiteralOwnerInfo = - !enclosingClassId && (nodeLabel === 'Method' || nodeLabel === 'Property') && definitionNode + !enclosingClassId && + (nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Property') && + definitionNode ? (findMemberAssignmentOwnerInfo(definitionNode, file.path) ?? - findObjectLiteralBindingInfo(definitionNode, file.path, { - // Only `Property` opts into the qualifier; `Method` ids must stay - // byte-identical or every object-literal method in every indexed - // repo changes id. - includeOwnerName: nodeLabel === 'Property', - }) ?? + objectLiteralBindingInfo ?? // R3-4: an anonymous literal in return position is owned by the // function whose shape it is. Last in the chain so a variable-bound // literal keeps its existing owner and its existing id. (nodeLabel === 'Property' ? findReturnShapeOwnerInfo(definitionNode, file.path) : null)) : null; + const isArrayContainedObjectCallable = + !enclosingClassId && + shouldObjectOwnerQualifyCallable(nodeLabel) && + definitionNode !== undefined && + isArrayContainedObjectLiteralMember(definitionNode); // Provenance for narrowing (R3-4). A return shape is a real definition but // the weaker one, and the unique-name pass ranks declared anchors above it // so indexing these cannot change an answer that already resolved. @@ -2496,7 +2548,9 @@ const processFileGroup = ( // define `bar` stay distinct nodes. objectLiteralOwnerInfo?.ownerName !== undefined ? `${objectLiteralOwnerInfo.ownerName}.${nodeName}` - : nodeName; + : isArrayContainedObjectCallable + ? positionQualifiedCallableName(nodeName, startPosition) + : nodeName; // #2742: qualify by the enclosing `mod` chain, so two same-named items at // different module depths in one file are DISTINCT nodes. Without this, @@ -2854,6 +2908,10 @@ const processFileGroup = ( name: nodeName, filePath: file.path, startLine, + ...(shouldObjectOwnerQualifyCallable(nodeLabel) && + (objectLiteralBindingInfo?.ownerName || isArrayContainedObjectCallable) + ? { startColumn } + : {}), endLine: definitionNode ? definitionNode.endPosition.row + lineOffset : startLine, language: language, isExported, @@ -2918,8 +2976,12 @@ const processFileGroup = ( : {}), }); - // Only emit File -> Symbol DEFINES for top-level symbols (issue #1944). - if (ownerId === undefined) { + // Object-literal callables remain file definitions as well as members of + // their exported binding. Class members still use HAS_METHOD alone. + const isTopLevelObjectCallable = + objectLiteralBindingInfo?.ownerName !== undefined && + shouldObjectOwnerQualifyCallable(nodeLabel); + if (ownerId === undefined || isTopLevelObjectCallable) { const fileId = generateId('File', file.path); const relId = generateId('DEFINES', `${fileId}->${nodeId}`); result.relationships.push({ @@ -2942,7 +3004,7 @@ const processFileGroup = ( type: memberEdgeType, confidence: 1.0, reason: objectLiteralOwnerInfo - ? 'object literal method belongs to exported object binding' + ? 'object literal member belongs to exported object binding' : '', }); } diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 9809a76e4..1679a3b80 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -6373,6 +6373,7 @@ export class LocalBackend { summaryOnly: true, skipEpistemic: true, skipEnrichment: true, + hasExplicitRelationTypes, }, ); } catch (e) { @@ -6627,6 +6628,7 @@ export class LocalBackend { limit: Number.isFinite(params.limit) ? params.limit : 100, offset: Number.isFinite(params.offset) ? params.offset : 0, pdgBridge, + hasExplicitRelationTypes, }); return composeUnifiedPdgImpactResult(pdgResult, interproceduralResult); } catch (e) { @@ -6643,6 +6645,7 @@ export class LocalBackend { limit: Number.isFinite(params.limit) ? params.limit : 100, offset: Number.isFinite(params.offset) ? params.offset : 0, summaryOnly: params.summaryOnly, + hasExplicitRelationTypes, }); } @@ -7016,6 +7019,8 @@ export class LocalBackend { skipEpistemic?: boolean; skipEnrichment?: boolean; pdgBridge?: PdgBridgeOptions; + /** Preserve an explicit caller filter; implicit structural seeds must not widen it. */ + hasExplicitRelationTypes?: boolean; }, ): Promise { const { maxDepth, relationTypes, includeTests, minConfidence } = opts; @@ -7078,7 +7083,11 @@ export class LocalBackend { const visited = new Set([symId]); const pdgBridgeEvidenceById = new Map(); let frontier = [symId]; + const objectCallableFrontier: string[] = []; let traversalComplete = true; + // Fetch one sentinel row beyond the cap so generated object bindings + // degrade visibly instead of allocating an unbounded seed frontier. + const OBJECT_CALLABLE_MEMBER_CAP = 5000; // Fix #480: For Java (and other JVM) Class/Interface nodes, CALLS edges // point to Constructor nodes and IMPORTS edges point to File nodes — not @@ -7153,6 +7162,63 @@ export class LocalBackend { } } catch (e) { logQueryError('impact:class-node-expansion', e); + traversalComplete = false; + } + } + + // Function-valued properties on exported object bindings are represented + // as Const/Variable -[:HAS_METHOD]-> Function. HAS_METHOD is intentionally + // absent from the default usage traversal, but downstream impact on the + // binding still needs to enter its own callable member before following + // CALLS. + if ( + direction === 'downstream' && + (symType === 'Const' || symType === 'Variable') && + relationTypes.includes('CALLS') && + !relationTypes.includes('HAS_METHOD') && + !opts.hasExplicitRelationTypes + ) { + try { + const memberRows = await executeParameterized( + repo.lbugPath, + ` + MATCH (n)-[hm:CodeRelation]->(member:Function) + WHERE n.id = $symId AND hm.type = 'HAS_METHOD' + RETURN DISTINCT member.id AS id, member.name AS name, + 'Function' AS type, member.filePath AS filePath + ORDER BY id + LIMIT ${OBJECT_CALLABLE_MEMBER_CAP + 1} + UNION ALL + MATCH (n)-[hm:CodeRelation]->(member:Method) + WHERE n.id = $symId AND hm.type = 'HAS_METHOD' + RETURN DISTINCT member.id AS id, member.name AS name, + 'Method' AS type, member.filePath AS filePath + ORDER BY id + LIMIT ${OBJECT_CALLABLE_MEMBER_CAP + 1} + `, + { symId }, + ); + memberRows.sort((a, b) => compareCodeUnits(String(a.id ?? a[0]), String(b.id ?? b[0]))); + if (memberRows.length > OBJECT_CALLABLE_MEMBER_CAP) traversalComplete = false; + for (const row of memberRows.slice(0, OBJECT_CALLABLE_MEMBER_CAP)) { + const memberId = row.id || row[0]; + if (memberId && !visited.has(memberId)) { + visited.add(memberId); + objectCallableFrontier.push(memberId); + impacted.push({ + depth: 1, + id: memberId, + name: row.name || row[1], + type: row.type || row[2], + filePath: row.filePath || row[3] || '', + relationType: 'HAS_METHOD', + confidence: 1, + }); + } + } + } catch (e) { + logQueryError('impact:object-callable-expansion', e); + traversalComplete = false; } } @@ -7307,7 +7373,10 @@ export class LocalBackend { break; } - frontier = nextFrontier; + frontier = + depth === 1 && objectCallableFrontier.length > 0 + ? [...new Set([...nextFrontier, ...objectCallableFrontier])] + : nextFrontier; } // Stamp the finalized, order-independent bridge evidence (strongest across @@ -7921,6 +7990,7 @@ export class LocalBackend { // the #1858 epistemic/boundaries fields — computing them per neighbor is // dead work on the highest-volume path, so suppress them here too. skipEpistemic: true, + hasExplicitRelationTypes: opts.relationTypes.length > 0, }); } catch { return null; diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 903a57016..64ddc8264 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -568,7 +568,6 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // and the v37/v38 clash it was written for: the next free value above every // IN-FLIGHT claim, not above origin/main. Every open PR touching gitnexus/ was // scanned; #3017 is the only other claimant. -// RE-CHECK AGAINST origin/main AND OPEN PRs IMMEDIATELY BEFORE MERGING. // // 72 -> 74 adds import-proven Convex endpoint metadata to Const/Function worker // output. A warm v72 cache has no convexEndpointFactory property, so the MCP @@ -578,7 +577,17 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // migration needs both guarantees. Version 73 is intentionally skipped because // concurrent PR #3046 (fixes #3041) claims it. Re-check main and open PRs // immediately before merge. -const SCHEMA_BUMP = 74; +// +// 74 -> 76 makes object-literal Function and Method members owner-qualified +// (#3041). A warm v74 cache replays the old collapsed callable ids and omits +// the new Const/Variable -> callable HAS_METHOD ownership edges, so this bump +// makes unchanged files re-parse rather than waiting for a source edit. The +// persisted graph is rebuilt separately when `analyzerRunnerIdentitiesEqual` +// detects the changed analyzer build in run-analyze.ts. Both guards are +// required; a parse-cache bump alone must never be read as a graph rebuild. +// Version 75 is intentionally skipped because concurrent PR #3017 claims it. +// RE-CHECK AGAINST origin/main AND OPEN PRs IMMEDIATELY BEFORE MERGING. +const SCHEMA_BUMP = 76; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/integration/ast-helpers-object-literal-binding.test.ts b/gitnexus/test/integration/ast-helpers-object-literal-binding.test.ts index 99f3700aa..9cbb75bda 100644 --- a/gitnexus/test/integration/ast-helpers-object-literal-binding.test.ts +++ b/gitnexus/test/integration/ast-helpers-object-literal-binding.test.ts @@ -7,6 +7,7 @@ * - happy path: file-scope export const / const / export var → returns binding * - local-inside-function / arrow / class-constructor → null * - nested object literal → null (safe under-approximation) + * - object literal reached through an array → null * - block-scoped declaration (if / for body) → null * - IIFE-wrapped object literal → null * - assignment without declarator → null (no throw) @@ -125,6 +126,17 @@ describe('findObjectLiteralBindingInfo — negative: nested literals', () => { }); }); +describe('findObjectLiteralBindingInfo — negative: array elements', () => { + it('does not invent an owner member for an object literal inside an array', () => { + const tree = parseTs(`export const handlers = [{ run() {} }, { run() {} }];`); + const methodNodes = findMethodNodes(tree.rootNode, 'run'); + expect(methodNodes).toHaveLength(2); + for (const methodNode of methodNodes) { + expect(findObjectLiteralBindingInfo(methodNode, 'src/handlers.ts')).toBe(null); + } + }); +}); + describe('findObjectLiteralBindingInfo — negative: block scope', () => { it('declared inside top-level if-block → null', () => { const tree = parseTs(` diff --git a/gitnexus/test/integration/object-literal-impact.test.ts b/gitnexus/test/integration/object-literal-impact.test.ts new file mode 100644 index 000000000..6e69b56fa --- /dev/null +++ b/gitnexus/test/integration/object-literal-impact.test.ts @@ -0,0 +1,185 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { listRegisteredRepos } from '../../src/storage/repo-manager.js'; +import { withTestLbugDB, type IndexedDBHandle } from '../helpers/test-indexed-db.js'; + +vi.mock('../../src/storage/repo-manager.js', async (importActual) => ({ + ...(await importActual()), + listRegisteredRepos: vi.fn().mockResolvedValue([]), + cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }), + findSiblingClones: vi.fn().mockResolvedValue([]), +})); + +type BackendHandle = IndexedDBHandle & { backend?: LocalBackend }; + +const FIRST = 'Const:src/convex.ts:first'; +const SECOND = 'Const:src/convex.ts:second'; +const THIRD = 'Variable:src/convex.ts:third'; +const FIRST_HANDLER = 'Function:src/convex.ts:first.handler'; +const SECOND_HANDLER = 'Function:src/convex.ts:second.handler'; +const THIRD_HANDLER = 'Function:src/convex.ts:third.handler'; +const HELPER_A = 'Function:src/convex.ts:helperA'; +const HELPER_B = 'Function:src/convex.ts:helperB'; +const HELPER_C = 'Function:src/convex.ts:helperC'; +const SERVICE = 'Const:src/convex.ts:service'; +const SERVICE_RUN = 'Method:src/convex.ts:service.run#0'; +const HELPER_D = 'Function:src/convex.ts:helperD'; + +withTestLbugDB( + 'object-literal-impact-3041', + (handle) => { + describe('downstream impact through object-owned callables (#3041)', () => { + let backend: LocalBackend; + + beforeAll(() => { + const attached = (handle as BackendHandle).backend; + if (!attached) throw new Error('LocalBackend was not attached during setup'); + backend = attached; + }); + + it.each([ + ['first', HELPER_A, HELPER_B], + ['second', HELPER_B, HELPER_A], + ])('%s reaches only its own helper', async (target, expected, excluded) => { + const result = await backend.callTool('impact', { + target, + direction: 'downstream', + }); + expect(result).not.toHaveProperty('error'); + const ids = Object.values(result.byDepth ?? {}) + .flatMap((entries) => entries as Array<{ id: string }>) + .map((entry) => entry.id); + expect(ids).toContain(expected); + expect(ids).not.toContain(excluded); + }); + + it('a let binding reaches its owned handler calls', async () => { + const result = await backend.callTool('impact', { + target: 'third', + direction: 'downstream', + }); + expect(result).not.toHaveProperty('error'); + const ids = Object.values(result.byDepth ?? {}) + .flatMap((entries) => entries as Array<{ id: string }>) + .map((entry) => entry.id); + expect(ids).toContain(HELPER_C); + expect(ids).not.toContain(HELPER_A); + expect(ids).not.toContain(HELPER_B); + }); + + it('counts the implicit member as depth 1 and its callee as depth 2', async () => { + const firstHop = await backend.callTool('impact', { + target: 'first', + direction: 'downstream', + maxDepth: 1, + }); + expect(firstHop.byDepth?.['1']?.map((entry: { id: string }) => entry.id)).toEqual([ + FIRST_HANDLER, + ]); + expect(firstHop.byDepth?.['2']).toBeUndefined(); + + const secondHop = await backend.callTool('impact', { + target: 'first', + direction: 'downstream', + maxDepth: 2, + }); + expect(secondHop.byDepth?.['1']?.map((entry: { id: string }) => entry.id)).toEqual([ + FIRST_HANDLER, + ]); + expect(secondHop.byDepth?.['2']?.map((entry: { id: string }) => entry.id)).toContain( + HELPER_A, + ); + expect(secondHop.summary?.direct).toBe(1); + }); + + it('does not widen an explicit CALLS-only traversal through HAS_METHOD', async () => { + const result = await backend.callTool('impact', { + target: 'first', + direction: 'downstream', + relationTypes: ['CALLS'], + maxDepth: 3, + }); + + expect(result.impactedCount).toBe(0); + expect(result.byDepth).toEqual({}); + }); + + it('enters a Method-labelled shorthand member on the default traversal', async () => { + const result = await backend.callTool('impact', { + target: 'service', + direction: 'downstream', + maxDepth: 2, + }); + + expect(result.byDepth?.['1']?.map((entry: { id: string }) => entry.id)).toEqual([ + SERVICE_RUN, + ]); + expect(result.byDepth?.['2']?.map((entry: { id: string }) => entry.id)).toEqual([HELPER_D]); + }); + + it('preserves explicit HAS_METHOD traversal depth', async () => { + const firstHop = await backend.callTool('impact', { + target: 'first', + direction: 'downstream', + maxDepth: 1, + relationTypes: ['HAS_METHOD', 'CALLS'], + }); + expect(firstHop).not.toHaveProperty('error'); + expect(firstHop.byDepth?.['1']?.map((entry: { id: string }) => entry.id)).toEqual([ + FIRST_HANDLER, + ]); + + const secondHop = await backend.callTool('impact', { + target: 'first', + direction: 'downstream', + maxDepth: 2, + relationTypes: ['HAS_METHOD', 'CALLS'], + }); + expect(secondHop).not.toHaveProperty('error'); + expect(secondHop.byDepth?.['2']?.map((entry: { id: string }) => entry.id)).toContain( + HELPER_A, + ); + }); + }); + }, + { + seed: [ + `CREATE (:Const {id: '${FIRST}', name: 'first', filePath: 'src/convex.ts', startLine: 1, endLine: 1})`, + `CREATE (:Const {id: '${SECOND}', name: 'second', filePath: 'src/convex.ts', startLine: 2, endLine: 2})`, + `CREATE (:Variable {id: '${THIRD}', name: 'third', filePath: 'src/convex.ts', startLine: 3, endLine: 3})`, + `CREATE (:Function {id: '${FIRST_HANDLER}', name: 'handler', filePath: 'src/convex.ts', startLine: 3, endLine: 3})`, + `CREATE (:Function {id: '${SECOND_HANDLER}', name: 'handler', filePath: 'src/convex.ts', startLine: 4, endLine: 4})`, + `CREATE (:Function {id: '${THIRD_HANDLER}', name: 'handler', filePath: 'src/convex.ts', startLine: 5, endLine: 5})`, + `CREATE (:Function {id: '${HELPER_A}', name: 'helperA', filePath: 'src/convex.ts', startLine: 5, endLine: 5})`, + `CREATE (:Function {id: '${HELPER_B}', name: 'helperB', filePath: 'src/convex.ts', startLine: 6, endLine: 6})`, + `CREATE (:Function {id: '${HELPER_C}', name: 'helperC', filePath: 'src/convex.ts', startLine: 7, endLine: 7})`, + `CREATE (:Const {id: '${SERVICE}', name: 'service', filePath: 'src/convex.ts', startLine: 8, endLine: 8})`, + `CREATE (:Method {id: '${SERVICE_RUN}', name: 'run', filePath: 'src/convex.ts', startLine: 8, endLine: 8})`, + `CREATE (:Function {id: '${HELPER_D}', name: 'helperD', filePath: 'src/convex.ts', startLine: 9, endLine: 9})`, + `MATCH (a:Const), (b:Function) WHERE a.id = '${FIRST}' AND b.id = '${FIRST_HANDLER}' CREATE (a)-[:CodeRelation {type: 'HAS_METHOD', confidence: 1.0, reason: 'object literal member'}]->(b)`, + `MATCH (a:Const), (b:Function) WHERE a.id = '${SECOND}' AND b.id = '${SECOND_HANDLER}' CREATE (a)-[:CodeRelation {type: 'HAS_METHOD', confidence: 1.0, reason: 'object literal member'}]->(b)`, + `MATCH (a:Variable), (b:Function) WHERE a.id = '${THIRD}' AND b.id = '${THIRD_HANDLER}' CREATE (a)-[:CodeRelation {type: 'HAS_METHOD', confidence: 1.0, reason: 'object literal member'}]->(b)`, + `MATCH (a:Function), (b:Function) WHERE a.id = '${FIRST_HANDLER}' AND b.id = '${HELPER_A}' CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 0.85, reason: 'direct'}]->(b)`, + `MATCH (a:Function), (b:Function) WHERE a.id = '${SECOND_HANDLER}' AND b.id = '${HELPER_B}' CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 0.85, reason: 'direct'}]->(b)`, + `MATCH (a:Function), (b:Function) WHERE a.id = '${THIRD_HANDLER}' AND b.id = '${HELPER_C}' CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 0.85, reason: 'direct'}]->(b)`, + `MATCH (a:Const), (b:Method) WHERE a.id = '${SERVICE}' AND b.id = '${SERVICE_RUN}' CREATE (a)-[:CodeRelation {type: 'HAS_METHOD', confidence: 1.0, reason: 'object literal member'}]->(b)`, + `MATCH (a:Method), (b:Function) WHERE a.id = '${SERVICE_RUN}' AND b.id = '${HELPER_D}' CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 0.85, reason: 'direct'}]->(b)`, + ], + poolAdapter: true, + afterSetup: async (handle) => { + vi.mocked(listRegisteredRepos).mockResolvedValue([ + { + name: 'object-literal-impact-repo', + path: '/object-literal-impact/repo', + storagePath: handle.tmpHandle.dbPath, + indexedAt: new Date().toISOString(), + lastCommit: 'abc123', + stats: { files: 1, nodes: 6, communities: 0, processes: 0 }, + }, + ]); + const backend = new LocalBackend(); + await backend.init(); + (handle as BackendHandle).backend = backend; + }, + }, +); diff --git a/gitnexus/test/integration/object-literal-owner-resolution.test.ts b/gitnexus/test/integration/object-literal-owner-resolution.test.ts index 176e6e19a..e2513711b 100644 --- a/gitnexus/test/integration/object-literal-owner-resolution.test.ts +++ b/gitnexus/test/integration/object-literal-owner-resolution.test.ts @@ -36,6 +36,17 @@ import { type PipelineResult, } from './resolvers/helpers.js'; import { generateId } from '../../src/lib/utils.js'; +import { + loadParseCache, + PARSE_CACHE_VERSION, + pruneCache, + saveParseCache, + type ParseCache, +} from '../../src/storage/parse-cache.js'; +import { + getDurableParsedFileDir, + pruneAndSaveDurableParsedFileStore, +} from '../../src/storage/parsedfile-store.js'; const DIST_WORKER = path.resolve( __dirname, @@ -146,7 +157,7 @@ describe.skipIf(!hasDistWorker)('object-literal owner resolution — worker pipe // The Method node id encodes arity disambiguation (#1 = one-arity overload). // Pin the canonical id so a regression that targets a phantom node fails. - const expectedTargetId = generateId('Method', 'src/service.ts:getUser#1'); + const expectedTargetId = generateId('Method', 'src/service.ts:fooService.getUser#1'); expect(callerToGetUser).toEqual([ { targetId: expectedTargetId, @@ -236,3 +247,331 @@ describe.skipIf(!hasDistWorker)( }); }, ); + +// ── #3041: same-named function-valued properties ─────────────────────────── + +describe.skipIf(!hasDistWorker)( + 'object-literal owner resolution — same-named property callables (#3041)', + () => { + let repoRoot: string; + let result: PipelineResult; + + beforeAll(async () => { + repoRoot = writeFixture({ + 'src/convex.ts': `function query(config: T): T { return config; } +function helperA(ctx: unknown) { return ctx; } +function helperB(ctx: unknown) { return ctx; } +function helperC(ctx: unknown) { return ctx; } + +export const first = query({ handler: async (ctx: unknown) => helperA(ctx) }); export const second = query({ handler: async (ctx: unknown) => helperB(ctx) }); +export let third = query({ handler: async (ctx: unknown) => helperC(ctx) }); +`, + }); + result = await runPipelineFromRepo(repoRoot, () => undefined, { + skipGraphPhases: true, + }); + }, 60000); + + afterAll(() => removeFixture(repoRoot)); + + it('creates one owner-qualified handler node per exported const', () => { + const handlerIds: string[] = []; + result.graph.forEachNode((node) => { + if (node.label === 'Function' && node.properties.name === 'handler') { + handlerIds.push(node.id); + } + }); + + expect(handlerIds.sort()).toEqual( + [ + generateId('Function', 'src/convex.ts:first.handler'), + generateId('Function', 'src/convex.ts:second.handler'), + generateId('Function', 'src/convex.ts:third.handler'), + ].sort(), + ); + }); + + it('links each exported const to only its own handler', () => { + const ownership = getRelationships(result, 'HAS_METHOD') + .filter((edge) => edge.target === 'handler') + .map((edge) => `${edge.source}:${edge.rel.targetId}`) + .sort(); + + expect(ownership).toEqual( + [ + `first:${generateId('Function', 'src/convex.ts:first.handler')}`, + `second:${generateId('Function', 'src/convex.ts:second.handler')}`, + `third:${generateId('Function', 'src/convex.ts:third.handler')}`, + ].sort(), + ); + }); + + it('keeps each handler call on its real owner with no cross-attribution', () => { + const calls = getRelationships(result, 'CALLS') + .filter( + (edge) => + edge.target === 'helperA' || edge.target === 'helperB' || edge.target === 'helperC', + ) + .map((edge) => `${edge.rel.sourceId}->${edge.target}`) + .sort(); + + expect(calls).toEqual( + [ + `${generateId('Function', 'src/convex.ts:first.handler')}->helperA`, + `${generateId('Function', 'src/convex.ts:second.handler')}->helperB`, + `${generateId('Function', 'src/convex.ts:third.handler')}->helperC`, + ].sort(), + ); + }); + + it('keeps owner-qualified handlers reachable from their file definition', () => { + const defined = getRelationships(result, 'DEFINES') + .filter((edge) => edge.target === 'handler') + .map((edge) => edge.rel.targetId) + .sort(); + + expect(defined).toEqual( + [ + generateId('Function', 'src/convex.ts:first.handler'), + generateId('Function', 'src/convex.ts:second.handler'), + generateId('Function', 'src/convex.ts:third.handler'), + ].sort(), + ); + }); + }, +); + +describe.skipIf(!hasDistWorker)('object-literal shorthand method identity (#3041)', () => { + let repoRoot: string; + let result: PipelineResult; + + beforeAll(async () => { + repoRoot = writeFixture({ + 'src/shorthand.ts': `function helperA(value: string) { return value; } +function helperB(value: string) { return value; } +export const alpha = { run(value: string) { return helperA(value); } }; +export const beta = { run(value: string) { return helperB(value); } }; +`, + }); + result = await runPipelineFromRepo(repoRoot, () => undefined, { + skipGraphPhases: true, + }); + }, 60_000); + + afterAll(() => removeFixture(repoRoot)); + + it('gives sibling shorthand methods distinct owner-qualified identities and calls', () => { + const nodeIds = new Set(); + result.graph.forEachNode((node) => nodeIds.add(node.id)); + const alphaRun = generateId('Method', 'src/shorthand.ts:alpha.run#1'); + const betaRun = generateId('Method', 'src/shorthand.ts:beta.run#1'); + const calls = getRelationships(result, 'CALLS') + .filter((edge) => edge.target === 'helperA' || edge.target === 'helperB') + .map((edge) => `${edge.rel.sourceId}->${edge.target}`) + .sort(); + + expect(nodeIds.has(alphaRun)).toBe(true); + expect(nodeIds.has(betaRun)).toBe(true); + expect(calls).toEqual([`${alphaRun}->helperA`, `${betaRun}->helperB`]); + }); + + it('keeps shorthand methods on both File DEFINES and binding HAS_METHOD edges', () => { + const methodIds = [ + generateId('Method', 'src/shorthand.ts:alpha.run#1'), + generateId('Method', 'src/shorthand.ts:beta.run#1'), + ].sort(); + const defined = getRelationships(result, 'DEFINES') + .filter((edge) => edge.target === 'run') + .map((edge) => edge.rel.targetId) + .sort(); + const owned = getRelationships(result, 'HAS_METHOD') + .filter((edge) => edge.target === 'run') + .map((edge) => edge.rel.targetId) + .sort(); + + expect(defined).toEqual(methodIds); + expect(owned).toEqual(methodIds); + }); +}); + +describe.skipIf(!hasDistWorker)('object-literal dotted property identity (#3041)', () => { + let repoRoot: string; + let result: PipelineResult; + + beforeAll(async () => { + repoRoot = writeFixture({ + 'src/dotted.ts': `function helperC(value: number) { return value; } +function helperD(value: number) { return value; } +export const p = { 'q.r': (value: number) => helperC(value) }; +export const z = { r: (value: number) => helperD(value) }; +`, + }); + result = await runPipelineFromRepo(repoRoot, () => undefined, { + skipGraphPhases: true, + }); + }, 60_000); + + afterAll(() => removeFixture(repoRoot)); + + it('attributes dotted and plain member calls to their own owner-qualified nodes', () => { + const calls = getRelationships(result, 'CALLS') + .filter((edge) => edge.target === 'helperC' || edge.target === 'helperD') + .map((edge) => `${edge.rel.sourceId}->${edge.target}`) + .sort(); + + expect(calls).toEqual( + [ + `${generateId('Function', 'src/dotted.ts:p.q.r')}->helperC`, + `${generateId('Function', 'src/dotted.ts:z.r')}->helperD`, + ].sort(), + ); + }); +}); + +describe.skipIf(!hasDistWorker)('object-literal array ownership barrier (#3041)', () => { + let repoRoot: string; + let result: PipelineResult; + + beforeAll(async () => { + repoRoot = writeFixture({ + 'src/array.ts': `function helperA(value: number) { return value; } +function helperB(value: number) { return value; } +export const handlers = [ + { handle: (value: number) => helperA(value) }, + { handle: (value: number) => helperB(value) }, +]; +`, + }); + result = await runPipelineFromRepo(repoRoot, () => undefined, { + skipGraphPhases: true, + }); + }, 60_000); + + afterAll(() => removeFixture(repoRoot)); + + it('keeps array-contained callables distinct without inventing ownership', () => { + const falseId = generateId('Function', 'src/array.ts:handlers.handle'); + const bareId = generateId('Function', 'src/array.ts:handle'); + const handlerIds = [ + generateId('Function', 'src/array.ts:handle@3:12'), + generateId('Function', 'src/array.ts:handle@4:12'), + ].sort(); + const nodeIds = new Set(); + result.graph.forEachNode((node) => nodeIds.add(node.id)); + const calls = getRelationships(result, 'CALLS') + .filter((edge) => edge.target === 'helperA' || edge.target === 'helperB') + .map((edge) => `${edge.rel.sourceId}->${edge.target}`) + .sort(); + const falseOwnership = getRelationships(result, 'HAS_METHOD').filter( + (edge) => edge.source === 'handlers' && edge.target === 'handle', + ); + + expect(nodeIds.has(falseId)).toBe(false); + expect(nodeIds.has(bareId)).toBe(false); + expect(handlerIds.every((id) => nodeIds.has(id))).toBe(true); + expect(calls).toEqual([`${handlerIds[0]}->helperA`, `${handlerIds[1]}->helperB`].sort()); + expect(falseOwnership).toEqual([]); + }); +}); + +describe.skipIf(!hasDistWorker)('nested array object Method identity (#3041)', () => { + let repoRoot: string; + let result: PipelineResult; + + beforeAll(async () => { + repoRoot = writeFixture({ + 'src/nested-array.ts': `function helperA(value: number) { return value; } +function helperB(value: number) { return value; } +export const registry = { + groups: [[ + { handle(value: number) { return helperA(value); } }, + { handle(value: number) { return helperB(value); } }, + ]], +}; +`, + }); + result = await runPipelineFromRepo(repoRoot, () => undefined, { + skipGraphPhases: true, + }); + }, 60_000); + + afterAll(() => removeFixture(repoRoot)); + + it('position-qualifies shorthand methods through nested arrays and objects', () => { + const expectedIds = [ + generateId('Method', 'src/nested-array.ts:handle@4:6#1'), + generateId('Method', 'src/nested-array.ts:handle@5:6#1'), + ].sort(); + const nodeIds = new Set(); + result.graph.forEachNode((node) => nodeIds.add(node.id)); + const calls = getRelationships(result, 'CALLS') + .filter((edge) => edge.target === 'helperA' || edge.target === 'helperB') + .map((edge) => `${edge.rel.sourceId}->${edge.target}`) + .sort(); + const ownership = getRelationships(result, 'HAS_METHOD').filter( + (edge) => edge.target === 'handle', + ); + + expect(expectedIds.every((id) => nodeIds.has(id))).toBe(true); + expect(calls).toEqual([`${expectedIds[0]}->helperA`, `${expectedIds[1]}->helperB`].sort()); + expect(ownership).toEqual([]); + }); +}); + +describe.skipIf(!hasDistWorker)('object-literal callable durable cache (#3041)', () => { + it('replays owner-qualified handler identities and calls without workers', async () => { + const repoRoot = writeFixture({ + 'src/convex.ts': `function query(config: T): T { return config; } +function helperA(ctx: unknown) { return ctx; } +function helperB(ctx: unknown) { return ctx; } +export const first = query({ handler: (ctx: unknown) => helperA(ctx) }); +export const second = query({ handler: (ctx: unknown) => helperB(ctx) }); +`, + }); + const storage = fs.mkdtempSync(path.join(os.tmpdir(), 'gnx-objlit-cache-')); + try { + const coldCache: ParseCache = { + version: PARSE_CACHE_VERSION, + entries: new Map(), + usedKeys: new Set(), + storagePath: storage, + onDiskKeys: new Set(), + }; + const cold = await runPipelineFromRepo(repoRoot, () => undefined, { + skipGraphPhases: true, + parseCache: coldCache, + workerPoolSize: 1, + }); + pruneCache(coldCache, coldCache.usedKeys); + const savedKeys = await saveParseCache(storage, coldCache); + await pruneAndSaveDurableParsedFileStore( + getDurableParsedFileDir(storage), + PARSE_CACHE_VERSION, + new Set(savedKeys), + ); + const warmCache = await loadParseCache(storage); + const warm = await runPipelineFromRepo(repoRoot, () => undefined, { + skipGraphPhases: true, + parseCache: warmCache ?? undefined, + workerPoolSize: 1, + }); + + const project = (pipeline: PipelineResult) => + getRelationships(pipeline, 'CALLS') + .filter((edge) => edge.target === 'helperA' || edge.target === 'helperB') + .map((edge) => `${edge.rel.sourceId}->${edge.target}`) + .sort(); + expect(warm.usedWorkerPool).toBe(false); + expect(project(warm)).toEqual(project(cold)); + expect(project(warm)).toEqual( + [ + `${generateId('Function', 'src/convex.ts:first.handler')}->helperA`, + `${generateId('Function', 'src/convex.ts:second.handler')}->helperB`, + ].sort(), + ); + } finally { + removeFixture(repoRoot); + fs.rmSync(storage, { recursive: true, force: true }); + } + }, 120_000); +}); diff --git a/gitnexus/test/unit/call-summary-schema-version.test.ts b/gitnexus/test/unit/call-summary-schema-version.test.ts index da6648858..e92e5db77 100644 --- a/gitnexus/test/unit/call-summary-schema-version.test.ts +++ b/gitnexus/test/unit/call-summary-schema-version.test.ts @@ -128,7 +128,7 @@ describe('incremental reuse gate — schema fingerprint (U-C5, #2798)', () => { }); }); -describe('semantic (non-DDL) analyzer changes ride the runner-identity receipt (#2798)', () => { +describe('semantic and id-shape changes ride the runner-identity receipt (#2798/#3041)', () => { it('run-analyze.ts still forces a full rebuild when the stamped runner identity differs', () => { // The invariant the INCREMENTAL_SCHEMA_VERSION ladder used to backstop. It // is implicit nowhere else: no other gate observes analyzer code that emits diff --git a/gitnexus/test/unit/impact-batching-grouping.test.ts b/gitnexus/test/unit/impact-batching-grouping.test.ts index e600042de..8224cd1d3 100644 --- a/gitnexus/test/unit/impact-batching-grouping.test.ts +++ b/gitnexus/test/unit/impact-batching-grouping.test.ts @@ -324,4 +324,139 @@ describe('impact: batching and grouping', () => { // Cleanup env delete process.env.IMPACT_MAX_CHUNKS; }); + + it('caps implicit object-callable expansion and reports partial impact', async () => { + const backend = new LocalBackend(); + const repoHandle = { + id: 'repo-object-cap', + name: 'repo-object-cap', + repoPath: '/tmp/repo-object-cap', + storagePath: '/tmp/repo-object-cap/.gitnexus', + lbugPath: '/tmp/repo-object-cap/.gitnexus/lbug', + indexedAt: 'now', + lastCommit: 'c', + stats: {}, + } as any; + + executeParameterizedMock.mockImplementation(async (...args: any[]) => { + const query = String(args[1] ?? ''); + if ( + query.includes("hm.type = 'HAS_METHOD'") && + query.includes('member:Function') && + query.includes('member:Method') + ) { + return Array.from({ length: 5001 }, (_, i) => ({ + id: `member-${i}`, + name: `member${i}`, + type: i % 2 === 0 ? 'Function' : 'Method', + filePath: 'src/object.ts', + })); + } + return []; + }); + + const result = await (backend as any)._runImpactBFS( + repoHandle, + { id: 'owner', name: 'owner' }, + 'Const', + 'downstream', + { + maxDepth: 1, + relationTypes: ['CALLS'], + includeTests: false, + minConfidence: 0, + skipEpistemic: true, + skipEnrichment: true, + }, + ); + + const memberCall = executeParameterizedMock.mock.calls.find((args: any[]) => + String(args[1] ?? '').includes('member:Function'), + ); + const traversalCall = executeParameterizedMock.mock.calls.find((args: any[]) => + String(args[1] ?? '').includes('r.type IN $relTypes'), + ); + expect(String(memberCall?.[1])).toContain('RETURN DISTINCT member.id AS id'); + expect(String(memberCall?.[1])).toContain('member:Method'); + expect(String(memberCall?.[1])).toContain('UNION ALL'); + expect(String(memberCall?.[1])).toContain('ORDER BY id'); + expect(String(memberCall?.[1])).toContain('LIMIT 5001'); + expect(traversalCall?.[2]?.frontierIds).toEqual(['owner']); + expect(result.byDepth['1']).toHaveLength(5000); + expect(result.partial).toBe(true); + }); + + it('marks object impact partial when callable seeding fails', async () => { + const backend = new LocalBackend(); + const repoHandle = { + id: 'repo-object-seed-failure', + name: 'repo-object-seed-failure', + repoPath: '/tmp/repo-object-seed-failure', + storagePath: '/tmp/repo-object-seed-failure/.gitnexus', + lbugPath: '/tmp/repo-object-seed-failure/.gitnexus/lbug', + indexedAt: 'now', + lastCommit: 'c', + stats: {}, + } as any; + + executeParameterizedMock.mockImplementation(async (...args: any[]) => { + const query = String(args[1] ?? ''); + if (query.includes('member:Function')) throw new Error('seed unavailable'); + return []; + }); + + const result = await (backend as any)._runImpactBFS( + repoHandle, + { id: 'owner', name: 'owner' }, + 'Const', + 'downstream', + { + maxDepth: 1, + relationTypes: ['CALLS'], + includeTests: false, + minConfidence: 0, + skipEpistemic: true, + skipEnrichment: true, + }, + ); + + expect(result.partial).toBe(true); + }); + + it('marks class impact partial when structural seeding fails', async () => { + const backend = new LocalBackend(); + const repoHandle = { + id: 'repo-class-seed-failure', + name: 'repo-class-seed-failure', + repoPath: '/tmp/repo-class-seed-failure', + storagePath: '/tmp/repo-class-seed-failure/.gitnexus', + lbugPath: '/tmp/repo-class-seed-failure/.gitnexus/lbug', + indexedAt: 'now', + lastCommit: 'c', + stats: {}, + } as any; + + executeParameterizedMock.mockImplementation(async (...args: any[]) => { + const query = String(args[1] ?? ''); + if (query.includes('(c:Constructor)')) throw new Error('seed unavailable'); + return []; + }); + + const result = await (backend as any)._runImpactBFS( + repoHandle, + { id: 'class-owner', name: 'Owner' }, + 'Class', + 'downstream', + { + maxDepth: 1, + relationTypes: ['CALLS'], + includeTests: false, + minConfidence: 0, + skipEpistemic: true, + skipEnrichment: true, + }, + ); + + expect(result.partial).toBe(true); + }); }); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index 57a76437b..89b840feb 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -230,14 +230,14 @@ describe('PARSE_CACHE_VERSION', () => { // replayed pre-feature captures and the feature was inert. 71 is the next // free value above every claim at this merge — origin/main is 70 and open // PR #3017 already claims 71, so 71 would have collided. - it('pins SCHEMA_BUMP to 74 so concurrent bumps cannot silently collide (#2766)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(74); + it('pins SCHEMA_BUMP to 76 so v74 caches cannot retain pre-#3041 identities', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(76); // The PREVIOUS version must fail the reuse gate, not merely differ from the // current one — a hardcoded number outside the conflict hunk rebases cleanly // while being wrong, which is exactly how the 37/38 exact clashes landed. // Every nearby historical or in-flight value is rejected, including 69, // which carried the route-table payload before this merge. - for (const taken of [59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73]) { + for (const taken of [59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75]) { expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken); } }); diff --git a/gitnexus/test/unit/scope-resolution/node-lookup-determinism.test.ts b/gitnexus/test/unit/scope-resolution/node-lookup-determinism.test.ts index 031de0870..bb5900cd3 100644 --- a/gitnexus/test/unit/scope-resolution/node-lookup-determinism.test.ts +++ b/gitnexus/test/unit/scope-resolution/node-lookup-determinism.test.ts @@ -20,6 +20,7 @@ interface Candidate { name?: string; qualifiedName?: string; startLine?: number; + startColumn?: number; } function buildLookup(candidates: readonly Candidate[]) { @@ -34,6 +35,7 @@ function buildLookup(candidates: readonly Candidate[]) { qualifiedName: candidate.qualifiedName ?? 'Service.save', filePath: FILE, ...(candidate.startLine !== undefined ? { startLine: candidate.startLine } : {}), + ...(candidate.startColumn !== undefined ? { startColumn: candidate.startColumn } : {}), }, }) satisfies ParseWorkerResult['nodes'][number], ); @@ -96,6 +98,73 @@ describe('parse-result graph insertion determinism', () => { expect(lookup.get(simpleKey(FILE, 'save'))).toBe(first.id); }); + it('uses exact columns to distinguish same-line owner-qualified callables', () => { + const first = { + id: `Function:${FILE}:first.handler`, + label: 'Function' as const, + name: 'handler', + qualifiedName: 'first.handler', + startLine: 4, + startColumn: 24, + }; + const second = { + id: `Function:${FILE}:second.handler`, + label: 'Function' as const, + name: 'handler', + qualifiedName: 'second.handler', + startLine: 4, + startColumn: 73, + }; + const lookup = buildLookup([second, first]); + + expect( + resolveDefGraphId( + FILE, + { + nodeId: `def:${FILE}#5:24:Function:handler`, + type: 'Function', + qualifiedName: 'handler', + }, + lookup, + ), + ).toBe(first.id); + expect( + resolveDefGraphId( + FILE, + { + nodeId: `def:${FILE}#5:73:Function:handler`, + type: 'Function', + qualifiedName: 'handler', + }, + lookup, + ), + ).toBe(second.id); + }); + + it('uses exact position before parsing dotted member names as qualifiers', () => { + const dotted = { + id: `Function:${FILE}:service.q.r`, + label: 'Function' as const, + name: 'q.r', + qualifiedName: 'service.q.r', + startLine: 8, + startColumn: 31, + }; + const lookup = buildLookup([dotted]); + + expect( + resolveDefGraphId( + FILE, + { + nodeId: `def:${FILE}#9:31:Function:q.r`, + type: 'Function', + qualifiedName: 'q.r', + }, + lookup, + ), + ).toBe(dotted.id); + }); + it('resolves a Record definition to its Record node instead of a same-named fallback', () => { const record = { id: `Record:${FILE}:Person`, From 48106d3c00f3807413d7761f37cd4535d3cfff56 Mon Sep 17 00:00:00 2001 From: DuduPhudu <34869259+ReidenXerx@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:35:36 +0300 Subject: [PATCH 06/17] fix(ingestion): index NestJS decorator routes so api_impact and route_map stop reporting live endpoints as non-existent (#3017) --- ARCHITECTURE.md | 2 +- .../group/extractors/http-patterns/node.ts | 200 +---- .../core/ingestion/languages/typescript.ts | 2 + .../core/ingestion/pipeline-phases/routes.ts | 76 +- .../route-extractors/data-route-table.ts | 16 +- .../core/ingestion/route-extractors/nest.ts | 548 +++++++++++++ gitnexus/src/storage/parse-cache.ts | 65 +- .../api/widgetsController.ts | 26 + .../src/health/health.controller.ts | 14 + .../src/legacy/legacy.controller.ts | 16 + .../src/venues/venues.controller.ts | 35 + .../src/venues/venues.service.ts | 34 + .../multi-verb-route-identity.test.ts | 52 ++ .../integration/nest-route-pipeline.test.ts | 216 +++++ .../test/unit/group/nest-route-parity.test.ts | 289 +++++++ .../test/unit/incremental-parse-cache.test.ts | 32 +- .../test/unit/nest-decorator-routes.test.ts | 738 ++++++++++++++++++ 17 files changed, 2160 insertions(+), 201 deletions(-) create mode 100644 gitnexus/src/core/ingestion/route-extractors/nest.ts create mode 100644 gitnexus/test/fixtures/multi-verb-route-app/api/widgetsController.ts create mode 100644 gitnexus/test/fixtures/nest-route-app/src/health/health.controller.ts create mode 100644 gitnexus/test/fixtures/nest-route-app/src/legacy/legacy.controller.ts create mode 100644 gitnexus/test/fixtures/nest-route-app/src/venues/venues.controller.ts create mode 100644 gitnexus/test/fixtures/nest-route-app/src/venues/venues.service.ts create mode 100644 gitnexus/test/integration/nest-route-pipeline.test.ts create mode 100644 gitnexus/test/unit/group/nest-route-parity.test.ts create mode 100644 gitnexus/test/unit/nest-decorator-routes.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7ae30a56b..6fe547b70 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -174,7 +174,7 @@ converging on the routes phase's `(method, url)` registry: | Filesystem convention | path → URL, no parsing | Next.js `app/`, Expo, PHP | | Single-file framework route | `isRouteFile` + worker extraction | Laravel `routes/*.php` | | Cross-file framework route | `discoverRootRouteFiles` + `extractRoutes` | Django `urlpatterns` | -| AST-level route in a normal file | `extractDecoratorRoutes` | Spring, FastAPI, NestJS, **JS/TS dispatch guards and static data route tables** | +| AST-level route in a normal file | `extractDecoratorRoutes` | Spring, FastAPI, NestJS (`@Controller` + `@Get`/`@Post`/…; URLs are controller-relative — `setGlobalPrefix` and URI versioning live in the bootstrap file and are not applied), **JS/TS dispatch guards and static data route tables** | The last row is the one whose name undersells it. A route is DECLARED by a decorator, but it can also be **inferred** from a raw `node:http` server's own diff --git a/gitnexus/src/core/group/extractors/http-patterns/node.ts b/gitnexus/src/core/group/extractors/http-patterns/node.ts index fa7c453df..7a198f595 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/node.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/node.ts @@ -15,6 +15,8 @@ import { DATA_ROUTE_TABLE_SOURCE, scanDataRouteTables, } from '../../../ingestion/route-extractors/data-route-table.js'; +import { extractNestRoutes } from '../../../ingestion/route-extractors/nest.js'; +import { normalizeExtractedRoutePath } from '../../../ingestion/route-extractors/route-path.js'; import { buildJsRepoFacts, extractJsModuleFacts, @@ -27,7 +29,8 @@ import { /** * Node.js / TypeScript HTTP plugin family. Handles: - * - NestJS `@Controller('prefix')` classes with `@Get(':id')` methods + * - NestJS `@Controller('prefix')` classes with `@Get(':id')` methods, + * delegated wholesale to the indexer's `extractNestRoutes` * - Express `router.get(...)` / `app.post(...)` providers * - `fetch(url)` / `fetch(url, { method: 'POST' })` consumers * - `axios.get(url)` / `axios.delete(url)` consumers @@ -42,34 +45,8 @@ import { * same `scan` function but bind to different grammars. */ -// ─── Provider: NestJS — class-level @Controller('prefix') ──────────── -// In tree-sitter-typescript decorators are NOT children of -// class_declaration / method_definition — they're siblings in the -// surrounding class_body / program node. We therefore match the -// decorator standalone and walk to its related class/method in JS. -const NEST_CONTROLLER_SPEC: PatternSpec> = { - meta: {}, - query: ` - (decorator - (call_expression - function: (identifier) @dec (#eq? @dec "Controller") - arguments: (arguments . [(string) (template_string)] @prefix))) @ctrl_decorator - `, -}; - -// ─── Provider: NestJS — method-level @Get/@Post/... decorators ─────── -// Matches either `@Get('path')` or `@Get()`. The `@path` capture is -// optional — when the first argument isn't a string, the plugin falls -// back to '/' for the method-level path. -const NEST_METHOD_SPEC: PatternSpec> = { - meta: {}, - query: ` - (decorator - (call_expression - function: (identifier) @dec (#match? @dec "^(Get|Post|Put|Delete|Patch)$") - arguments: (arguments) @args)) @method_decorator - `, -}; +// NestJS providers are not queried here at all — see the `extractNestRoutes` +// call in `scanBundle`. // ─── Provider: Express — router.get/app.post/... ───────────────────── const EXPRESS_SPEC: PatternSpec> = { @@ -176,8 +153,6 @@ const AXIOS_OBJECT_SPEC: PatternSpec> = { }; interface NodePatternBundle { - controller: CompiledPatterns>; - methodDecorator: CompiledPatterns>; express: CompiledPatterns>; fetchNoOptions: CompiledPatterns>; fetchWithOptions: CompiledPatterns>; @@ -195,8 +170,6 @@ function compileBundle(language: unknown, name: string): NodePatternBundle { patterns: [spec], } satisfies LanguagePatterns>); return { - controller: mk(NEST_CONTROLLER_SPEC, 'nest-controller'), - methodDecorator: mk(NEST_METHOD_SPEC, 'nest-method-decorator'), express: mk(EXPRESS_SPEC, 'express'), fetchNoOptions: mk(FETCH_NO_OPTIONS_SPEC, 'fetch-no-options'), fetchWithOptions: mk(FETCH_WITH_OPTIONS_SPEC, 'fetch-with-options'), @@ -211,33 +184,6 @@ const JAVASCRIPT_BUNDLE = compileBundle(JavaScript, 'javascript-http'); const TYPESCRIPT_BUNDLE = compileBundle(TypeScript.typescript, 'typescript-http'); const TSX_BUNDLE = compileBundle(TypeScript.tsx, 'tsx-http'); -const NEST_DECORATOR_TO_HTTP: Record = { - Get: 'GET', - Post: 'POST', - Put: 'PUT', - Delete: 'DELETE', - Patch: 'PATCH', -}; - -/** - * Find the nearest enclosing class_declaration for a node, or null. - */ -function findEnclosingClass(node: Parser.SyntaxNode): Parser.SyntaxNode | null { - let cur: Parser.SyntaxNode | null = node.parent; - while (cur) { - if (cur.type === 'class_declaration') return cur; - cur = cur.parent; - } - return null; -} - -function joinPath(prefix: string, sub: string): string { - const cleanPrefix = prefix.replace(/^\/+/, '').replace(/\/+$/, ''); - const cleanSub = sub.replace(/^\/+/, ''); - if (!cleanPrefix) return `/${cleanSub}`; - return `/${cleanPrefix}/${cleanSub}`; -} - /** * Walk `pair` children of an `object` literal and return the unquoted * string/template_string value for the first pair whose key matches one @@ -260,68 +206,6 @@ function readStringProp(objectNode: Parser.SyntaxNode, keyNames: readonly string return null; } -/** - * For a standalone `decorator` node (child of class_body / program), - * find the related `class_declaration` node that it decorates. In - * tree-sitter-typescript the decorator is placed before the class - * declaration as a sibling (when decorating a class) or inside the - * class_body before a method_definition (when decorating a method); - * we walk the parent chain until we find the enclosing class. - */ -function findDecoratedClass(decoratorNode: Parser.SyntaxNode): Parser.SyntaxNode | null { - const parent = decoratorNode.parent; - if (!parent) return null; - // Case 1: decorator is a sibling of the class_declaration at program / - // export_statement level. Walk forward through siblings until we find - // the class_declaration this decorator belongs to. - for (let i = 0; i < parent.namedChildCount; i++) { - const child = parent.namedChild(i); - if (child && child.id === decoratorNode.id) { - for (let j = i + 1; j < parent.namedChildCount; j++) { - const next = parent.namedChild(j); - if (!next) continue; - if (next.type === 'decorator') continue; // adjacent decorators stack - if (next.type === 'class_declaration') return next; - if (next.type === 'export_statement') { - // `export class Foo { ... }` wraps the declaration. - for (let k = 0; k < next.namedChildCount; k++) { - const inner = next.namedChild(k); - if (inner?.type === 'class_declaration') return inner; - } - } - break; - } - break; - } - } - // Case 2: decorator is inside a class_body (decorating a method) — - // walk up to the enclosing class_declaration. - return findEnclosingClass(decoratorNode); -} - -/** - * For a method-level decorator node (child of class_body before a - * method_definition), find the method_definition it decorates. - */ -function findDecoratedMethod(decoratorNode: Parser.SyntaxNode): Parser.SyntaxNode | null { - const parent = decoratorNode.parent; - if (!parent || parent.type !== 'class_body') return null; - for (let i = 0; i < parent.namedChildCount; i++) { - const child = parent.namedChild(i); - if (child && child.id === decoratorNode.id) { - for (let j = i + 1; j < parent.namedChildCount; j++) { - const next = parent.namedChild(j); - if (!next) continue; - if (next.type === 'decorator') continue; - if (next.type === 'method_definition') return next; - return null; - } - return null; - } - } - return null; -} - /** * Map each named import's LOCAL binding to its DECLARED export name and source * module, by walking the file's `import { x as y } from 'm'` statements. Lets @@ -589,57 +473,33 @@ function scanBundle( // symbol resolves to the real definition rather than its local alias text. const importMap = buildImportMap(tree); - // NestJS: collect `@Controller('prefix')` class decorators, keyed by - // the `class_declaration` they decorate. - const prefixByClassId = new Map(); - for (const match of runCompiledPatterns(bundle.controller, tree)) { - const prefixNode = match.captures.prefix; - const decoratorNode = match.captures.ctrl_decorator; - if (!prefixNode || !decoratorNode) continue; - const prefix = unquoteLiteral(prefixNode.text); - if (prefix === null) continue; - const classNode = findDecoratedClass(decoratorNode); - if (!classNode) continue; - prefixByClassId.set(classNode.id, prefix); - } - - // NestJS: method-level @Get/@Post/... decorators. The decorator's - // arguments list may be empty (`@Get()`), a string (`@Get('path')`), - // or something else (which we skip). - for (const match of runCompiledPatterns(bundle.methodDecorator, tree)) { - const decNode = match.captures.dec; - const argsNode = match.captures.args; - const decoratorNode = match.captures.method_decorator; - if (!decNode || !argsNode || !decoratorNode) continue; - const httpMethod = NEST_DECORATOR_TO_HTTP[decNode.text]; - if (!httpMethod) continue; - const methodNode = findDecoratedMethod(decoratorNode); - if (!methodNode) continue; - const enclosingClass = findEnclosingClass(methodNode); - // Only emit NestJS detections when the class actually has a - // @Controller decorator — without it, the match is almost certainly - // something else (e.g. an unrelated library using similar names). - if (!enclosingClass || !prefixByClassId.has(enclosingClass.id)) continue; - const prefix = prefixByClassId.get(enclosingClass.id) ?? ''; - - let rawPath = '/'; - const firstArg = argsNode.namedChild(0); - if (firstArg && (firstArg.type === 'string' || firstArg.type === 'template_string')) { - const unquoted = unquoteLiteral(firstArg.text); - if (unquoted !== null) rawPath = unquoted; - } - - // Get the method name from the decorated method_definition. - const methodNameNode = methodNode.childForFieldName('name'); - const name = methodNameNode?.text ?? null; - + // NestJS: delegated to the indexer's extractor rather than re-queried here. + // Two independent readings of the same decorators is how the layers drift: + // the local scan saw only `class_declaration` (never `abstract class`), only + // five of the nine verbs, only a positional string `@Controller('x')`, and + // — worst — INVENTED `/` for a method path it could not read, so + // `@Get(ROUTES.SEARCH)` became a `GET /venues` contract that the graph, which + // correctly drops it, has no Route node for. "A missing route is a coverage + // limit; an invented one is a lie" (ARCHITECTURE.md). Calling the extractor + // makes that divergence structurally impossible, exactly as the + // `scanDataRouteTables` call below already does for static route tables. + // + // `filePath` rides only on the returned struct and never reaches the + // `HttpDetection`, so a bare `scan(tree)` with no `fileRel` passes '' rather + // than losing the routes. `lineOffset` is 0: the group scanner parses whole + // files, so `lineNumber` is already the absolute 1-based line this + // `HttpDetection.line` wants. + for (const route of extractNestRoutes(tree, fileRel ?? '', 0)) { out.push({ role: 'provider', framework: 'nest', - method: httpMethod, - path: joinPath(prefix, rawPath), - name, - line: methodNode.startPosition.row + 1, + method: route.httpMethod, + // The prefix travels separately at the ingestion layer, so the join is + // ours to do — with ingestion's own joiner, so the two layers cannot + // disagree about the URL either. + path: normalizeExtractedRoutePath(route.routePath, route.prefix ?? null), + name: route.handlerName ?? null, + line: route.lineNumber, confidence: 0.8, }); } diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index e6106df69..4cf95f9c9 100644 --- a/gitnexus/src/core/ingestion/languages/typescript.ts +++ b/gitnexus/src/core/ingestion/languages/typescript.ts @@ -126,11 +126,13 @@ import { } from './javascript/index.js'; 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) => [ ...extractDispatchGuardRoutes(...args), ...extractDataRouteTableRoutes(...args), + ...extractNestRoutes(...args), ]; /** diff --git a/gitnexus/src/core/ingestion/pipeline-phases/routes.ts b/gitnexus/src/core/ingestion/pipeline-phases/routes.ts index 8115f5b40..e93fa033c 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/routes.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/routes.ts @@ -195,6 +195,45 @@ export const routesPhase: PipelinePhase = { const allFetchCalls = [...parseFetchCalls]; const routeRegistry = new Map(); + /** + * Registry keys written straight from the file list below, never through + * `addRoute`. `resolveRouteHandlerSymbols` walks only `extractedRoutes` and + * `decoratorRoutes`, so it never sees these URLs and its `claimed` set never + * contains them — which means a handler stamped on one of these keys was + * resolved for a DIFFERENT route. + * + * That is reachable, and it fabricates rather than omits (#3049). A + * method-agnostic route (`@All`, a Django function view, a verb-less + * dispatch guard) keys by URL alone via `routeNodeKey`, so it collides with + * a file-convention route at the same URL. It claims the key unopposed in + * `claim()`, then loses first-writer-wins here in `addRoute` and is dropped + * as a duplicate — and without this guard the surviving file-convention node + * would read that handler and present another application's controller + * method as its own. `api_impact` is documented to be run BEFORE editing a + * route handler, so it would answer with a handler from the wrong app. + * + * Dropping the losing route is a separate and deliberate consequence of + * URL-only identity; this only stops the false attribution. + * + * Membership is recorded AT the pre-seeding `set`, mirroring `claim()` in + * call-processor.ts, which writes `claimed` and its result map together + * rather than re-deriving either by rescanning. Identifying pre-seeded + * entries by matching `entry.source` against a list of source strings would + * spell them a second time, away from the sites that produce them — and a + * fourth pre-seeded source added later would then reopen #3049 in silence. + * `addRoute` deliberately does NOT record here: its routes ARE + * handler-resolved, so suppressing them would widen the guard into a bug of + * its own. + * + * Each `add` below sits inside its own `!routeRegistry.has(key)` gate, as + * every `routeRegistry.set` in this phase does: the map is write-once per + * key, so a losing candidate cannot record a key it did not claim and no + * later writer can take a recorded key away. Key-membership is therefore + * equivalent to source-matching by construction — pre-seeded routes carry no + * verb and `routeNodeKey(undefined, url) === url` — which is why the + * two-candidates-one-URL case needs no fixture to settle it. + */ + const preSeededKeys = new Set(); // Detect Expo Router app/ roots vs Next.js app/ roots (monorepo-safe) const expoAppRoots = new Set(); @@ -217,32 +256,33 @@ export const routesPhase: PipelinePhase = { } } + // One writer for every pre-seeded route, so recording membership cannot be + // forgotten. Inlining `has` / `set` / `add` at each site made the invariant + // a convention three call sites had to remember — and a fourth source that + // forgot the `add` would reopen #3049 exactly as silently as the source-set + // it replaced. This is the shape `claim()` in call-processor.ts uses for the + // same reason: one helper writes the collection and its key set together. + const preSeed = (url: string, entry: Omit): boolean => { + if (routeRegistry.has(url)) return false; + routeRegistry.set(url, { ...entry, url }); + preSeededKeys.add(url); + return true; + }; + for (const p of allPaths) { if (expoAppPaths.has(p)) { const expoURL = expoFileToRouteURL(p); - if (expoURL && !routeRegistry.has(expoURL)) { - routeRegistry.set(expoURL, { - filePath: p, - source: 'expo-filesystem-route', - url: expoURL, - }); + if (expoURL && preSeed(expoURL, { filePath: p, source: 'expo-filesystem-route' })) { continue; } } const nextjsURL = nextjsFileToRouteURL(p); - if (nextjsURL && !routeRegistry.has(nextjsURL)) { - routeRegistry.set(nextjsURL, { - filePath: p, - source: 'nextjs-filesystem-route', - url: nextjsURL, - }); + if (nextjsURL && preSeed(nextjsURL, { filePath: p, source: 'nextjs-filesystem-route' })) { continue; } if (p.endsWith('.php')) { const phpURL = phpFileToRouteURL(p); - if (phpURL && !routeRegistry.has(phpURL)) { - routeRegistry.set(phpURL, { filePath: p, source: 'php-file-route', url: phpURL }); - } + if (phpURL) preSeed(phpURL, { filePath: p, source: 'php-file-route' }); } } @@ -311,7 +351,11 @@ export const routesPhase: PipelinePhase = { const { source: routeSource, method: routeMethod, url } = entry; const handlerPath = handlerPathFor(routeKey, entry); const content = handlerContents.get(handlerPath); - const handlerSymbolId = routeHandlerSymbols.get(routeKey); + // A pre-seeded route can never legitimately appear in + // `routeHandlerSymbols`, so a key that does is a route that LOST (#3049). + const handlerSymbolId = preSeededKeys.has(routeKey) + ? undefined + : routeHandlerSymbols.get(routeKey); const analysisContent = entry.source === DATA_ROUTE_TABLE_SOURCE && content ? handlerSymbolContent( diff --git a/gitnexus/src/core/ingestion/route-extractors/data-route-table.ts b/gitnexus/src/core/ingestion/route-extractors/data-route-table.ts index 053d207fe..bcc879e3c 100644 --- a/gitnexus/src/core/ingestion/route-extractors/data-route-table.ts +++ b/gitnexus/src/core/ingestion/route-extractors/data-route-table.ts @@ -116,7 +116,14 @@ function decodeJavaScriptStringLiteral(raw: string): string | null { return decoded; } -function plainString(node: SyntaxNode): string | null { +/** + * A `string`/`template_string` node's decoded value, or `null` when it is not a + * readable literal (an interpolated template, an unterminated escape). Shared + * with the NestJS extractor so both agree on what a readable literal is — + * notably that escapes must be DECODED, not dropped, because tree-sitter splits + * a literal around every `escape_sequence`. + */ +export function plainString(node: SyntaxNode): string | null { if (node.type === 'string') return decodeJavaScriptStringLiteral(node.text); if ( node.type === 'template_string' && @@ -129,7 +136,12 @@ function plainString(node: SyntaxNode): string | null { return null; } -function propertyName(node: SyntaxNode): string | null { +/** + * A property key's name, for the spellings that carry one — `{ path: … }` and + * `{ 'path': … }`. A computed key (`{ [KEY]: … }`) has none. Shared with the + * NestJS extractor, which reads `@Controller({ path: … })` the same way. + */ +export function propertyName(node: SyntaxNode): string | null { if (node.type === 'identifier' || node.type === 'property_identifier') return node.text; if (node.type === 'string') return plainString(node); return null; diff --git a/gitnexus/src/core/ingestion/route-extractors/nest.ts b/gitnexus/src/core/ingestion/route-extractors/nest.ts new file mode 100644 index 000000000..136c6354a --- /dev/null +++ b/gitnexus/src/core/ingestion/route-extractors/nest.ts @@ -0,0 +1,548 @@ +/** + * NestJS decorator routes for the indexer. + * + * A NestJS endpoint is declared across two decorators: `@Controller('venues')` + * on the class supplies the prefix, and `@Get('search')` on a method supplies + * the verb and the remainder. Neither half is a route on its own, which is why + * a pattern that only looks at one of them finds nothing. + * + * Until this existed, TypeScript's `extractDecoratorRoutes` hook was dispatch + * guards plus static data route tables only, so a NestJS repo produced + * essentially no `Route` nodes. That is not a quiet gap: `route_map`, + * `api_impact` and `shape_check` all read `Route` nodes and answer "no routes + * matching …" when there are none — so `api_impact`, whose documented job is to + * be run BEFORE modifying a route handler, reported every live endpoint as + * non-existent, and a not-found reads as a safe change (#3009). + * + * The extraction mirrors `spring.ts`, which solves the identical shape for + * `@RequestMapping` + `@GetMapping`: collect class-level prefixes keyed by class + * node id, then walk method decorators and attach the prefix of their enclosing + * class. As there, the prefix travels on `ExtractedDecoratorRoute.prefix` and + * the routes phase performs the join via `normalizeExtractedRoutePath`, so + * NestJS routes are keyed identically to every other framework's. + * + * The multi-path form `@Get(['a', 'b'])` mounts the handler at BOTH paths, so + * it emits both routes: N paths is N elements of the returned + * `ExtractedDecoratorRoute[]`, which is already how this layer spells N routes + * — the same representation `spring.ts` reaches for `@GetMapping({"/a","/b"})`, + * and the reason neither needs a special case downstream. The CLASS-level array + * (`@Controller(['a', 'b'])`) is DECLINED rather than cross-multiplied over the + * class's methods, again matching `spring.ts`: there an array-form class prefix + * only ever suppresses the class, with the cross-product tracked in #2280. + * + * Known limitation: the URLs produced here are CONTROLLER-RELATIVE. A global + * prefix (`app.setGlobalPrefix('api')`) and URI versioning are applied by the + * bootstrap file, not by any decorator this file can see, so neither is + * reflected — a route served at `/api/v1/venues/search` is stored as + * `/venues/search`. The module's "drop rather than guess" floor is unavailable + * for it: the evidence lives in a different file, so honouring it would mean + * dropping every Nest route in every repo. `spring.ts` has the same hole for + * `server.servlet.context-path`; `ExtractedDecoratorRoute.prefix` is the + * channel a cross-file follow-up would use, the way FastAPI resolves its mount. + */ + +import type Parser from 'tree-sitter'; +import type { ExtractedDecoratorRoute } from '../workers/parse-worker.js'; +import { plainString, propertyName } from './data-route-table.js'; +import { isDev } from '../utils/env.js'; +import { logger } from '../../logger.js'; + +/** + * NestJS method decorators → HTTP verb. A Map rather than an object literal + * because the lookup key is an arbitrary decorator name read out of source: a + * plain object answers `@toString()` with `Object.prototype.toString`, which is + * truthy and would be emitted verbatim as the route's httpMethod. + */ +const NEST_METHOD_DECORATORS: ReadonlyMap = new Map([ + ['Get', 'GET'], + ['Post', 'POST'], + ['Put', 'PUT'], + ['Patch', 'PATCH'], + ['Delete', 'DELETE'], + ['Head', 'HEAD'], + ['Options', 'OPTIONS'], + ['All', '*'], + // `@Sse` mounts a real GET endpoint that streams; it is as much a route as + // `@Get`. `@Search` is deliberately absent — `normalizeRouteMethod` rejects + // SEARCH as non-standard and would key the route by URL alone, colliding + // with every other verb on that path. + ['Sse', 'GET'], +]); + +/** + * Class node types that can carry a `@Controller`. `export abstract class C` + * parses as `abstract_class_declaration`, a DIFFERENT node type — and a + * decorated abstract base sharing CRUD routes with its subclasses is ordinary + * Nest, so matching `class_declaration` alone silently drops the whole + * controller rather than one route. + */ +const CLASS_DECLARATION_TYPES: ReadonlySet = new Set([ + 'class_declaration', + 'abstract_class_declaration', +]); + +/** + * Cheap parse-free gate. Every JS/TS file in every repo reaches this hook, so + * skip the walk unless the file could plausibly declare a controller. A file + * without the substring cannot produce a route here, because a `@Controller` + * decorator is REQUIRED before any method decorator is believed (see below). + */ +const CONTROLLER_HINT = '@Controller'; + +/** The decorator's name — `Controller` for `@Controller('x')`, `Get` for `@Get()`. */ +function decoratorName(decorator: Parser.SyntaxNode): string | null { + const inner = decorator.namedChild(0); + if (!inner) return null; + // `@Get()` is a call_expression; a bare `@Injectable` is a plain identifier. + if (inner.type === 'identifier') return inner.text; + if (inner.type === 'call_expression') { + const fn = inner.childForFieldName('function'); + return fn?.type === 'identifier' ? fn.text : null; + } + return null; +} + +/** + * The literal path(s) a decorator call mounts, one entry per path — or `['']` + * when the decorator takes no argument (`@Controller()` / `@Get()` — both legal + * and both meaning "no path segment of my own"). + * + * A list rather than a single string because `@Get(['a', 'b'])` mounts the + * handler at two URLs, and two routes is what the caller's output contract + * already says that in: `ExtractedDecoratorRoute[]`. No new field, and no + * special case at the emit site — the same shape `spring.ts` gets for free from + * a query that matches one element at a time. + * + * Returns `null` when an argument IS present but is not a readable literal. + * That is deliberately distinct from `['']`: a computed prefix + * (`@Controller(ROUTES.VENUES)`) whose value we cannot read must drop the route + * rather than silently mount it at the wrong URL. `route_map` presents its + * output as fact, and a wrong path is worse than a missing one. `[]` is a third + * answer and means neither of those: `@Get([])` is legal, knowably mounts + * nothing, and so emits nothing — it must never be read as the unknowable case, + * which is the one that suppresses a whole controller. + * + * Reading one literal is delegated to `plainString`, the same judge the + * data-route-table extractor uses, so both agree on what is readable. Filtering + * `string_fragment` children and joining them looks equivalent and is not: + * tree-sitter SPLITS a literal around each `escape_sequence`, and the join then + * DELETES the escape rather than decoding it. `@Get(':id(\\d+)')` — the ordinary + * spelling of a Nest regex param, whose value is `:id(\d+)` — came out as + * `:id(d+)`, and `@Get('/v\u0069ews')` came out as `/vews`. Both are paths the + * app never serves, i.e. the wrong-URL outcome the paragraph above forbids. + */ +function decoratorLiteralPaths(decorator: Parser.SyntaxNode): readonly string[] | null { + const call = decorator.namedChild(0); + // A Nest route decorator is a FACTORY: `@Get()` invokes it and returns the + // decorator that registers the route. A BARE `@Get` is the factory itself, + // never applied, so Nest registers nothing — emitting a route for it would + // publish a URL the app does not serve. The same holds one level up for a + // bare `@Controller`, which registers no controller. + // + // `@Get()` with no ARGUMENT is different and still a real pathless route: + // what distinguishes them is the call, not the argument list. That case falls + // through to the `!first` branch below. + if (call?.type !== 'call_expression') return null; + const first = call.childForFieldName('arguments')?.namedChild(0); + if (!first) return ['']; + // The object form belongs to `@Controller` alone — a verb decorator takes + // `string | string[]`, so Nest mounts nothing for `@Get({ path: 'a' })`. + // Reading it as a route would mint a URL the app never serves, which is the + // invented fact this module refuses; an unreadable shape drops instead. + if (first.type === 'object' && decoratorName(decorator) !== 'Controller') return null; + return literalPaths(first); +} + +/** + * The paths carried by one decorator ARGUMENT node, split out from + * {@link decoratorLiteralPaths} only so the object form can re-enter it: Nest + * accepts an array inside `{ path: … }` as well, and reusing the same judge is + * what keeps `@Controller({ path: ['a', 'b'] })` from being read by a second, + * laxer set of rules that has drifted from this one. + */ +function literalPaths(node: Parser.SyntaxNode): readonly string[] | null { + // `@Controller({ path: 'cats', version: '1' })` is the documented form for + // URI/header versioning, and its path is a plain literal sitting right there. + // Worth reading rather than dropping, because the asymmetry is severe: an + // unreadable METHOD path costs one route, an unreadable PREFIX costs every + // route on the class. + if (node.type === 'object') { + // But a `path` pair only PROVES the mount when nothing else in the object + // can replace it, and the first match proves nothing on its own: + // `{ path: 'cats', ...options }` mounts wherever `options.path` says, and + // `{ path: 'cats', path: 'dogs' }` mounts at `dogs` — last write wins in + // both. Either one publishes `/cats`, a URL the app never serves, and it + // looks exactly like a correct one, which is the wrong-answer-dressed-as- + // fact this module refuses. So the object is read only when EVERY member is + // a named, non-repeated pair. That whole-entry fail-closed walk is the + // shape `routeFromObject` uses in `data-route-table.ts`. + const values = new Map(); + for (const child of node.namedChildren) { + // Skipped FIRST. A comment between two pairs is ordinary formatting; run + // through the not-a-pair test below it would refuse the object and cost + // the class every route it has, over a comment. + if (child.type === 'comment') continue; + // `spread_element` (`{ ...options }`), `shorthand_property_identifier` + // (`{ path }`) and `method_definition` (`{ getFoo() {} }`) all land here + // — probed and identical across the three grammars this extractor runs + // under. None offers a key/value this file can read, and the first can + // introduce or overwrite `path` from a value declared elsewhere. + if (child.type !== 'pair') return null; + const key = child.childForFieldName('key'); + const value = child.childForFieldName('value'); + if (key === null || value === null) return null; + // Compared through `propertyName`, the same judge used to READ the key — + // so `{ path: … }` and `{ 'path': … }` are one key and collide as + // duplicates. Comparing raw key text instead makes them two distinct + // keys, and `{ path: 'cats', 'path': 'dogs' }` silently mounts the loser. + const name = propertyName(key); + // No readable name means a computed key (`{ [dynamicKey]: 'b' }`), which + // could evaluate to `path` and take the mount with it — refused, not + // ignored. A repeated key is refused wherever it appears, not only on + // `path`: a duplicate anywhere is evidence the object is not the fixed + // literal it reads as, and cost is one controller against a wrong URL. + if (name === null || values.has(name)) return null; + values.set(name, value); + } + + // Deliberately NOT `containsExecutingExpression` (data-route-table.ts): that + // guards whole-entry declarativeness for a static route table, a different + // invariant. Here only `path` has to be provable, so a non-literal value on + // an unrelated key — `{ path: 'a', scope: Scope.REQUEST }`, ordinary Nest — + // stays benign and keeps its controller. + const path = values.get('path'); + // A missing `path` keeps the existing drop and must never read as `''`: + // `@Controller({ version: '1' })` mounts at a prefix this decorator does + // not state, and `''` would publish every one of its methods at the root. + return path === undefined ? null : literalPaths(path); + } + + // `array` is the node type in all three grammars this extractor runs under — + // tree-sitter-typescript's `typescript` and `tsx`, and tree-sitter-javascript + // — probed rather than assumed, because a name that differs in one of them + // would silently restore the old drop for that grammar alone. + if (node.type === 'array') { + const paths: string[] = []; + for (const element of node.namedChildren) { + const value = plainString(element); + // One unreadable element poisons the whole array. Emitting the readable + // ones would present a partial mapping as a complete one — the endpoint + // behind `ROUTES.ADMIN` would be missing from a controller that otherwise + // looks fully covered, which is the same wrong-answer-dressed-as-fact this + // module refuses above, only harder to notice. + if (value === null) return null; + paths.push(value); + } + return paths; + } + + const value = plainString(node); + return value === null ? null : [value]; +} + +/** + * Decorators that immediately precede `node` among its parent's named children. + * In tree-sitter-typescript a decorator is a SIBLING placed before the thing it + * decorates — at `export_statement`/`program` level for a class — and + * decorators stack. + * + * Walks the sibling chain rather than indexing into `parent.namedChildren`, + * which is the same uncached-getter trap {@link collectClassRoutes} documents: + * a class's parent is usually `program`, so reading the list marshals every + * top-level statement in the file, once per class. That is quadratic in + * top-level statements — measured 200ms for a file of 800 classes, against + * 0.9ms for this form. + */ +function precedingDecorators(node: Parser.SyntaxNode): Parser.SyntaxNode[] { + const out: Parser.SyntaxNode[] = []; + for (let sibling = node.previousNamedSibling; sibling; sibling = sibling.previousNamedSibling) { + // A comment between the decorators and the thing they decorate is ordinary + // (`@Post('x')` then a JSDoc block then the method) and must not terminate + // the stack — doing so makes the whole decorated route invisible. + if (sibling.type === 'comment') continue; + if (sibling.type !== 'decorator') break; + out.push(sibling); + } + return out; +} + +/** + * Leading `decorator` children of a node, stopping at the first child that is + * neither a decorator nor a comment. Comments are skipped for the same reason + * as in {@link precedingDecorators}: a doc block sitting between `@Controller` + * and the class must not hide the decorator. + */ +function leadingDecorators(node: Parser.SyntaxNode): Parser.SyntaxNode[] { + const out: Parser.SyntaxNode[] = []; + for (const child of node.namedChildren) { + if (child.type === 'comment') continue; + if (child.type !== 'decorator') break; + out.push(child); + } + return out; +} + +/** + * Cap on the decorator text quoted in the dropped-controller log. Long enough + * to identify the shape, short enough not to dump a wrapped multi-line + * decorator into the operator's terminal. + */ +const DROPPED_CONTROLLER_LOG_LIMIT = 160; + +/** + * Every decorator attached to a class, across the two shapes the grammar + * produces — which differ by whether the class is exported: + * + * `@Controller('a') class A {}` → decorator is a CHILD of class_declaration + * `@Controller('a') export class A {}` → decorator is a child of export_statement, + * i.e. a SIBLING of the class_declaration + * + * Checking only one of them silently drops half of all controllers, so collect + * from both, plus the sibling position for the class itself. There is no fourth + * source: both grammars fold a class's decorators INTO the `export_statement` + * production, so an `export_statement` never has one as a preceding sibling. + */ +function classDecorators(classNode: Parser.SyntaxNode): Parser.SyntaxNode[] { + const out = [...leadingDecorators(classNode), ...precedingDecorators(classNode)]; + const wrapper = classNode.parent; + if (wrapper?.type === 'export_statement') out.push(...leadingDecorators(wrapper)); + return out; +} + +/** + * The `@Controller(...)` prefix for a class, or undefined when it has none. + * One string, not a list: a class-level array (`@Controller(['a', 'b'])`) is + * DECLINED here, exactly as `spring.ts` declines an array-form + * `@RequestMapping` — it detects the shape only to suppress the class, leaving + * the prefix × method cross-product to #2280. Collapsing to `null` is that + * suppression, and this parity is deliberate, not an oversight: the two + * extractors solve the same shape and should not disagree about which half of + * it is supported. + */ +function controllerPrefix( + classNode: Parser.SyntaxNode, + filePath: string, +): string | null | undefined { + for (const decorator of classDecorators(classNode)) { + if (decoratorName(decorator) !== 'Controller') continue; + const paths = decoratorLiteralPaths(decorator); + // `@Controller([])` lands here too and needs no answer of its own: a + // controller mounted at no path serves no route, so "emit nothing for this + // class" is what both readings of it come to. + if (paths === null || paths.length !== 1) { + // The single funnel for EVERY whole-controller drop — an unreadable + // constant (`@Controller(ROUTES.VENUES)`), a multi-path array, an + // unreadable array element, and an options object whose `path` another + // member could override all return null here. Reporting at the refusal + // sites instead would make the rarest cause the loudest, and leave the + // motivating one from this module's own header silent. + // + // `isDev` at `info`, not `debug`: the logger's base level IS `info`, so + // an isDev-gated `debug` is gated twice and stays silent in exactly the + // dev run it exists for. Same shape the routes phase uses. + if (isDev) { + const shape = decorator.text.replace(/\s+/g, ' '); + logger.info( + `🗺️ NestJS: dropped @Controller in ${filePath} — its prefix is not provable: ${ + shape.length > DROPPED_CONTROLLER_LOG_LIMIT + ? `${shape.slice(0, DROPPED_CONTROLLER_LOG_LIMIT)}…` + : shape + }`, + ); + } + return null; + } + return paths[0]; + } + return undefined; +} + +/** + * Extract NestJS routes from one parsed TypeScript/JavaScript file. + * + * A method decorator is only believed when its enclosing class carries a + * `@Controller`. `@Get`/`@Post`/`@Delete` are common identifiers, and without + * that requirement any unrelated library using the same decorator names would + * mint phantom endpoints. + */ +export function extractNestRoutes( + tree: Parser.Tree, + filePath: string, + lineOffset = 0, +): ExtractedDecoratorRoute[] { + if (!tree.rootNode.text.includes(CONTROLLER_HINT)) return []; + + const out: ExtractedDecoratorRoute[] = []; + + const visit = (node: Parser.SyntaxNode): void => { + if (CLASS_DECLARATION_TYPES.has(node.type)) { + const prefix = controllerPrefix(node, filePath); + // `undefined` — not a controller at all. `null` — a controller whose + // prefix could not be read, so its routes' URLs are unknowable. + if (prefix !== undefined) { + if (prefix !== null) collectClassRoutes(node, prefix, filePath, lineOffset, out); + return; // a controller's methods are handled here; don't re-walk them + } + } + for (const child of node.namedChildren) visit(child); + }; + + visit(tree.rootNode); + return out; +} + +/** + * Modifiers that take a `method_definition` out of Nest's handler set. + * + * Nest's `RequestMapping` writes the handler onto the class PROTOTYPE's + * `descriptor.value`, and `RouterExplorer` scans prototype instance methods for + * that metadata. A `static` method lives on the constructor and is never + * scanned; an accessor's descriptor carries `get`/`set` and no `value` to + * register. A verb decorator on any of the three therefore mounts NOTHING, so a + * route minted from one is a URL the app does not serve — the invented fact + * this module refuses everywhere else. + */ +const NON_HANDLER_MODIFIERS: ReadonlySet = new Set(['static', 'get', 'set']); + +/** Longest entry above — the cheap gate that keeps `.trim()` off a method body. */ +const LONGEST_NON_HANDLER_MODIFIER = 6; + +/** + * Whether Nest could register this `method_definition` as a request handler. + * + * Reads `children`, NOT `namedChildren`, and that is the whole difficulty: + * `static`, `get` and `set` are ANONYMOUS tokens in all three grammars this + * extractor runs under, so they never appear among named children. A static + * method, a getter, a setter and a plain method expose the IDENTICAL + * `namedChildren` (`property_identifier`, `formal_parameters`, + * `statement_block`) — probed, not assumed — so the module's usual + * `namedChildren` idiom cannot see the modifier at all and every one of the + * three reads as an ordinary handler. + * + * Matches on child TEXT, not node type, and skips the `name` field — the same + * two rules `hasKeyword` in `field-extractors/configs/helpers.ts` applies, and + * that the TS/JS captures and method extractor already use for this question. + * The text rule is load-bearing: `static` reaches the tree as an anonymous + * token in some grammar versions and a keyword node in others, so a + * `child.type === 'static'` test silently stops firing on a grammar bump — here + * that would readmit exactly the phantom routes this function removes, with the + * suite still green. Skipping `name` is what keeps a method literally called + * `get()` or `static()` from reading as a modifier. + * + * Open-coded rather than calling `hasKeyword` three times, which was measured + * at 13.96us per method against 4.30us here: that helper takes ONE keyword, so + * three keywords is three full passes, and it calls `.text.trim()` on every + * child including `statement_block` — the whole method body. `.some()` does not + * rescue it, since a real handler matches nothing and pays all three. The + * length guard keeps `.trim()` off a multi-KB body; no modifier exceeds it. + * + * The scan is bounded by one method's own children (a handful), so it is not + * the uncached-getter trap {@link collectClassRoutes} documents — that one bites + * when a PARENT's child list is re-marshalled once per member. + */ +function isRequestHandler(member: Parser.SyntaxNode): boolean { + const nameNode = member.childForFieldName('name'); + for (const child of member.children) { + if (child === nameNode) continue; + const text = child.text; + if (text.length <= LONGEST_NON_HANDLER_MODIFIER && NON_HANDLER_MODIFIERS.has(text.trim())) { + return false; + } + } + return true; +} + +function collectClassRoutes( + classNode: Parser.SyntaxNode, + prefix: string, + filePath: string, + lineOffset: number, + out: ExtractedDecoratorRoute[], +): void { + const body = classNode.childForFieldName('body'); + if (!body) return; + + // ONE forward pass over the body, accumulating the decorator run and flushing + // it at each method. Calling `precedingDecorators` per method instead is + // quadratic in methods-per-controller for a reason that is invisible in the + // source: `namedChildren` is an UNCACHED getter in node-tree-sitter, so every + // call re-marshals the entire class body into fresh JS objects before the + // `findIndex`. Measured here, 800 methods cost 362ms (450us/method, up from + // 42us/method at 50); a single pass is flat. `spring.ts` never had this + // because a Java annotation is a child of the declaration it annotates. + const pending: Parser.SyntaxNode[] = []; + + for (const member of body.namedChildren) { + if (member.type === 'decorator') { + pending.push(member); + continue; + } + // Same reason as in `precedingDecorators`: a JSDoc block between a + // decorator stack and its method must not hide the route (a real + // controller shape, pinned by the suite). Known limitation of that skip: a + // decorator ORPHANED by a commented-out handler is then absorbed onto the + // NEXT method, minting a phantom route with the wrong handler. There is no + // AST fix — an orphan followed by a comment is indistinguishable from a + // stack whose method happens to be documented — and losing every + // documented route is the worse trade, so it is made deliberately. + if (member.type === 'comment') continue; + + // tree-sitter-javascript makes a method decorator a CHILD of the + // `method_definition`, not a preceding sibling as in tree-sitter-typescript + // — and this extractor is registered on the JavaScript provider too, which + // already advertises `framework: 'nestjs'`. Reading only siblings meant + // every `.js` Nest controller emitted nothing. On TypeScript the first + // named child is the method name, so `leadingDecorators` contributes + // nothing there and no route is collected twice. + // + // A static member, a getter and a setter are decorated exactly like a + // handler and registered as none, so they contribute no decorators (see + // `isRequestHandler`). They still fall THROUGH to the `pending.length = 0` + // below rather than `continue` past it: skipping the clear would hand their + // decorator run to the next method, trading a phantom route for a + // misattributed one — the strictly worse of the two, since it corrupts a + // route that is otherwise correct. + const decorators = + member.type === 'method_definition' && isRequestHandler(member) + ? [...pending, ...leadingDecorators(member)] + : []; + for (const decorator of decorators) { + const name = decoratorName(decorator); + if (name === null) continue; + const httpMethod = NEST_METHOD_DECORATORS.get(name); + if (httpMethod === undefined) continue; + + const routePaths = decoratorLiteralPaths(decorator); + if (routePaths === null) continue; // unreadable → skip + + const handlerName = member.childForFieldName('name')?.text; + + // One route per path. `@Get(['a', 'b'])` mounts the handler at both, and + // everything else about the two is identical — same verb, same handler, + // same line — so the loop is the whole of the multi-path support. An + // empty array falls out as zero iterations without a special case. + for (const routePath of routePaths) { + out.push({ + filePath, + // A pathless `@Get()` is the controller's index route and carries no + // segment of its own. Emit '/' rather than '': `claim()` in + // call-processor short-circuits on a falsy routePath, so an empty + // string would still produce the Route node but silently lose its + // handler symbol — the route would exist with nothing attached to it. + // Both spellings normalize to the same URL against the prefix. + routePath: routePath === '' ? '/' : routePath, + httpMethod, + decoratorName: name, + lineNumber: member.startPosition.row + 1 + lineOffset, + prefix: prefix === '' ? null : prefix, + ...(handlerName === undefined ? {} : { handlerName }), + }); + } + } + + // Anything that is not a decorator or a comment ends the run — including + // the method that just consumed it, so a decorated FIELD's stack is never + // absorbed onto the method after it. + pending.length = 0; + } +} diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 64ddc8264..8cc639900 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -586,8 +586,71 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // detects the changed analyzer build in run-analyze.ts. Both guards are // required; a parse-cache bump alone must never be read as a graph rebuild. // Version 75 is intentionally skipped because concurrent PR #3017 claims it. +// +// 74 -> 75 adds #3009's NestJS decorator routes to the JS/TS decoratorRoutes +// channel. Same reasoning as 69: a warm pre-feature cache replays unchanged +// worker results, which for every already-indexed NestJS repo means replaying +// the empty route set this change exists to fix — the fix would appear to do +// nothing until something else invalidated the cache. +// +// 75, not 71 (this branch's original claim) and not 74: origin/main cascaded +// past both while this PR was open. #2980 took 71, #3046 claims 73, and the +// Convex endpoint-metadata change took 74 (skipping 73 for exactly that +// reason). 75 is the next free value above origin/main AND above every +// in-flight claim — the rule the ledger states, not "one above main". Every +// open PR touching gitnexus/src/storage/parse-cache.ts was scanned at this +// merge: #3046 (73) and #1616 (a stale 2) are the only other claimants. // RE-CHECK AGAINST origin/main AND OPEN PRs IMMEDIATELY BEFORE MERGING. -const SCHEMA_BUMP = 76; +// +// 75 -> 76 for the NestJS multi-path (array) form: `@Get(['a','b'])` now yields +// one `decoratorRoutes` entry per path where it previously yielded none. That +// changes worker output for the same file, and 75 was already claimed earlier +// on this same branch — so a warm cache written by a dev or CI build at v75, +// before the array form landed, would replay the pre-feature captures under a +// BYTE-IDENTICAL `PARSE_CACHE_VERSION` and the array form would be inert. The +// package version is untouched here, so it cannot rescue that case. Same-branch +// re-bumping is unusual, but the ledger's rule is about what a warm cache can +// replay, not about how the value was reached. +// +// 76 -> 77 because 76 was NOT free. While this branch sat in review, origin/main +// advanced to ac68f5254 and #3046 took 76 — the value this branch already held. +// `gitnexus/package.json` is 1.6.9 on both sides, so `PARSE_CACHE_VERSION` was +// the byte-identical string `76+1.6.9` on two branches that changed +// incompatible worker output. Every warm cache would have been reused across +// both features, making both inert while every test stayed green. +// +// The sharpest part: #3046 skipped 75 BECAUSE this branch held it, then took +// 76 — and this branch had meanwhile moved 75 -> 76 for the array form. Two +// PRs each doing the bookkeeping correctly still collided, because each +// re-checked once and neither re-checked after the other moved. That is what +// the re-check line below is for, and why it says AT MERGE rather than +// when you pick the number. +// +// 77 is free at this merge: origin/main is 76, and the open PRs touching this +// constant are #2840 (a stale 71) and #1616 (a stale 2). Scan with the contents +// API at each PR head, not `gh pr diff` — that exits non-zero on an +// inaccessible fork and prints nothing, so a grep over its output skips the PR +// silently. #2840 was missed exactly that way this round. +// +// WHY THIS IS STILL A HAND-PICKED NUMBER, when `SCHEMA_FINGERPRINT` next door +// is a derived sha256 that cannot collide. The derivation exists and already +// runs: `resolveAnalyzerRunnerIdentity` computes `build.digest` over the +// analyzer build tree on every analyze. It is not used here because it moves on +// ANY build change — a comment-only edit, this paragraph included — so every +// dev and CI rebuild would force a full re-parse of every repo. That is the +// expensive half of the trade, and `PARSE_CACHE_VERSION` already carries the +// package version, so this counter's only job is separating builds that SHARE +// one: dev, CI, unreleased. Exactly the population a whole-build digest would +// punish, and exactly the population that hits the exact-clash failure. +// +// So the counter stays, and the open cost is that a bump nobody makes is +// invisible: a capture change with no bump ships inert and no check can see it. +// #2860 (a base-branch CI comparison) closes the "not greater than main" axis +// only. A digest over just the determinant subset — the language queries plus +// `route-extractors/` and `workers/` module content — would close the missing- +// bump axis without invalidating on unrelated churn, and is the real follow-up. +// RE-CHECK AGAINST origin/main AND OPEN PRs IMMEDIATELY BEFORE MERGING. +const SCHEMA_BUMP = 77; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/fixtures/multi-verb-route-app/api/widgetsController.ts b/gitnexus/test/fixtures/multi-verb-route-app/api/widgetsController.ts new file mode 100644 index 000000000..4a5c0f676 --- /dev/null +++ b/gitnexus/test/fixtures/multi-verb-route-app/api/widgetsController.ts @@ -0,0 +1,26 @@ +/** + * A NestJS controller mounting a METHOD-AGNOSTIC route at `/api/widgets` — the + * same URL `app/api/widgets/route.ts` produces as a Next.js filesystem route. + * + * `@All` maps to httpMethod '*', which `routeNodeKey` keys by URL alone, so + * this route collides with the filesystem one. It is the shape behind #3049. + * + * `@Get('gadgets')` is the NON-colliding companion, and it is here for a + * reason the `@All` route cannot serve: because the `@All` route loses its key + * to the filesystem node and is dropped as a duplicate, it leaves NO trace in + * the graph at all — so the whole of this file could stop being extracted + * without a single assertion changing. `GET /api/gadgets` is the only witness + * this fixture can offer that NestJS extraction ran (see the test's comment). + */ +@Controller('api') +export class WidgetsController { + @All('widgets') + handleEveryVerb() { + return 'nest handler'; + } + + @Get('gadgets') + listGadgets() { + return '[]'; + } +} diff --git a/gitnexus/test/fixtures/nest-route-app/src/health/health.controller.ts b/gitnexus/test/fixtures/nest-route-app/src/health/health.controller.ts new file mode 100644 index 000000000..462ba7717 --- /dev/null +++ b/gitnexus/test/fixtures/nest-route-app/src/health/health.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Get } from '@nestjs/common'; + +/** + * A controller with no prefix of its own. `@Controller()` is legal NestJS and + * means "mount at the root", so the method path must reach the graph bare — + * this is the fixture half of the extractor's `prefix: '' -> null` mapping. + */ +@Controller() +export class HealthController { + @Get('health') + health(): string { + return 'ok'; + } +} diff --git a/gitnexus/test/fixtures/nest-route-app/src/legacy/legacy.controller.ts b/gitnexus/test/fixtures/nest-route-app/src/legacy/legacy.controller.ts new file mode 100644 index 000000000..d91a8ee82 --- /dev/null +++ b/gitnexus/test/fixtures/nest-route-app/src/legacy/legacy.controller.ts @@ -0,0 +1,16 @@ +import { Controller, Get } from '@nestjs/common'; + +const ROUTE_PREFIXES = { legacy: 'legacy' }; + +/** + * The prefix is not a literal, so the URLs of this controller's methods are + * unknowable here. `route_map` presents its output as fact: a missing route is + * recoverable, a route mounted at the wrong URL is not. + */ +@Controller(ROUTE_PREFIXES.legacy) +export class LegacyController { + @Get('reports') + legacyReports(): string { + return 'legacy'; + } +} diff --git a/gitnexus/test/fixtures/nest-route-app/src/venues/venues.controller.ts b/gitnexus/test/fixtures/nest-route-app/src/venues/venues.controller.ts new file mode 100644 index 000000000..4a519f31c --- /dev/null +++ b/gitnexus/test/fixtures/nest-route-app/src/venues/venues.controller.ts @@ -0,0 +1,35 @@ +import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common'; +import { VenuesService } from './venues.service'; +import type { Venue } from './venues.service'; + +/** + * The shape #3009 is about: the URL is split across two decorators. The class + * decorator carries the prefix and the method decorator carries the verb plus + * the remainder, so neither half is a route on its own. + */ +@Controller('venues') +export class VenuesController { + constructor(private readonly venues: VenuesService) {} + + // Pathless index route: its URL is the controller prefix and nothing else. + @Get() + findAll(): Venue[] { + return this.venues.listVenues(); + } + + @Get('search') + search(@Query('q') term: string): Venue[] { + return this.venues.searchVenues(term); + } + + // Same URL as findAll(), different verb — two Route nodes, not one. + @Post() + create(@Body() input: Venue): Venue { + return this.venues.insertVenue(input); + } + + @Delete(':id') + remove(@Param('id') id: string): void { + this.venues.deleteVenue(id); + } +} diff --git a/gitnexus/test/fixtures/nest-route-app/src/venues/venues.service.ts b/gitnexus/test/fixtures/nest-route-app/src/venues/venues.service.ts new file mode 100644 index 000000000..b5225f100 --- /dev/null +++ b/gitnexus/test/fixtures/nest-route-app/src/venues/venues.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@nestjs/common'; + +export interface Venue { + id: string; + name: string; +} + +/** + * Not a controller. It exists so each handler body calls a real symbol, and so + * the `@Controller` file gate is shown skipping a decorated class that declares + * no routes. + */ +@Injectable() +export class VenuesService { + private readonly rows: Venue[] = []; + + listVenues(): Venue[] { + return this.rows; + } + + searchVenues(term: string): Venue[] { + return this.rows.filter((row) => row.name.includes(term)); + } + + insertVenue(input: Venue): Venue { + this.rows.push(input); + return input; + } + + deleteVenue(id: string): void { + const index = this.rows.findIndex((row) => row.id === id); + if (index !== -1) this.rows.splice(index, 1); + } +} diff --git a/gitnexus/test/integration/multi-verb-route-identity.test.ts b/gitnexus/test/integration/multi-verb-route-identity.test.ts index 938c44305..d488c2522 100644 --- a/gitnexus/test/integration/multi-verb-route-identity.test.ts +++ b/gitnexus/test/integration/multi-verb-route-identity.test.ts @@ -12,6 +12,8 @@ * Fixture: `test/fixtures/multi-verb-route-app/` * - ItemController.java: GET /api/items, POST /api/items, GET /api/widgets * - app/api/widgets/route.ts: Next.js filesystem route → /api/widgets + * - api/widgetsController.ts: NestJS `@All` → method-agnostic /api/widgets, + * plus `@Get('gadgets')` → GET /api/gadgets (the non-colliding witness) * - web/itemsClient.ts: verb-less fetch() consumers of both URLs */ import { describe, it, expect, beforeAll } from 'vitest'; @@ -81,6 +83,56 @@ describe('Multi-verb Route node identity (#2289)', () => { expect(decoratorNode!.properties.method).toBe('GET'); }); + it("never stamps a losing route's handler onto a filesystem node (#3049)", () => { + // The NestJS `@All('widgets')` route keys by URL alone (routeNodeKey drops + // '*'), so it collides with the filesystem route, claims the key unopposed + // in claim(), and is then dropped here by first-writer-wins. Its handler + // must not survive that loss: a Next.js Route node carrying a NestJS + // controller method is a fabricated fact, not a missing one, and + // api_impact is documented to be run BEFORE editing a route handler. + const fsNode = routeNode(generateId('Route', '/api/widgets')); + + expect(fsNode, 'filesystem Route node /api/widgets should exist').toBeTruthy(); + expect(fsNode!.properties.handlerSymbolId).toBeUndefined(); + }); + + it('extracts a NON-colliding NestJS route (the witness that #3049 suppressed a REAL claim)', () => { + // Load-bearing for the assertion above it, which cannot stand alone: + // `handlerSymbolId === undefined` passes identically whether the guard + // correctly dropped a real NestJS `@All` claim or whether NestJS + // extraction produced nothing at all. Disproved by experiment — commenting + // out `...extractNestRoutes(...args)` in `languages/typescript.ts` and + // rebuilding `dist/` left the whole suite green. + // + // Asserting that the `WidgetsController` class and its `handleEveryVerb` + // method exist does NOT repair that, and is the first thing the next + // reader will try: those nodes come from the definitions phase, which runs + // regardless, while `extractNestRoutes` is wired only as the + // `decoratorRoutes` hook and contributes no class or method node. + // `@All('widgets')` also cannot witness its own extraction — it collides + // on `routeNodeKey`, is dropped as a duplicate, and leaves no graph trace. + // A second route at a URL nothing else claims is the only evidence the + // graph can carry, so this node existing IS the proof the `@All` claim the + // guard suppressed was real. + const gadgets = routeNode(routeId('GET', '/api/gadgets')); + + expect(gadgets, 'NestJS GET /api/gadgets Route node should exist').toBeTruthy(); + + const handler = result.graph.getNode(String(gadgets!.properties.handlerSymbolId)); + expect(String(handler?.properties.name)).toBe('listGadgets'); + }); + + it('still resolves a same-URL route that did NOT lose its key', () => { + // The guard above is scoped to pre-seeded sources, so the Java + // `GET /api/widgets` node — a different key, resolved for itself — keeps + // its handler. Without this, the fix reads as "filesystem collisions are + // handler-less" when the real rule is "a route that lost donates nothing". + const decoratorNode = routeNode(routeId('GET', '/api/widgets')); + const handler = result.graph.getNode(String(decoratorNode!.properties.handlerSymbolId)); + + expect(String(handler?.properties.name)).toBe('getWidgets'); + }); + it('connects a verb-less fetch() consumer to every Route node at the URL', () => { const consumerFileId = generateId('File', 'web/itemsClient.ts'); // Collect FETCHES targets without a test-level conditional: pass the diff --git a/gitnexus/test/integration/nest-route-pipeline.test.ts b/gitnexus/test/integration/nest-route-pipeline.test.ts new file mode 100644 index 000000000..7ea13e8ee --- /dev/null +++ b/gitnexus/test/integration/nest-route-pipeline.test.ts @@ -0,0 +1,216 @@ +/** + * End-to-end coverage of NestJS `@Controller` + `@Get`/`@Post`/`@Delete` route + * ingestion (#3009). + * + * The unit suite (`test/unit/nest-decorator-routes.test.ts`) pins what the + * extractor RETURNS. Nothing there proves the return value becomes anything: + * the reported symptom was not a wrong `ExtractedDecoratorRoute`, it was + * `api_impact` — whose documented job is to be run BEFORE modifying a route + * handler — reporting every live endpoint as non-existent, because the graph + * held no `Route` nodes at all. That is the tier this file covers: the routes + * phase performing the prefix/path join, `claim()` in call-processor resolving + * `handlerName` to a real symbol UID, and `prefix: null` surviving both. + * + * The fixture lives at `test/fixtures/nest-route-app/`, mirroring + * `spring-route-app/` — Spring solves the identical two-decorator shape and + * `nest.ts` is modelled on it. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import path from 'node:path'; +import type { GraphNode, GraphRelationship } from 'gitnexus-shared'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; +import type { PipelineResult } from '../../src/types/pipeline.js'; + +const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'nest-route-app'); + +// Compared against POSIX-normalized graph paths (see `routes()`), so these stay +// forward-slashed rather than going through `path.join` — the Windows shard +// would otherwise compare backslashes against the graph's forward slashes. +const CONTROLLER_FILE = 'src/venues/venues.controller.ts'; +const HEALTH_FILE = 'src/health/health.controller.ts'; + +interface RouteView { + /** `${method} ${url}` — the `routeNodeKey` identity, as a sortable string. */ + readonly identity: string; + /** The handler the route resolved to, or the literal `'undefined'`. */ + readonly handler: string; + readonly handlerLabel: string; + readonly handlerFile: string; +} + +describe('NestJS decorator route ingestion pipeline', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(FIXTURE, () => {}, {}); + }, 120_000); + + const nodes = (): readonly GraphNode[] => { + const out: GraphNode[] = []; + result.graph.forEachNode((node) => void out.push(node)); + return out; + }; + + const relationships = (): readonly GraphRelationship[] => { + const out: GraphRelationship[] = []; + result.graph.forEachRelationship((rel) => void out.push(rel)); + return out; + }; + + const routeNodes = (): readonly GraphNode[] => nodes().filter((node) => node.label === 'Route'); + + /** + * Unresolved fields are stringified rather than branched on, so a route that + * lost its handler reads as the literal `'undefined'` in the diff instead of + * quietly skipping an assertion. + */ + const routes = (): readonly RouteView[] => + routeNodes() + .map((node) => { + const handler = result.graph.getNode(String(node.properties.handlerSymbolId)); + return { + identity: `${String(node.properties.method)} ${String(node.properties.name)}`, + handler: String(handler?.properties.name), + handlerLabel: String(handler?.label), + handlerFile: String(handler?.properties.filePath).replaceAll('\\', '/'), + }; + }) + .sort((a, b) => a.identity.localeCompare(b.identity)); + + const identities = (): readonly string[] => routes().map((route) => route.identity); + + it('emits Route nodes at all — the reported symptom was zero', () => { + // Its own case because every expectation below is satisfiable by an empty + // graph in the ways that matter least: `not.toContain` passes vacuously on + // an empty array, and a full-table `toEqual` against `[]` reports a missing + // element rather than "the extractor never ran". + expect(routeNodes().length).toBeGreaterThan(0); + }); + + it('mounts a pathless @Get() at the controller prefix WITH its handler attached', () => { + // The reason `nest.ts` emits '/' instead of '' for a pathless decorator: + // `claim()` short-circuits on a falsy `routePath`, so '' would still create + // this Route node and silently drop `handlerSymbolId`. The unit test can + // only see `routePath === '/'` at the extractor boundary; the guard being + // load-bearing is observable here and nowhere else. + expect(routes().find((route) => route.identity === 'GET /venues')).toEqual({ + identity: 'GET /venues', + handler: 'findAll', + handlerLabel: 'Method', + handlerFile: CONTROLLER_FILE, + }); + }); + + it('joins each @Controller prefix with its method paths and resolves every handler', () => { + // Two invariants, one table, because a projection of this assertion can + // only fail where this one already does. The join is what the extractor + // cannot do alone — it ships `prefix` and the routes phase folds it in via + // `normalizeExtractedRoutePath`, so these read `/venues/search`, not + // `search`. And `claim()` is first-writer-wins per `(method, url)`, so a + // mis-keyed route surfaces as a handler donated to the wrong URL rather + // than as an absence. + expect(routes()).toEqual([ + { + identity: 'DELETE /venues/:id', + handler: 'remove', + handlerLabel: 'Method', + handlerFile: CONTROLLER_FILE, + }, + { + identity: 'GET /health', + handler: 'health', + handlerLabel: 'Method', + handlerFile: HEALTH_FILE, + }, + { + identity: 'GET /venues', + handler: 'findAll', + handlerLabel: 'Method', + handlerFile: CONTROLLER_FILE, + }, + { + identity: 'GET /venues/search', + handler: 'search', + handlerLabel: 'Method', + handlerFile: CONTROLLER_FILE, + }, + { + identity: 'POST /venues', + handler: 'create', + handlerLabel: 'Method', + handlerFile: CONTROLLER_FILE, + }, + ]); + }); + + it('splits one URL into one Route node per verb', () => { + // `@Get()` and `@Post()` on the same controller share a URL. `routeNodeKey` + // makes them distinct identities; collapsing them would drop a live + // endpoint and hand its handler to the survivor. + expect( + routes() + .filter((route) => route.identity.endsWith(' /venues')) + .map((route) => `${route.identity} -> ${route.handler}`), + ).toEqual(['GET /venues -> findAll', 'POST /venues -> create']); + }); + + it('carries a prefix-less @Controller() through to a bare URL', () => { + // `@Controller()` maps to `prefix: null`, and null must reach the join as + // "no prefix" rather than as the string 'null' or a leading empty segment. + expect(identities()).toContain('GET /health'); + expect(identities().filter((id) => id.includes('//'))).toEqual([]); + }); + + it('records per-decorator provenance on the HANDLES_ROUTE edge', () => { + // Nest routes are DECLARED by an annotation, so they take the generic + // `decorator-` source rather than a bespoke one — the same channel + // Spring and FastAPI use, which is the point of modelling on `spring.ts`. + const routeIds = new Set(routeNodes().map((node) => node.id)); + expect( + [ + ...new Set( + relationships() + .filter((rel) => rel.type === 'HANDLES_ROUTE' && routeIds.has(rel.targetId)) + .map((rel) => String(rel.reason)), + ), + ].sort(), + ).toEqual(['decorator-Delete', 'decorator-Get', 'decorator-Post']); + }); + + describe('precision — what must NOT become a route', () => { + // Absence assertions are satisfied just as well by a file that was never + // read, so prove ingestion first; otherwise this whole block is decoration. + it('ingested the unreadable-prefix controller', () => { + expect( + nodes() + .filter((node) => String(node.properties.filePath ?? '').endsWith('legacy.controller.ts')) + .map((node) => String(node.properties.name)), + ).toEqual(expect.arrayContaining(['LegacyController', 'legacyReports'])); + }); + + it('drops a controller whose prefix is not a literal', () => { + // `@Controller(ROUTE_PREFIXES.legacy)` — the URL is unknowable, and + // `route_map` presents its output as fact. Emitting `/reports` here would + // be a wrong route, which is worse than a missing one. + expect(identities().filter((id) => id.includes('reports'))).toEqual([]); + expect(identities().filter((id) => id.includes('legacy'))).toEqual([]); + }); + + it('ingested the decorated non-controller service', () => { + expect( + nodes() + .filter((node) => String(node.properties.filePath ?? '').endsWith('venues.service.ts')) + .map((node) => String(node.properties.name)), + ).toEqual(expect.arrayContaining(['VenuesService', 'listVenues', 'searchVenues'])); + }); + + it('does not mint routes from a class with no @Controller', () => { + // Verb decorators are believed only inside a `@Controller` class; without + // that gate any library sharing the names would mint phantom endpoints. + expect(routes().filter((route) => route.handlerFile.endsWith('venues.service.ts'))).toEqual( + [], + ); + }); + }); +}); diff --git a/gitnexus/test/unit/group/nest-route-parity.test.ts b/gitnexus/test/unit/group/nest-route-parity.test.ts new file mode 100644 index 000000000..c0ed482d6 --- /dev/null +++ b/gitnexus/test/unit/group/nest-route-parity.test.ts @@ -0,0 +1,289 @@ +/** + * Parity guard for the two NestJS route layers. + * + * GitNexus reads `@Controller` / `@Get` decorators for two consumers: the + * indexer's `route-extractors/nest.ts` (which mints graph `Route` nodes) and + * the group layer's `http-patterns/node.ts` (which mints cross-repo HTTP + * contracts). They used to be two independent tree-sitter scans, and that is + * the shape #2265 already showed to be a slow leak: there the group query + * matched Spring's array form `@GetMapping({"/a","/b"})` and ingestion's did + * not, so the graph silently under-covered what the contracts claimed. Nest had + * the same divergence pointing the other way and worse — the group scan + * INVENTED `/` for any method path it could not read, so `@Get(ROUTES.SEARCH)` + * became a `GET /venues` contract with no Route node behind it. "A missing + * route is a coverage limit; an invented one is a lie" (ARCHITECTURE.md). + * + * The group layer now CALLS `extractNestRoutes` instead of re-querying the + * decorators, so the two cannot disagree by construction. What this file + * guards is that the call stays wired and keeps its `HttpDetection` shape: the + * assertions below pair each group result with the value computed straight from + * `extractNestRoutes` + `normalizeExtractedRoutePath`, and also pin the literal + * expected URLs, so a mutual regression cannot pass by having both sides go + * quiet together. + */ +import { describe, it, expect } from 'vitest'; +import Parser from 'tree-sitter'; +import JavaScript from 'tree-sitter-javascript'; +import TypeScript from 'tree-sitter-typescript'; +import { + JAVASCRIPT_HTTP_PLUGIN, + TYPESCRIPT_HTTP_PLUGIN, +} from '../../../src/core/group/extractors/http-patterns/node.js'; +import type { HttpLanguagePlugin } from '../../../src/core/group/extractors/http-patterns/types.js'; +import { extractNestRoutes } from '../../../src/core/ingestion/route-extractors/nest.js'; +import { normalizeExtractedRoutePath } from '../../../src/core/ingestion/route-extractors/route-path.js'; + +// Compiled tree-sitter queries are grammar-bound, so a plugin must be driven +// with a tree parsed by ITS grammar. +interface Lang { + readonly parser: Parser; + readonly plugin: HttpLanguagePlugin; +} + +function lang(grammar: unknown, plugin: HttpLanguagePlugin): Lang { + const parser = new Parser(); + parser.setLanguage(grammar as Parameters[0]); + return { parser, plugin }; +} + +const TS = lang(TypeScript.typescript, TYPESCRIPT_HTTP_PLUGIN); +const JS = lang(JavaScript, JAVASCRIPT_HTTP_PLUGIN); + +/** `METHOD /full/url` pairs the GROUP layer reports as NestJS providers. */ +function groupPairs(src: string, target: Lang = TS): string[] { + return target.plugin + .scan(target.parser.parse(src)) + .filter((d) => d.role === 'provider' && d.framework === 'nest') + .map((d) => `${d.method} ${d.path}`) + .sort(); +} + +/** + * The same pairs computed straight from the indexer's extractor, joining the + * prefix the way the routes phase does. This is the reference the group layer + * must equal — and, since the group layer now calls the same function, the + * assertion is really "the call is still there and still joins the prefix". + */ +function ingestionPairs(src: string, target: Lang = TS): string[] { + return extractNestRoutes(target.parser.parse(src), 'venues.controller.ts') + .map((r) => `${r.httpMethod} ${normalizeExtractedRoutePath(r.routePath, r.prefix ?? null)}`) + .sort(); +} + +/** A minimal `@Controller('venues')` wrapping the given class-body members. */ +function venuesController(members: string): string { + return ` +import { Controller, Get, Post, Put, Patch, Delete, Head, Options, All, Sse } from '@nestjs/common'; + +@Controller('venues') +export class VenuesController { +${members} +} +`; +} + +describe('NestJS route parity — group node.ts delegates to ingestion nest.ts', () => { + const VERB_CASES: ReadonlyArray = [ + ['Get', 'GET'], + ['Post', 'POST'], + ['Put', 'PUT'], + ['Patch', 'PATCH'], + ['Delete', 'DELETE'], + // The four the group layer's own query never listed: its verb set stopped + // at Patch, so every @Head/@Options/@All/@Sse endpoint was invisible to + // contract matching while sitting in the graph as a Route node. + ['Head', 'HEAD'], + ['Options', 'OPTIONS'], + ['All', '*'], + // @Sse mounts a real streaming GET; '*' is the method-agnostic spelling + // `findMatchingKeys` already understands from Spring's @RequestMapping. + ['Sse', 'GET'], + ]; + + it.each(VERB_CASES)('pins the verb @%s → %s', (decorator, method) => { + const src = venuesController(` @${decorator}('slots')\n handler() {}`); + expect(groupPairs(src)).toEqual([`${method} /venues/slots`]); + expect(ingestionPairs(src)).toEqual(groupPairs(src)); + }); + + it('pins an abstract controller — a node type the group query never matched', () => { + // `export abstract class C` parses as `abstract_class_declaration`, a + // DIFFERENT node type from `class_declaration`. A decorated abstract base + // sharing CRUD routes with its subclasses is ordinary Nest, and the old + // group query dropped the WHOLE controller for it, not one route. + const src = ` +import { Controller, Get } from '@nestjs/common'; + +@Controller('venues') +export abstract class BaseVenuesController { + @Get('list') + list() {} +} +`; + expect(groupPairs(src)).toEqual(['GET /venues/list']); + expect(ingestionPairs(src)).toEqual(groupPairs(src)); + }); + + it('pins the object-form @Controller({ path }) — the documented versioning shape', () => { + // The old group query required a positional `(string)`/`(template_string)` + // argument, so `@Controller({ path: 'cats', version: '1' })` — the form the + // Nest docs give for URI/header versioning — suppressed the whole class. + const src = ` +import { Controller, Get } from '@nestjs/common'; + +@Controller({ path: 'venues', version: '1' }) +export class VenuesController { + @Get('list') + list() {} +} +`; + expect(groupPairs(src)).toEqual(['GET /venues/list']); + expect(ingestionPairs(src)).toEqual(groupPairs(src)); + }); + + it('pins the argument-less @Controller() — routes mount at the root', () => { + // Legal Nest, and the old group query's mandatory prefix argument made the + // class invisible rather than rooting its methods at '/'. + const src = ` +import { Controller, Get } from '@nestjs/common'; + +@Controller() +export class HealthController { + @Get('health') + health() {} +} +`; + expect(groupPairs(src)).toEqual(['GET /health']); + expect(ingestionPairs(src)).toEqual(groupPairs(src)); + }); + + it('pins a pathless @Get() at the controller prefix with no trailing slash', () => { + // The group layer's local `joinPath('venues', '/')` returned '/venues/'; + // the graph stored '/venues'. Both contract-id generation + // (`normalizeHttpPath`) and match-time canonicalization + // (`normalizeContractId`) strip a trailing slash, so this was a latent + // divergence rather than a live mismatch — but it is one fewer way for the + // two layers to describe the same endpoint differently. + const src = venuesController(' @Get()\n index() {}'); + expect(groupPairs(src)).toEqual(['GET /venues']); + expect(ingestionPairs(src)).toEqual(groupPairs(src)); + }); + + it('decodes an escaped literal identically in both layers', () => { + // tree-sitter SPLITS a string around each `escape_sequence`. The group + // layer's `unquoteLiteral` (`raw.slice(1, -1)`) left the backslash in place, + // so the ordinary spelling of a Nest regex param came out as a path the app + // never serves. `plainString` decodes it. + const src = venuesController(String.raw` @Get(':id(\\d+)')` + '\n byId() {}'); + expect(groupPairs(src)).toEqual([String.raw`GET /venues/:id(\d+)`]); + expect(ingestionPairs(src)).toEqual(groupPairs(src)); + }); + + it('emits NOTHING for an unreadable method path instead of inventing the prefix', () => { + // The precision case. `@Get(ROUTES.SEARCH)` is not readable from this file, + // and the old group scan answered it with a fabricated `GET /venues` — a + // contract that exact-matches any consumer of the controller root and has + // no Route node behind it. The readable sibling proves the controller is + // still SEEN, so this is a dropped route rather than a dropped class. + const src = ` +import { Controller, Get } from '@nestjs/common'; +import { ROUTES } from './routes.js'; + +@Controller('venues') +export class VenuesController { + @Get('list') + list() {} + + @Get(ROUTES.SEARCH) + search() {} +} +`; + expect(groupPairs(src)).toEqual(['GET /venues/list']); + expect(ingestionPairs(src)).toEqual(groupPairs(src)); + }); + + it('reads a JavaScript Nest controller, whose decorators sit under the method', () => { + // tree-sitter-javascript makes a method decorator a CHILD of the + // `method_definition`; tree-sitter-typescript makes it a preceding SIBLING. + // The group scan only ever walked siblings, so every `.js` Nest controller + // emitted zero contracts. + const src = ` +const { Controller, Get } = require('@nestjs/common'); + +@Controller('venues') +class VenuesController { + @Get('search') + search() {} +} +`; + expect(groupPairs(src, JS)).toEqual(['GET /venues/search']); + expect(ingestionPairs(src, JS)).toEqual(groupPairs(src, JS)); + }); + + it('agrees with the indexer on the full (method, URL) set for one mixed fixture', () => { + // The genuine parity assertion (#2265's lesson): one fixture, both layers, + // set equality — plus the literal expectation, so the two cannot agree by + // both returning nothing. + const src = ` +import { Controller, Get, Post, Delete, All, Sse } from '@nestjs/common'; +import { ROUTES } from './routes.js'; + +@Controller({ path: 'venues' }) +export abstract class VenuesController { + @Get() + index() {} + + @Get(':id') + byId() {} + + @Post('/') + create() {} + + @Delete(ROUTES.PURGE) + purge() {} + + @All('proxy') + proxy() {} + + @Sse('events') + events() {} +} + +@Controller() +export class RootController { + @Get('healthz') + healthz() {} +} +`; + const expected = [ + '* /venues/proxy', + 'GET /healthz', + 'GET /venues', + 'GET /venues/:id', + 'GET /venues/events', + 'POST /venues', + ].sort(); + + expect(groupPairs(src)).toEqual(expected); + expect(ingestionPairs(src)).toEqual(expected); + }); + + it('carries the handler name, 1-based line and provider confidence onto the detection', () => { + // The fields the contract extractor resolves a symbol from. `lineNumber` is + // already 1-based at the ingestion layer, so the delegation must NOT add + // one again — `list()` is on line 7 of this source, the same line the + // replaced code reported via `methodNode.startPosition.row + 1`. + const src = venuesController(" @Get('list')\n list() {}"); + expect(TS.plugin.scan(TS.parser.parse(src)).filter((d) => d.framework === 'nest')).toEqual([ + { + role: 'provider', + framework: 'nest', + method: 'GET', + path: '/venues/list', + name: 'list', + line: 7, + confidence: 0.8, + }, + ]); + }); +}); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index 89b840feb..d6500c702 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -222,22 +222,32 @@ describe('PARSE_CACHE_VERSION', () => { // adds Spring non-HTTP handler side-channel facts (#2417 / #2891), so it is // the next free value after both cache payload changes. // Moved 70 -> 71 for #2980's Java constant-route capture set (moduleConstants - // + routePathOperands). This branch first argued no bump was needed because - // "the ledger already sits at 70, whose capture set post-dates and includes - // this harvest" — it does not: 70 was cut by fe3d7e56b for #2417/#2891, an - // ancestor of this PR's base. Leaving it made PARSE_CACHE_VERSION byte- - // identical across the merge, so every same-package-version warm cache - // replayed pre-feature captures and the feature was inert. 71 is the next - // free value above every claim at this merge — origin/main is 70 and open - // PR #3017 already claims 71, so 71 would have collided. - it('pins SCHEMA_BUMP to 76 so v74 caches cannot retain pre-#3041 identities', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(76); + // + routePathOperands). 72 -> 74 added import-proven Convex endpoint metadata, + // skipping 73 because open PR #3046 claims it. + // Version 75 adds #3009's NestJS decorator routes to the same JS/TS + // decoratorRoutes channel, so a warm pre-feature cache cannot replay the empty + // route set that change fixes. This branch originally claimed 71; origin/main + // cascaded past it (71 to #2980, 74 to Convex) while the PR was open, so 71 + // would now be BELOW main and the reuse gate would never fire. 75 is the next + // free value above origin/main and above every in-flight claim (#3046 at 73, + // #1616 at a stale 2) — the rule, re-applied at merge, not at authoring time. + // Moved 75 -> 76 within this same branch for the NestJS array form, then + // 76 -> 77 because 76 turned out not to be free: origin/main reached 76 via + // #3046 while this branch was in review, and package.json is 1.6.9 on both + // sides, so the cache key was the byte-identical `76+1.6.9` on two branches + // with incompatible worker output. #3046 had skipped 75 precisely because + // this branch held it. Two PRs each doing the bookkeeping correctly still + // collided, because each re-checked once and neither re-checked after the + // other moved — which is why the rule is re-applied AT MERGE, not when the + // number is picked. + it('pins SCHEMA_BUMP to 77 so concurrent bumps cannot silently collide (#2766)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(77); // The PREVIOUS version must fail the reuse gate, not merely differ from the // current one — a hardcoded number outside the conflict hunk rebases cleanly // while being wrong, which is exactly how the 37/38 exact clashes landed. // Every nearby historical or in-flight value is rejected, including 69, // which carried the route-table payload before this merge. - for (const taken of [59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75]) { + for (const taken of [59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76]) { expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken); } }); diff --git a/gitnexus/test/unit/nest-decorator-routes.test.ts b/gitnexus/test/unit/nest-decorator-routes.test.ts new file mode 100644 index 000000000..c201af701 --- /dev/null +++ b/gitnexus/test/unit/nest-decorator-routes.test.ts @@ -0,0 +1,738 @@ +import { describe, expect, it } from 'vitest'; +import Parser from 'tree-sitter'; +import TypeScript from 'tree-sitter-typescript'; +import JavaScript from 'tree-sitter-javascript'; +import { extractNestRoutes } from '../../src/core/ingestion/route-extractors/nest.js'; +import { normalizeExtractedRoutePath } from '../../src/core/ingestion/route-extractors/route-path.js'; + +const tsParser = new Parser(); +tsParser.setLanguage(TypeScript.typescript); + +// The extractor is registered on the JavaScript provider too, and the two +// grammars place a method decorator differently, so both must be exercised. +const jsParser = new Parser(); +jsParser.setLanguage(JavaScript); + +const extract = (source: string) => + extractNestRoutes(tsParser.parse(source), 'src/x.controller.ts'); + +const extractJs = (source: string) => + extractNestRoutes(jsParser.parse(source), 'src/x.controller.js'); + +/** What the routes phase will key the Route node by: verb + joined path. */ +const format = (routes: ReturnType) => + routes.map( + (r) => `${r.httpMethod} ${normalizeExtractedRoutePath(r.routePath, r.prefix ?? null)}`, + ); + +const urls = (source: string) => format(extract(source)); +const jsUrls = (source: string) => format(extractJs(source)); + +describe('NestJS decorator routes', () => { + it('joins the controller prefix with each method path', () => { + expect( + urls(` + @Controller('venues') + export class VenueController { + @Get() + findAll() {} + + @Get('search') + search() {} + + @Post(':id/follow') + follow(@Param('id') id: string) {} + + @Delete(':id') + remove() {} + } + `), + ).toEqual([ + 'GET /venues', + 'GET /venues/search', + 'POST /venues/:id/follow', + 'DELETE /venues/:id', + ]); + }); + + it("emits '/' rather than '' for a pathless @Get, so the handler still resolves", () => { + // call-processor's claim() short-circuits on a falsy routePath, so '' + // would create the Route node but silently lose its handler symbol. + // Both spellings normalize to the same URL. + const [route] = extract(` + @Controller('venues') + export class VenueController { + @Get() + findAll() {} + } + `); + expect(route.routePath).toBe('/'); + expect(normalizeExtractedRoutePath(route.routePath, route.prefix ?? null)).toBe('/venues'); + }); + + it('handles a prefixless @Controller()', () => { + expect( + urls(` + @Controller() + export class AppController { + @Get('health') + health() {} + } + `), + ).toEqual(['GET /health']); + }); + + it('captures the handler method name for symbol resolution', () => { + const routes = extract(` + @Controller('users') + export class UserController { + @Patch(':id') + updateOne() {} + } + `); + + expect(routes).toHaveLength(1); + expect(routes[0]).toMatchObject({ + httpMethod: 'PATCH', + routePath: ':id', + prefix: 'users', + decoratorName: 'Patch', + handlerName: 'updateOne', + filePath: 'src/x.controller.ts', + }); + }); + + it('supports a non-exported controller and all verbs', () => { + expect( + urls(` + @Controller('a') + class A { + @Put('p') p() {} + @Head('h') h() {} + @Options('o') o() {} + @All('any') any() {} + } + `), + ).toEqual(['PUT /a/p', 'HEAD /a/h', 'OPTIONS /a/o', '* /a/any']); + }); + + it('applies each controller its own prefix when a file declares several', () => { + expect( + urls(` + @Controller('one') + export class One { @Get('x') x() {} } + + @Controller('two') + export class Two { @Get('y') y() {} } + `), + ).toEqual(['GET /one/x', 'GET /two/y']); + }); + + it('carries stacked decorators through to the route', () => { + expect( + urls(` + @Controller('secure') + export class SecureController { + @UseGuards(AuthGuard) + @Get('me') + me() {} + } + `), + ).toEqual(['GET /secure/me']); + }); + + it('sees through a comment between the decorators and the method', () => { + // Found on a real controller: four stacked decorators, then a JSDoc block, + // then the method. Breaking the backward walk at the comment made the + // entire route invisible. + expect( + urls(` + @Controller('dev') + export class DevController { + @Post('simulate-expiry') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'x' }) + /** + * Simulate an expiry. + */ + simulateExpiry() {} + } + `), + ).toEqual(['POST /dev/simulate-expiry']); + }); + + it('sees through a comment between @Controller and the class', () => { + expect( + urls(` + @Controller('docs') + /** The controller. */ + export class DocsController { + @Get('x') x() {} + } + `), + ).toEqual(['GET /docs/x']); + }); + + // ─── Precision guards ────────────────────────────────────────────── + + it('ignores verb-named decorators on a class that is not a @Controller', () => { + // `Get`/`Post` are ordinary identifiers; without the @Controller + // requirement any library reusing those names mints phantom endpoints. + // The unrelated controller is what makes this test reach the per-class + // check: without a `@Controller` anywhere the file short-circuits at the + // parse-free substring gate and the assertion proves nothing. + expect( + urls(` + @Controller('y') + export class RealController { + @Get('real') + real() {} + } + + @Injectable() + export class NotAController { + @Get('looks-like-a-route') + nope() {} + } + `), + ).toEqual(['GET /y/real']); + }); + + it.each([ + { label: 'a constant it cannot read', argument: 'ROUTES.SEARCH' }, + { label: 'an interpolated template', argument: '`${prefix}/search`' }, + { label: 'an array with one element it cannot read', argument: "['a', ROUTES.ADMIN]" }, + ])('drops a route whose path is $label', ({ argument }) => { + // A wrong URL is worse than a missing one — route_map presents this as fact. + // The array row is why one bad element poisons the whole array rather than + // emitting its readable siblings: a half-mapped controller reads as a fully + // mapped one, which is the same lie with less to notice. + expect( + extract(` + @Controller('x') + export class C { + @Get(${argument}) + search() {} + } + `), + ).toEqual([]); + }); + + it('drops every route of a controller whose prefix cannot be read', () => { + expect( + extract(` + @Controller(BASE_PATH) + export class C { + @Get('search') + search() {} + } + `), + ).toEqual([]); + }); + + it('returns nothing for a file with no @Controller at all', () => { + expect(extract(`export function get() { return 1; }`)).toEqual([]); + }); + + // ─── Literal decoding ────────────────────────────────────────────── + + it('decodes an escape in a path instead of deleting it', () => { + // The source below spells the Nest regex param the way a controller does, + // `@Get(':id(\\d+)')`, whose runtime value is `:id(\d+)`. tree-sitter SPLITS + // that literal around the escape_sequence, so keeping only the + // string_fragment children and joining them yielded `:id(d+)` — a URL the + // app never serves, i.e. the wrong-path outcome this module calls worse + // than a missing one. + const [route] = extract(` + @Controller('users') + export class UserController { + @Get(':id(\\\\d+)') + one() {} + } + `); + expect(route.routePath).toBe(':id(\\d+)'); + }); + + it('decodes a unicode escape rather than dropping its payload', () => { + expect( + urls(` + @Controller('v') + export class C { + @Get('/v\\u0069ews') + views() {} + } + `), + ).toEqual(['GET /v/views']); + }); + + it("treats an empty @Controller('') as carrying no prefix", () => { + const [route] = extract(` + @Controller('') + export class C { + @Get('a') a() {} + } + `); + expect(route.prefix).toBeNull(); + expect(normalizeExtractedRoutePath(route.routePath, route.prefix ?? null)).toBe('/a'); + }); + + // ─── Multi-path (array form) ─────────────────────────────────────── + + it('emits one route per path for the array form', () => { + // `@Get(['a','b'])` mounts the handler at BOTH URLs, so both are routes. + // N paths needs no new field to say so: N routes is what an + // ExtractedDecoratorRoute[] already is, the same representation spring.ts + // uses for `@GetMapping({"/a","/b"})`. + const routes = extract(` + @Controller('x') + export class C { + @Get(['a', 'b']) + search() {} + } + `); + + expect(format(routes)).toEqual(['GET /x/a', 'GET /x/b']); + // Everything other than the path is the same route twice — in particular + // the handler, or only one of the two URLs would resolve to a symbol. + expect(routes.map((r) => r.handlerName)).toEqual(['search', 'search']); + }); + + it('reads a single-element array as that one path', () => { + expect( + urls(` + @Controller(['a']) + export class C { + @Get(['b']) b() {} + } + `), + ).toEqual(['GET /a/b']); + }); + + it('emits nothing for an empty array path, and drops only the route it skips', () => { + // `@Get([])` is legal and mounts no URL. It is neither a pathless `@Get()` + // nor an unreadable path: reading it as the first would mint `GET /x`, a + // URL the app does not serve. + expect( + extract(` + @Controller('x') + export class C { + @Get([]) none() {} + } + `), + ).toEqual([]); + + // Whichever the reason a path yields no route — knowably empty, or + // unreadable — it costs exactly its own route and not the controller's + // others, which is what makes a per-decorator skip safe. + expect( + urls(` + @Controller('x') + export class C { + @Get([]) none() {} + @Get(ROUTES.ADMIN) admin() {} + @Get('a') a() {} + } + `), + ).toEqual(['GET /x/a']); + }); + + it('decodes escapes inside array elements too', () => { + // Each element goes through the same `plainString` a scalar path does, so + // the split-around-escape_sequence trap cannot come back on this arm alone. + expect( + extract(` + @Controller('u') + export class C { + @Get([':id(\\\\d+)', '/v\\u0069ews']) + one() {} + } + `).map((r) => r.routePath), + ).toEqual([':id(\\d+)', '/views']); + }); + + // ─── Controller shapes ───────────────────────────────────────────── + + it('extracts routes from an abstract controller base class', () => { + // `export abstract class` parses as abstract_class_declaration, a separate + // node type — and a decorated abstract base sharing CRUD routes with its + // subclasses is ordinary Nest, so missing it drops the whole controller. + expect( + urls(` + @Controller('base') + export abstract class BaseController { + @Get('a') a() {} + } + `), + ).toEqual(['GET /base/a']); + }); + + it('reads the path out of the object form used for URI versioning', () => { + expect( + urls(` + @Controller({ path: 'cats', version: '1' }) + export class CatsController { + @Get('breeds') breeds() {} + } + `), + ).toEqual(['GET /cats/breeds']); + }); + + it('reads a quoted path key, rather than dropping the class over the quotes', () => { + expect( + urls(` + @Controller({ 'path': 'cats' }) + export class CatsController { + @Get('breeds') breeds() {} + } + `), + ).toEqual(['GET /cats/breeds']); + }); + + it.each([ + { label: 'no path key', argument: "{ version: '1' }" }, + { label: 'a computed path', argument: '{ path: BASE_PATH }' }, + { label: 'a computed path key', argument: "{ [PATH_KEY]: 'cats' }" }, + ])('still drops a controller whose object form has $label', ({ argument }) => { + expect( + extract(` + @Controller(${argument}) + export class C { + @Get('a') a() {} + } + `), + ).toEqual([]); + }); + + // A `path` pair proves the mount point only when nothing ELSE in the object + // can replace it. `{ path: 'cats', ...options }` reads as `cats` under a + // first-match scan and mounts wherever `options.path` says at runtime; + // `{ path: 'cats', path: 'dogs' }` mounts at `dogs`. Both are the wrong-URL + // outcome this module calls worse than a missing one, and both are silent — + // a published `/cats` looks exactly like a correct one. So the object is read + // only when every member is a named, non-repeated pair: the whole-entry + // fail-closed shape `routeFromObject` uses in data-route-table.ts. + it.each([ + // `options.path` overrides the pair above it, so the extracted prefix and + // the served prefix disagree with nothing in the file to say so. + { label: 'a trailing spread', argument: "{ path: 'cats', ...options }" }, + // Deterministically SAFE under JS evaluation order — a later `path` pair + // always wins over an earlier spread — and refused anyway. Reading member + // order as proof makes the verdict turn on which side of the spread the + // author happened to type `path`, and how often each spelling occurs in + // real controllers is unmeasured. Meanwhile the two failure directions are + // not symmetric: reading it wrong publishes a URL the app never serves, and + // `route_map`/`api_impact` present that as fact, while refusing omits a + // route that is still findable in source. + { label: 'a leading spread', argument: "{ ...options, path: 'cats' }" }, + { label: 'nothing but a spread', argument: '{ ...options }' }, + // Last write wins at runtime, so a first-match scan names the loser. + { label: 'a repeated path key', argument: "{ path: 'cats', path: 'dogs' }" }, + // Visible only through `propertyName`: compared as raw key text, `path` and + // `'path'` are two different keys and the duplicate check never fires. + { + label: 'a repeated path key in its quoted spelling', + argument: "{ path: 'cats', 'path': 'dogs' }", + }, + // Refusal is on ANY repeated key, not only `path` — a duplicate anywhere is + // evidence the object is not the fixed literal it reads as. + { + label: 'a repeated key other than path', + argument: "{ path: 'a', version: '1', version: '2' }", + }, + // A computed key could evaluate to `path` and take the mount with it. + { label: 'a computed key beside the path', argument: "{ path: 'a', [dynamicKey]: 'b' }" }, + // `shorthand_property_identifier` and `method_definition`; neither is a + // `pair`, so neither offers a key/value this file can read. + { label: 'a shorthand property', argument: '{ path }' }, + { label: 'a method', argument: "{ path: 'a', getFoo() {} }" }, + ])('refuses an object form whose path another member could override: $label', ({ argument }) => { + expect( + extract(` + @Controller(${argument}) + export class C { + @Get('b') b() {} + } + `), + ).toEqual([]); + }); + + it.each([ + // A comment between two pairs is ordinary formatting. It has to be skipped + // BEFORE the not-a-pair test above, or the refusal fires on it and costs + // the controller every route it has. + { + label: 'a comment between its pairs', + argument: "{ path: 'a', /* URI versioning */ version: '1' }", + }, + // Only `path` has to be provable. A non-literal value on an unrelated key + // is benign: `containsExecutingExpression` in data-route-table.ts refuses + // these, but it guards whole-entry declarativeness for a static route + // table — a different invariant from "can this member move the mount". + { + label: 'non-literal values on keys other than path', + argument: "{ path: 'a', host: 'x', scope: Scope.REQUEST, durable: true }", + }, + ])('still reads the prefix out of an object form with $label', ({ argument }) => { + expect( + urls(` + @Controller(${argument}) + export class C { + @Get('b') b() {} + } + `), + ).toEqual(['GET /a/b']); + }); + + it.each([ + { label: 'a single path', argument: "{ path: 'a' }" }, + { label: 'an array of paths', argument: "{ path: ['a', 'b'] }" }, + // The verb gate short-circuits on the object form before the object is + // walked at all, so the class-form refusal never gets a say here. + { label: 'an object the class form would also refuse', argument: "{ path: 'a', ...options }" }, + ])('mints nothing from the object form on a VERB decorator ($label)', ({ argument }) => { + // `@Controller` takes the object form; `@Get` and friends take + // `string | string[]`. Nest mounts nothing here, so emitting a route would + // invent a URL — the failure this module exists to avoid, and the reason + // the class prefix below is deliberately readable: the route is dropped + // because the METHOD path is unreadable, not because the class was. + expect( + extract(` + @Controller('x') + export class C { + @Get(${argument}) a() {} + } + `), + ).toEqual([]); + }); + + it.each([ + { label: 'the bare array form', argument: "['a', 'b']" }, + { label: 'an array inside the object form', argument: "{ path: ['a', 'b'] }" }, + ])('declines a controller whose prefix is multi-path: $label', ({ argument }) => { + // Deliberate parity with spring.ts, which detects an array-form class + // @RequestMapping only to SUPPRESS that class, leaving the prefix x method + // cross-product to #2280. The method path here is perfectly readable, so + // the alternative is not "drop one route" but "publish it under one of the + // two prefixes, or none" — URLs the application does not serve. + expect( + extract(` + @Controller(${argument}) + export class C { + @Get('a') a() {} + } + `), + ).toEqual([]); + }); + + it('extracts from a .js controller, where a decorator is a CHILD of the method', () => { + // tree-sitter-javascript nests a method decorator inside method_definition + // rather than placing it before as a sibling. The same extractor serves the + // JavaScript provider, so reading siblings only meant every .js Nest + // controller emitted nothing while the wiring claimed nestjs coverage. + expect( + jsUrls(` + @Controller('venues') + export class VenueController { + @UseGuards(AuthGuard) + @Get('search') + search() {} + } + `), + ).toEqual(['GET /venues/search']); + }); + + // ─── Verb coverage ───────────────────────────────────────────────── + + it('treats @Sse as the GET endpoint it mounts', () => { + expect( + urls(` + @Controller('events') + export class EventsController { + @Sse('stream') stream() {} + } + `), + ).toEqual(['GET /events/stream']); + }); + + it('emits nothing for a decorator that mounts no endpoint', () => { + // Paired with the @Sse case above: without it, an unsupported route + // decorator and a non-route decorator are the same silent []. + expect( + extract(` + @Controller('events') + export class EventsController { + @UseGuards(AuthGuard) guarded() {} + } + `), + ).toEqual([]); + }); + + it.each(['toString', 'constructor'])( + 'does not mint a route for a decorator named @%s', + (name) => { + // The verb table is looked up by decorator name, so a plain object would + // answer `Object.prototype.toString` here — truthy, and emitted verbatim + // as the route's httpMethod. + expect( + extract(` + @Controller('x') + export class C { + @${name}() f() {} + } + `), + ).toEqual([]); + }, + ); + + it("does not carry a decorated property's decorators onto the next method", () => { + // The decorator run is accumulated in one forward pass over the class body; + // a non-method member must reset it, the way the backward walk used to stop. + expect( + urls(` + @Controller('di') + export class C { + @Inject(SERVICE) + private readonly svc: Service; + + @Get('a') a() {} + } + `), + ).toEqual(['GET /di/a']); + }); + + // A Nest route decorator is a FACTORY: `@Get()` invokes it and returns the + // decorator that registers the route. A bare `@Get` is the factory itself, + // never applied to anything, so Nest registers nothing — emitting a route for + // it publishes a URL the app does not serve. `@Get()` with no argument IS a + // real pathless route and must keep working; the difference is the call, not + // the argument list. + it.each([ + { label: 'a bare verb decorator', member: '@Get a() {}' }, + { + label: 'a bare verb decorator beside a real one', + member: "@Get a() {}\n @Post('b') b() {}", + }, + ])('mints no route for $label', ({ member }) => { + expect( + urls(` + @Controller('x') + export class C { + ${member} + } + `).filter((route) => route.startsWith('GET')), + ).toEqual([]); + }); + + it('drops a class whose @Controller is bare rather than invoked', () => { + // Same rule one level up: an uninvoked `@Controller` registers no + // controller, so its methods are not routes either. + expect( + extract(` + @Controller + export class C { + @Get('a') a() {} + } + `), + ).toEqual([]); + }); + + it('still emits a pathless route for an INVOKED decorator with no argument', () => { + // The control: `@Get()` differs from `@Get` by the call, and only the call. + expect( + urls(` + @Controller('x') + export class C { + @Get() a() {} + } + `), + ).toEqual(['GET /x']); + }); + + // ─── Members Nest never registers as handlers ────────────────────── + + /** Routes a controller emits when `member` is its only member. */ + const memberUrls = (member: string) => + urls(` + @Controller('v') + export class C { + ${member} + } + `); + + /** A non-handler followed by a real one — the shape both arms below need. */ + const STATIC_THEN_INSTANCE = ` + @Controller('v') + export class C { + @Get('s') + static s() {} + + @Get('i') + i() {} + } + `; + + // Nest's `RequestMapping` writes the handler onto the class PROTOTYPE's + // `descriptor.value`, and `RouterExplorer` scans prototype instance methods + // for that metadata. A `static` method lives on the constructor and is never + // scanned; an accessor's descriptor carries `get`/`set` and no `value` to + // register. A verb decorator on any of the three mounts NOTHING, so a route + // minted from one is a URL the app does not serve — the same + // wrong-answer-dressed-as-fact the object-form refusals above exist for. + it.each([ + { label: 'a static method', member: "@Get('s') static s() {}" }, + { label: 'a getter', member: "@Get('s') get s(): string { return ''; }" }, + { label: 'a setter', member: "@Get('s') set s(v: string) {}" }, + ])('mints nothing for $label, which Nest never registers as a handler', ({ member }) => { + expect(memberUrls(member)).toEqual([]); + }); + + // The other half of the modifier check, and the half a mutation can actually + // reach: these modifiers must NOT reject. `async` matters most — it is the + // dominant shape of a real Nest handler, so widening the exclusion set to + // include it would silently delete most routes in most Nest repos while the + // table above stayed green. `override` and an accessibility modifier are the + // other tokens that sit in the same position on a `method_definition`, and a + // method merely NAMED `get`/`set`/`static` is a property_identifier, not a + // modifier — it must survive too. + it.each([ + { label: 'an async method', member: "@Get('s') async s() {}" }, + { label: 'a public method', member: "@Get('s') public s() {}" }, + { label: 'a protected method', member: "@Get('s') protected s() {}" }, + { + label: 'an async method with an accessibility modifier', + member: "@Get('s') public async s() {}", + }, + { label: 'a method named get', member: "@Get('s') get() {}" }, + { label: 'a method named static', member: "@Get('s') static() {}" }, + // The control for the mints-nothing table above: identical source minus the + // modifier, which is what makes those three empty results evidence of the + // check rather than of a fixture that happens to parse to nothing. + { label: 'a plain instance method', member: "@Get('s') s() {}" }, + ])('still emits the route for $label', ({ member }) => { + expect(memberUrls(member)).toEqual(['GET /v/s']); + }); + + it('drops a decorated static method under the JavaScript grammar too', () => { + // tree-sitter-javascript makes a method decorator a CHILD of + // `method_definition`, so `children` reads `decorator | static | + // property_identifier | …` and the modifier is NOT at a fixed index — the + // check has to test every child's type. The instance method beside it is + // the in-fixture control: its route proves this .js arm still measures + // something rather than passing on a fixture that parses to nothing. + expect(jsUrls(STATIC_THEN_INSTANCE)).toEqual(['GET /v/i']); + }); + + it("does not donate a non-handler member's decorator run to the method after it", () => { + // Pins the UNCONDITIONAL `pending.length = 0` at the end of the member + // loop, NOT the modifier check: a non-handler must fall through to that + // clear rather than `continue` past it. Deliberately green before the + // modifier check existed too — there the static member consumed the run + // into its own (wrong) route and then cleared it — so this goes red only + // if a future edit adds the early `continue`. Asserted over the routes + // attributed to `i`, because the whole-output form would instead be + // measuring the modifier check the table above already covers. + const routes = extract(STATIC_THEN_INSTANCE); + + expect(format(routes.filter((route) => route.handlerName === 'i'))).toEqual(['GET /v/i']); + }); +}); From 35591b22d0d104f3fa4fc1b1a55243be9f5fa49c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:42:29 +0100 Subject: [PATCH 07/17] chore(deps)(deps-dev): bump @testing-library/jest-dom in /gitnexus-web (#3050) Bumps [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) from 6.9.1 to 7.0.0. - [Release notes](https://github.com/testing-library/jest-dom/releases) - [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md) - [Commits](https://github.com/testing-library/jest-dom/compare/v6.9.1...v7.0.0) --- updated-dependencies: - dependency-name: "@testing-library/jest-dom" dependency-version: 7.0.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gitnexus-web/package-lock.json | 13 ++++++++----- gitnexus-web/package.json | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index a0162ee9c..d4fb6e57b 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -49,7 +49,7 @@ "devDependencies": { "@babel/types": "^8.0.4", "@playwright/test": "^1.62.0", - "@testing-library/jest-dom": "^6.9.1", + "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/dompurify": "^3.2.0", @@ -2091,9 +2091,9 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz", + "integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==", "dev": true, "license": "MIT", "dependencies": { @@ -2105,9 +2105,12 @@ "redent": "^3.0.0" }, "engines": { - "node": ">=14", + "node": ">=22", "npm": ">=6", "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11" } }, "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 046bab225..7de002950 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -59,7 +59,7 @@ "devDependencies": { "@babel/types": "^8.0.4", "@playwright/test": "^1.62.0", - "@testing-library/jest-dom": "^6.9.1", + "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/dompurify": "^3.2.0", From c34468f0f270284e8a441ca431a2081af8a9bf6b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:43:01 +0100 Subject: [PATCH 08/17] chore(deps)(deps): bump react-i18next in /gitnexus-web (#3051) Bumps [react-i18next](https://github.com/i18next/react-i18next) from 17.0.11 to 17.0.12. - [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md) - [Commits](https://github.com/i18next/react-i18next/compare/v17.0.11...v17.0.12) --- updated-dependencies: - dependency-name: react-i18next dependency-version: 17.0.12 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gitnexus-web/package-lock.json | 16 ++++++++-------- gitnexus-web/package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index d4fb6e57b..bd4713fe7 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -36,7 +36,7 @@ "pandemonium": "^2.4.0", "react": "^19.2.5", "react-dom": "^19.2.8", - "react-i18next": "^17.0.11", + "react-i18next": "^17.0.12", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", "react-zoom-pan-pinch": "^4.0.3", @@ -246,9 +246,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -7417,12 +7417,12 @@ } }, "node_modules/react-i18next": { - "version": "17.0.11", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz", - "integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==", + "version": "17.0.12", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.12.tgz", + "integrity": "sha512-lFWPEGkxQ6RhusdUkysFBD58VHfSSzvHBzqMgN0SvfVpdQGfwtNkStTqdy08/sJd7s807qqutgx93fRpD0DJ3Q==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.29.2", + "@babel/runtime": "^7.29.7", "html-parse-stringify": "^4.0.1", "use-sync-external-store": "^1.6.0" }, diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 7de002950..dbb2a23c1 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -46,7 +46,7 @@ "pandemonium": "^2.4.0", "react": "^19.2.5", "react-dom": "^19.2.8", - "react-i18next": "^17.0.11", + "react-i18next": "^17.0.12", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", "react-zoom-pan-pinch": "^4.0.3", From 15274029aa66f0f0146515d6be90c69b01516b69 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:43:17 +0100 Subject: [PATCH 09/17] chore(deps)(deps-dev): bump @vercel/node in /gitnexus-web (#3052) Bumps [@vercel/node](https://github.com/vercel/vercel/tree/HEAD/packages/node) from 5.9.9 to 5.10.1. - [Release notes](https://github.com/vercel/vercel/releases) - [Changelog](https://github.com/vercel/vercel/blob/main/packages/node/CHANGELOG.md) - [Commits](https://github.com/vercel/vercel/commits/@vercel/fs-detectors@5.10.1/packages/node) --- updated-dependencies: - dependency-name: "@vercel/node" dependency-version: 5.10.1 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- gitnexus-web/package-lock.json | 16 ++++++++-------- gitnexus-web/package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index bd4713fe7..d0c3bf24f 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -57,7 +57,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.4", "@types/react-syntax-highlighter": "^15.5.13", - "@vercel/node": "^5.9.9", + "@vercel/node": "^5.10.1", "@vitejs/plugin-react": "^6.0.5", "@vitest/coverage-v8": "^4.1.9", "jsdom": "^29.1.1", @@ -2641,9 +2641,9 @@ } }, "node_modules/@vercel/build-utils": { - "version": "14.0.5", - "resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-14.0.5.tgz", - "integrity": "sha512-ChbTraIvChbcFXMwDPLE8MoWpNGSRhJ2cXsE0V3iJQIVYDRgjFoT6JzWfkuc7w/3ojLLr8eMoae7M1v6OXoC5Q==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-14.1.1.tgz", + "integrity": "sha512-kW9CeW0aokEBvX1rSgNyOKg90VyIQOmT0wBl7KXneM3Qs1+x4Puakqp97BdIgttWEtmN96UvdhQVG2bCA5JsPA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2694,9 +2694,9 @@ } }, "node_modules/@vercel/node": { - "version": "5.9.9", - "resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.9.9.tgz", - "integrity": "sha512-jaMocJLa+rP3WpwYrbx2kUpHObjXK/JZOsbtmodDMAtfXbwl7niPNcEbdYYj/fBPSX8yRUXBF3tQsasocbjD5Q==", + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.10.1.tgz", + "integrity": "sha512-muj+t8sZ2XHQDkWcHxkql2rbvr/HhZOqYdZBG7pw8F5RLasL3o0gjHLXJKwHAEHp2I3fd3AgbMud2oz+hzeV0g==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2704,7 +2704,7 @@ "@edge-runtime/primitives": "4.1.0", "@edge-runtime/vm": "3.2.0", "@types/node": "20.11.0", - "@vercel/build-utils": "14.0.5", + "@vercel/build-utils": "14.1.1", "@vercel/error-utils": "2.2.1", "@vercel/nft": "1.10.0", "@vercel/static-config": "3.4.1", diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index dbb2a23c1..a4642f63d 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -67,7 +67,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.4", "@types/react-syntax-highlighter": "^15.5.13", - "@vercel/node": "^5.9.9", + "@vercel/node": "^5.10.1", "@vitejs/plugin-react": "^6.0.5", "@vitest/coverage-v8": "^4.1.9", "jsdom": "^29.1.1", From 414687ad10677dc331bc438954877ade647cd764 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:43:33 +0100 Subject: [PATCH 10/17] chore(deps): bump docker/setup-buildx-action from 4.2.0 to 4.3.0 (#3057) Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 4.2.0 to 4.3.0. - [Release notes](https://github.com/docker/setup-buildx-action/releases) - [Commits](https://github.com/docker/setup-buildx-action/compare/bb05f3f5519dd87d3ba754cc423b652a5edd6d2c...37fe631027851001ddb9b187196cc803df7f5f0e) --- updated-dependencies: - dependency-name: docker/setup-buildx-action dependency-version: 4.3.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker.yml | 2 +- .github/workflows/trivy.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 73bb80ba5..dac6a7398 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -141,7 +141,7 @@ jobs: uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Install Cosign uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 1e2b2d1e3..2bc3701f3 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -50,7 +50,7 @@ jobs: persist-credentials: false - name: Setup Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Build image (load locally for scan) uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 From fb49613a4d423a6a8f98ba3f47a6c59a22e87457 Mon Sep 17 00:00:00 2001 From: DuduPhudu <34869259+ReidenXerx@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:29:05 +0300 Subject: [PATCH 11/17] fix(ingestion): ignore emitted Next.js build output, and delete the inert public/build entry (#3018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ingestion): ignore emitted Next.js build output, and restore the dead public/build entry `DEFAULT_IGNORE_LIST` contained `.next` — the build CACHE — but not `_next`, the emitted OUTPUT, which are different directories. A Capacitor/Cordova shell copies a built Next.js bundle to `/app/src/main/assets/public/_next/static/`, where no path segment hits the list, so the walker indexed the bundle as source. On a real mobile-wrapped Next.js app that was 256 minified chunk files, and every `Route` node the repo produced pointed at a webpack chunk rather than at source. The filename heuristics did not catch them either: they match `.bundle.`, `.chunk.`, `.generated.` and `.d.ts`, while Next.js emits hashed names like `6862-9d1cdcb99f169a06.js`. Separately, `'public/build'` had been sitting in `DEFAULT_IGNORE_LIST` matching nothing at all. That set is tested one path SEGMENT at a time, and is also read by `isHardcodedIgnoredDirectory(name)`, which receives a bare directory name — so a slash-containing member can never compare equal to anything. Rather than delete the entry and lose its intent, multi-segment paths now live in `DEFAULT_IGNORED_PATH_FRAGMENTS` and are matched against the whole path, so Remix / Laravel Mix asset output is ignored as originally intended. A guard test pins the invariant that made the dead entry possible: no member of the name set may contain a slash. Measured against a production Capacitor-wrapped Next.js app (1558 JS/TS files on disk): 256 newly ignored, none of them under `src/`, and zero files that were previously ignored become indexed. Closes #3007 Co-Authored-By: Claude Opus 5 (1M context) * fix(ingestion): drop the inert public/build machinery, discriminate _next by segment, ignore _next on the web upload path Addresses the review findings on #3018. Remove DEFAULT_IGNORED_PATH_FRAGMENTS, hasIgnoredPathFragment and its shouldIgnorePath branch. The mechanism was correct but unreachable: all four of its match forms put a `/` or end-of-string on both sides of `build`, so a fragment match strictly implies `build` is a whole segment, which the per-segment DEFAULT_IGNORE_LIST loop already catches one branch earlier. Measured over 768,420 generated paths: 65,506 fragment matches, 0 of them decisive, 0 implication violations. `'public/build'` really was an inert member of the name set, but its paths were never unignored — bare `'build'` covered them on both sides — so the entry is deleted rather than relocated, which is the other option #3007 offered. The slash-free guard test stays; it is what stops the next slash-bearing entry from dying the same way. Add negative cases pinning that `_next` matches as a whole path segment. The previous suite could not tell a segment rule from a substring rule: replacing the entry with `normalizedPath.includes('_next')` passed all five tests, while eating `src/_nextgen/index.ts`. Rename the public/build test to what it actually pins — that deleting the inert entry changed no behavior — since it is green on both sides by design. Add `_next` to the web upload filter's EXCLUDED_DIRS. That list is the live browser ingestion path (RepoAnalyzer -> filterRepoFiles -> /api/analyze/upload) and had `.next` but not `_next`, so a Capacitor-wrapped Next.js app uploaded its entire minified tree against the server's 20000-file / 250MB caps for files the analyzer then discards. Co-Authored-By: Claude Opus 5 (1M context) * test(ignore-service): make the single-component set guards able to fail The slash-free guard added for #3007 could not fail. It selected entry lines with startsWith("'") and read only the first quoted token per line, so 'public/build' could return as a backtick string, behind an inline block comment, as a second entry on an existing line, or via .add() and every test stayed green. Prettier and eslint miss the backtick and inline-comment forms too, so CI did not catch them either. U1: remove the duplicate '.serverless' entry so the set can be pinned to one exact number. A Set discarded it, so no ignore behaviour changes. U2/U3: replace the line-based parser with a shared single-pass scanner in test/helpers/ignore-set-source.ts, and extend the guard from DEFAULT_IGNORE_LIST to IGNORED_FILES, ROOT_ARTIFACT_DIRECTORIES and IGNORED_EXTENSIONS, which share the same single-component match contract. The scanner tracks string and comment state together because neither can be removed first: the ignore-list comments quote paths and carry an apostrophe, so matching literals before stripping comments yields phantom slash-bearing entries; and a glob string containing a comment-open sequence makes regex comment-stripping swallow the closing bracket. Only a single pass is correct in both directions. Counts are pinned exactly rather than floored — a floor cannot protect a two-member set and hides a partial parse. Shapes a source parser cannot resolve (spread, interpolation, concatenation, later .add) now throw instead of quietly reporting fewer members, and the parsed names are cross-checked against isHardcodedIgnoredDirectory so parser drift fails without exporting the set. Verified by mutation: all six fail-open spellings now turn the suite red; 187 tests pass, tsc clean. * test(ignore-service): pin that _next prunes the directory, not just its files Every measured benefit of ignoring _next comes from never enumerating the bundle tree, and no file list can observe that: anything under _next is rejected whether the walk pruned the directory or descended and rejected each file. childrenIgnored is the only observation that separates them. The existing build-output tests all call shouldIgnorePath, the leaf predicate, so a refactor moving _next to a shouldIgnorePath-only rule would keep them green while silently restoring the full walk. These assertions close that. Also pins that _next matches as a whole segment (_nextgen and my_next are still walked), and that the `!_next/` negation recovers the directory at any depth — the bare form is the one that works, since `!_next/**` alone never gets tested: childrenIgnored prunes the directory before any descendant pattern is reached. Placed in the .gitnexusignore-negation describe block, which owns mkPath and the tmpdir fixture and is registered in scripts/cross-platform-tests.ts. Verified by mutation: disabling only the pruning branch in childrenIgnored leaves the build-output suite at 26/26 green and turns these assertions red. * test(ignore-service): guard the twin build-output ignore lists against drift _next now lives in two lists in two packages — the analyzer's DEFAULT_IGNORE_LIST and the browser upload filter's EXCLUDED_DIRS — with nothing tying them together. This is the seventh twin-list pair in this repo; the header of receiver-twin-list-drift.test.ts records that the previous ones each shipped a bug when one side moved. Containment runs web -> CLI only, and that is the load-bearing direction: the browser filter decides what the server ever sees, and it reads no .gitnexusignore, so a name it drops that the analyzer would have indexed is silent source loss with no recovery. The reverse is not an error — the analyzer prunes far more aggressively than an upload needs to. .gitnexus is the one exemption and has a mechanism: the walker passes dot: false to glob, so it never enumerates dot-directories. Asserted in both directions so re-adding it to the CLI list or dropping it from the web list both fail. Both sides are source-parsed through the shared helper. DEFAULT_IGNORE_LIST is module-private, and no test in this package imports across the package boundary — every cross-package precedent reads source instead. Also corrects the documentation this PR's comments got wrong: the guard test is cited by path rather than as "below", the unreproducible per-repo percentage is gone, the reason _next is deliberately unanchored is recorded next to the entry (no /_next form matches a root-level _next/static/…), and the upload filter now states that it consults no repository ignore rules — so unlike the CLI, a negation cannot recover what it drops. Verified by mutation: a web-only addition and a CLI removal each turn the guard red. 194 targeted tests pass; tsc clean in both packages. * refactor(test): read the ignore sets with the TypeScript parser, not a hand-rolled scanner The guards read ignore-service.ts as source because the sets are module-private. The first pass hand-rolled a character scanner to do it, and the repo already vendors the right tool: ts.createSourceFile, used this way in literal-collectors, query-determinism-guard, cli-index-help and group/sync-partial-extraction. The scanner had two silent gaps a real parser does not have: - It rejected `${` by substring, but template literals were consumed whole, so that branch could never fire and an interpolated member was accepted as a literal — the exact under-report the file refused to allow. - It took the first `[` after the marker, which on a type-annotated declaration (`readonly string[] = ...`) is the annotation's empty pair. It returned [] with no throw, which would make every assertion in a suite vacuously true. This is the hazard receiver-twin-list-drift.test.ts documents having hit. Reading the declaration node removes both, along with the comment-vs-string ordering problem that motivated the scanner: a parser cannot mistake a comment for a string or a glob's `/*` for a comment-open. Also drops the four pinned exact counts. They were a ratchet — these sets are edited by unrelated PRs, each of which would have failed a count assertion about nothing it touched — and with a real parser the partial-parse hazard they existed to catch cannot happen silently: a member that is not a plain string literal throws. Markers collapse to set names, and the duplicated path-resolution boilerplate moves into the helper the two suites already share. Net 187 deletions against 123 insertions. Verified by mutation: backtick, inline comment, same-line, double-quote, duplicate, interpolation, spread and runtime .add() are all caught; a type-annotated declaration now reads correctly instead of returning empty. 194 tests pass, tsc clean. --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Gergo Magyar --- gitnexus-web/src/lib/upload-filter.test.ts | 16 +++ gitnexus-web/src/lib/upload-filter.ts | 11 ++ gitnexus/src/config/ignore-service.ts | 23 +++- gitnexus/test/helpers/ignore-set-source.ts | 107 +++++++++++++++ .../test/unit/ignore-build-output.test.ts | 127 ++++++++++++++++++ gitnexus/test/unit/ignore-service.test.ts | 32 +++++ .../unit/upload-filter-ignore-drift.test.ts | 90 +++++++++++++ 7 files changed, 404 insertions(+), 2 deletions(-) create mode 100644 gitnexus/test/helpers/ignore-set-source.ts create mode 100644 gitnexus/test/unit/ignore-build-output.test.ts create mode 100644 gitnexus/test/unit/upload-filter-ignore-drift.test.ts diff --git a/gitnexus-web/src/lib/upload-filter.test.ts b/gitnexus-web/src/lib/upload-filter.test.ts index e97b9ec2c..1943720ba 100644 --- a/gitnexus-web/src/lib/upload-filter.test.ts +++ b/gitnexus-web/src/lib/upload-filter.test.ts @@ -31,6 +31,22 @@ describe('filterRepoFiles', () => { expect(r.droppedCount).toBe(4); }); + it('excludes emitted _next output, including the Capacitor/Cordova copy', () => { + // `.next` was listed but `_next` was not, so a mobile-wrapped Next.js app + // uploaded its whole minified bundle against the server's caps for files + // the analyzer then discards anyway (#3007). + const input = [ + f('repo/android/app/src/main/assets/public/_next/static/chunks/main.js'), + f('repo/ios/App/App/public/_next/static/chunks/framework.js'), + f('repo/_next/static/chunks/x.js'), + f('repo/src/index.ts'), + f('repo/src/_nextgen/index.ts'), + ]; + const r = filterRepoFiles(input); + expect(r.manifest).toEqual(['repo/src/index.ts', 'repo/src/_nextgen/index.ts']); + expect(r.droppedCount).toBe(3); + }); + it('drops files over the per-file size cap', () => { const input = [f('repo/big.bin', MAX_FILE_BYTES + 1), f('repo/small.ts', 10)]; const r = filterRepoFiles(input); diff --git a/gitnexus-web/src/lib/upload-filter.ts b/gitnexus-web/src/lib/upload-filter.ts index a24520f74..9b3fb30db 100644 --- a/gitnexus-web/src/lib/upload-filter.ts +++ b/gitnexus-web/src/lib/upload-filter.ts @@ -22,6 +22,17 @@ export const EXCLUDED_DIRS = new Set([ 'build', 'out', '.next', + // `.next` is the build CACHE, `_next` the EMITTED output — different + // directories. A Capacitor/Cordova shell leaves the emitted bundle at + // `/app/src/main/assets/public/_next/`, so without this the whole + // minified tree is uploaded against the server's file/byte caps only to be + // discarded by the analyzer's own ignore list (#3007). + // + // This pre-filter reads no repository ignore rules, so unlike the CLI walker + // a `.gitnexusignore` negation cannot recover anything dropped here. Names + // added below must therefore stay a subset of the analyzer's own list; see + // `gitnexus/test/unit/upload-filter-ignore-drift.test.ts`. + '_next', '.nuxt', '.cache', 'coverage', diff --git a/gitnexus/src/config/ignore-service.ts b/gitnexus/src/config/ignore-service.ts index 645754427..f1b9e1150 100644 --- a/gitnexus/src/config/ignore-service.ts +++ b/gitnexus/src/config/ignore-service.ts @@ -58,13 +58,33 @@ const DEFAULT_IGNORE_LIST = new Set([ 'obj', 'target', // Java/Rust '.next', + // `.next` is Next.js's build CACHE; `_next` is the EMITTED output, and the two + // are different directories. A Capacitor/Cordova shell copies the emitted + // bundle to `/app/src/main/assets/public/_next/static/…`, where none + // of the path segments hit this list — so a mobile-wrapped Next.js app had its + // shipped bundle indexed as source, and every Route node it produced pointed at + // a webpack chunk rather than code anyone wrote (#3007). + // + // The name is deliberately unanchored. No `/_next` form matches a + // root-level `_next/static/…`, which is the shape the reported repo has, so + // anchoring it would miss the case it was added for. The accepted cost is a + // hand-written directory literally named `_next`; recover one with a bare + // `!_next/` line in `.gitnexusignore`. + '_next', '.nuxt', '.output', '.vercel', '.netlify', '.serverless', '_build', - 'public/build', + // `'public/build'` used to sit here. This set is tested one path SEGMENT at a + // time, and `isHardcodedIgnoredDirectory(name)` takes a bare directory name, + // so a slash-containing member could never match either — it was inert. Its + // paths were never unignored though: bare `'build'` above already prunes + // `public/build/**`, so removing the entry changes no behavior (#3007). + // `test/unit/ignore-build-output.test.ts` keeps the next slash-bearing entry + // in this set — or in IGNORED_FILES, ROOT_ARTIFACT_DIRECTORIES or + // IGNORED_EXTENSIONS — from dying the same way. '.parcel-cache', '.turbo', '.svelte-kit', @@ -95,7 +115,6 @@ const DEFAULT_IGNORE_LIST = new Set([ // remains covered by .gitignore/.gitnexusignore and the unambiguous names. 'monaco-workers', // Monaco editor web-worker bundles generated for browser runtime '.terraform', - '.serverless', // Documentation (optional - might want to keep) // 'docs', diff --git a/gitnexus/test/helpers/ignore-set-source.ts b/gitnexus/test/helpers/ignore-set-source.ts new file mode 100644 index 000000000..04db55dd6 --- /dev/null +++ b/gitnexus/test/helpers/ignore-set-source.ts @@ -0,0 +1,107 @@ +/** + * Reads the bare-name sets in `src/config/ignore-service.ts` out of source. + * + * Those sets are module-private, and exporting them purely to be testable would + * widen a production surface to satisfy a test — the call + * `receiver-twin-list-drift.test.ts` documents. So the guards read the source + * instead, through the TypeScript parser the repo already vendors and already + * uses this way (`literal-collectors.ts`, `query-determinism-guard.test.ts`, + * `cli-index-help.test.ts`). + * + * Using the real parser is what makes the guards trustworthy. A text scanner has + * to decide whether a delimiter opens a comment or sits inside a string, and it + * gets that wrong in both directions here: the ignore-list comments quote paths + * and carry an apostrophe (`Next.js's`), while a glob string such as `'** / *'` + * contains a comment-open sequence. It also has to guess which bracket belongs + * to the declaration rather than to a type annotation. Each of those is a way to + * silently read fewer members — and a guard that quietly stops seeing members is + * the exact defect these guards exist to catch. + * + * `setEntries` therefore refuses anything that is not a plain list of string + * literals, rather than skipping the members it cannot resolve. + */ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); + +/** The analyzer's ignore rules — the sets every guard in this family reads. */ +export const IGNORE_SERVICE_PATH = path.join( + REPO_ROOT, + 'gitnexus', + 'src', + 'config', + 'ignore-service.ts', +); + +/** The browser upload pre-filter, whose excluded-directory set must not drift from the above. */ +export const UPLOAD_FILTER_PATH = path.join( + REPO_ROOT, + 'gitnexus-web', + 'src', + 'lib', + 'upload-filter.ts', +); + +export const readSource = (file: string): string => readFileSync(file, 'utf8'); + +/** + * The string literals `setName` is constructed from, in declaration order. + * + * Throws — never returns a short list — when the declaration is missing or holds + * anything other than plain string literals (a spread, an interpolation, a + * concatenation, a computed value). + */ +export const setEntries = (source: string, setName: string): string[] => { + const sourceFile = ts.createSourceFile( + 'ignore-set-source.ts', + source, + ts.ScriptTarget.Latest, + true, + ); + + let elements: ts.NodeArray | undefined; + const visit = (node: ts.Node): void => { + if ( + elements === undefined && + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.name.text === setName && + node.initializer !== undefined && + ts.isNewExpression(node.initializer) && + node.initializer.arguments?.length === 1 && + ts.isArrayLiteralExpression(node.initializer.arguments[0]) + ) { + elements = node.initializer.arguments[0].elements; + return; + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + + if (elements === undefined) { + throw new Error(`${setName} is not declared as \`new Set([...])\` — update this test`); + } + + const unresolvable = elements.filter((element) => !ts.isStringLiteral(element)); + if (unresolvable.length > 0) { + throw new Error( + `${setName} holds ${unresolvable.length} member(s) that are not plain string literals ` + + `(first: \`${unresolvable[0].getText(sourceFile)}\`). A source-reading guard cannot resolve ` + + `those, so switch this set to a runtime assertion rather than letting the guard see fewer members.`, + ); + } + + return elements.map((element) => (element as ts.StringLiteral).text); +}; + +/** + * True when `setName` is mutated by `.add(...)` anywhere in `source`. + * + * `setEntries` reads the declaration only, so a member appended afterwards would + * be invisible to it. The guards assert this is false rather than under-reporting. + */ +export const hasRuntimeAdd = (source: string, setName: string): boolean => + new RegExp(`\\b${setName}\\s*\\.\\s*add\\s*\\(`).test(source); diff --git a/gitnexus/test/unit/ignore-build-output.test.ts b/gitnexus/test/unit/ignore-build-output.test.ts new file mode 100644 index 000000000..7e8a87a29 --- /dev/null +++ b/gitnexus/test/unit/ignore-build-output.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from 'vitest'; +import { isHardcodedIgnoredDirectory, shouldIgnorePath } from '../../src/config/ignore-service.js'; +import { + IGNORE_SERVICE_PATH, + hasRuntimeAdd, + readSource, + setEntries, +} from '../helpers/ignore-set-source.js'; + +/** + * Emitted build output must not be indexed as source (#3007). + * + * `.next` (the build cache) was listed but `_next` (the emitted output) was + * not, so a Capacitor/Cordova shell that copies a Next.js bundle into + * `/app/src/main/assets/public/_next/static/` had its shipped bundle + * indexed as source — and every `Route` node the repo produced pointed at a + * webpack bundle instead of code anyone wrote. + */ + +describe('build-output ignores', () => { + it('ignores emitted _next output, including the Capacitor/Cordova copy', () => { + expect(shouldIgnorePath('_next/static/chunks/main.js')).toBe(true); + expect(shouldIgnorePath('.next/server/app/page.js')).toBe(true); + expect( + shouldIgnorePath( + 'android/app/src/main/assets/public/_next/static/chunks/6862-9d1cdcb99f169a06.js', + ), + ).toBe(true); + expect(shouldIgnorePath('ios/App/App/public/_next/static/chunks/framework-abc123.js')).toBe( + true, + ); + }); + + it('does not ignore ordinary source that merely mentions next', () => { + expect(shouldIgnorePath('src/next-steps.ts')).toBe(false); + expect(shouldIgnorePath('src/nextConfig/index.ts')).toBe(false); + expect(shouldIgnorePath('packages/next-auth/src/index.ts')).toBe(false); + }); + + it('matches _next as a whole segment, not as a substring', () => { + // Without these, `normalizedPath.includes('_next')` would satisfy every + // other assertion in this file — the suite could not tell a segment rule + // from a substring rule, and a substring rule would eat real source. + expect(shouldIgnorePath('src/_nextgen/index.ts')).toBe(false); + expect(shouldIgnorePath('packages/my_next/src/index.ts')).toBe(false); + expect(shouldIgnorePath('src/prefix_next.ts')).toBe(false); + }); + + it('keeps public/build ignored after the inert name-set entry was removed', () => { + // NOT a regression test for new behavior — it pins that DELETING the inert + // `'public/build'` entry changed nothing, because bare `'build'` matches + // these as an ordinary segment and always did. Green on both sides of the + // change by design; that is the point. + expect(shouldIgnorePath('public/build/entry.client.js')).toBe(true); + expect(shouldIgnorePath('apps/web/public/build/manifest.js')).toBe(true); + }); + + it('does not ignore public/ or build/-adjacent source outside that pair', () => { + expect(shouldIgnorePath('public/favicon-loader.ts')).toBe(false); + expect(shouldIgnorePath('src/public/api.ts')).toBe(false); + }); + + describe('single-component set guards', () => { + const source = readSource(IGNORE_SERVICE_PATH); + + // Every one of these sets is compared against a single path component, so a + // member containing `/` is dead on arrival — the defect that left + // `'public/build'` inert. + const SET_NAMES = [ + 'DEFAULT_IGNORE_LIST', + 'IGNORED_FILES', + 'ROOT_ARTIFACT_DIRECTORIES', + 'IGNORED_EXTENSIONS', + ] as const; + + it.each(SET_NAMES)('%s holds no slash-bearing member', (setName) => { + expect(setEntries(source, setName).filter((entry) => entry.includes('/'))).toEqual([]); + }); + + it.each(SET_NAMES)('%s holds no duplicate member', (setName) => { + const entries = setEntries(source, setName); + expect(new Set(entries).size).toBe(entries.length); + }); + + it.each(SET_NAMES)('%s is never mutated by .add() after construction', (setName) => { + expect(hasRuntimeAdd(source, setName)).toBe(false); + }); + + it('every IGNORED_EXTENSIONS member starts with a dot', () => { + const entries = setEntries(source, 'IGNORED_EXTENSIONS'); + expect(entries.filter((entry) => !entry.startsWith('.'))).toEqual([]); + }); + + it('reads entries the declaration holds, not text the comments quote', () => { + // The comments in DEFAULT_IGNORE_LIST quote paths and carry an apostrophe + // (`Next.js's`), so a text scanner reads phantom entries out of them — + // several slash-bearing, which would fail the assertion above on correct + // source. Parsing the declaration cannot see comments at all. + const entries = setEntries(source, 'DEFAULT_IGNORE_LIST'); + expect(entries).toContain('_next'); + expect(entries).not.toContain('public/build'); + expect(entries).not.toContain('env/'); + }); + + it('agrees with the runtime set it claims to describe', () => { + // Catches drift between what the guard reads and what the module does, + // without exporting the set. + const entries = setEntries(source, 'DEFAULT_IGNORE_LIST'); + expect(entries.filter((entry) => !isHardcodedIgnoredDirectory(entry))).toEqual([]); + }); + + it('fails loudly when a set is no longer declared as a Set of literals', () => { + expect(() => setEntries(source, 'NOT_A_REAL_SET')).toThrow(/update this test/); + }); + + it('refuses a declaration whose members it cannot resolve', () => { + // A spread, an interpolation, or a concatenation resolves at runtime, not + // in source. Reading the resolvable members and passing is the failure mode + // these guards exist to prevent, so the reader refuses instead. + const poisoned = source.replace( + 'const ROOT_ARTIFACT_DIRECTORIES = new Set([', + 'const ROOT_ARTIFACT_DIRECTORIES = new Set([...OTHER_NAMES,', + ); + expect(() => setEntries(poisoned, 'ROOT_ARTIFACT_DIRECTORIES')).toThrow(/not plain string/); + }); + }); +}); diff --git a/gitnexus/test/unit/ignore-service.test.ts b/gitnexus/test/unit/ignore-service.test.ts index 4fb5536ef..60d6e8f77 100644 --- a/gitnexus/test/unit/ignore-service.test.ts +++ b/gitnexus/test/unit/ignore-service.test.ts @@ -334,6 +334,38 @@ describe('.gitnexusignore negation overrides hardcoded DEFAULT_IGNORE_LIST (#771 expect(filter.childrenIgnored(mkPath('Env'))).toBe(false); }); + // `_next` has to prune the DIRECTORY, not merely reject each file underneath. + // Every measured benefit of ignoring it comes from never enumerating the + // bundle tree, and no file list can show the difference — anything under + // `_next` is rejected either way. `childrenIgnored` is the only observation + // that distinguishes them, so a refactor that moved `_next` to a + // `shouldIgnorePath`-only rule would keep the build-output suite green while + // silently restoring the full walk. + it('prunes emitted _next output as a directory, at any depth', async () => { + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.childrenIgnored(mkPath('_next'))).toBe(true); + expect(filter.childrenIgnored(mkPath('android/app/src/main/assets/public/_next'))).toBe(true); + }); + + it('matches _next as a whole segment, so _nextgen source is still walked', async () => { + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.childrenIgnored(mkPath('src/_nextgen'))).toBe(false); + expect(filter.childrenIgnored(mkPath('packages/my_next'))).toBe(false); + }); + + it('`!_next/` negation unlocks the emitted output directory at any depth', async () => { + // The bare form is the one that works. `!_next/**` alone is a silent no-op: + // `childrenIgnored` prunes the directory before any descendant pattern is + // ever tested, so no file underneath reaches `ignored`. + await fs.writeFile(path.join(tmpDir, '.gitnexusignore'), '!_next/\n'); + const filter = await createIgnoreFilter(tmpDir); + + expect(filter.childrenIgnored(mkPath('_next'))).toBe(false); + expect(filter.childrenIgnored(mkPath('android/app/src/main/assets/public/_next'))).toBe(false); + }); + it('prunes a nested env directory only when pyvenv.cfg identifies a virtual environment', async () => { await fs.mkdir(path.join(tmpDir, 'backend', 'env'), { recursive: true }); await fs.writeFile(path.join(tmpDir, 'backend', 'env', 'pyvenv.cfg'), 'home = python\n'); diff --git a/gitnexus/test/unit/upload-filter-ignore-drift.test.ts b/gitnexus/test/unit/upload-filter-ignore-drift.test.ts new file mode 100644 index 000000000..0eea8b06e --- /dev/null +++ b/gitnexus/test/unit/upload-filter-ignore-drift.test.ts @@ -0,0 +1,90 @@ +/** + * The drift guard for the twin build-output ignore lists (#3007 follow-up). + * + * TWO lists spell "do not index this directory", in two packages: + * + * - `DEFAULT_IGNORE_LIST` — gitnexus `src/config/ignore-service.ts`. The + * analyzer's own list, consulted for every path during the repository walk. + * - `EXCLUDED_DIRS` — gitnexus-web `src/lib/upload-filter.ts`. A client-side + * pre-filter that decides what a browser folder upload sends at all. + * + * `_next` was added to both in the same PR, one commit apart. Before it, neither + * carried the name — so #3007 was a shared omission rather than drift between + * them. What this test guards is the divergence that becomes possible now that + * the same name lives in two places with nothing tying them together. + * + * The containment runs web -> CLI only, and that direction is the load-bearing + * one: the browser filter decides what the server ever sees, so a name it drops + * that the analyzer would have indexed is silent source loss with no recovery — + * this pre-filter reads no `.gitnexusignore`, so a negation cannot bring the + * files back. The reverse direction is not an error: roughly sixty CLI-only + * names exist because the analyzer prunes far more aggressively than an upload + * needs to, and the walker's own `dot: false` already hides dot-directories from + * it. That asymmetry is why equality is not the assertion. + * + * `.gitnexus` is the one deliberate exception, and it has a mechanism rather + * than being an oversight: the CLI walker passes `dot: false` to glob + * (`src/core/ingestion/filesystem-walker.ts`), so it never enumerates + * dot-directories and does not need the name in its list. The browser filter has + * no equivalent and must name it. That exemption is asserted explicitly in both + * directions, so re-adding `.gitnexus` to the CLI list or dropping it from the + * web list both fail loudly. + * + * Both sides are read from source rather than imported. `DEFAULT_IGNORE_LIST` is + * module-private. `EXCLUDED_DIRS` is exported, but `upload-filter.ts` types its + * inputs with the DOM `File` interface, which does not resolve under this + * package's `lib: ["ES2022"] / types: ["node"]` — so importing it here would not + * typecheck. + */ +import { describe, it, expect } from 'vitest'; +import { + IGNORE_SERVICE_PATH, + UPLOAD_FILTER_PATH, + readSource, + setEntries, +} from '../helpers/ignore-set-source.js'; + +const cliNames = () => setEntries(readSource(IGNORE_SERVICE_PATH), 'DEFAULT_IGNORE_LIST'); +const webNames = () => setEntries(readSource(UPLOAD_FILTER_PATH), 'EXCLUDED_DIRS'); + +/** + * Names the browser filter may drop that the analyzer does not list. + * + * Only `.gitnexus`, and only because the CLI walker's `dot: false` makes the + * entry unnecessary there. A name added here must cite a comparable structural + * reason in the walker — this list is not a place to park a failing assertion. + */ +const WEB_ONLY_ALLOWLIST = ['.gitnexus']; + +describe('build-output ignore lists stay in agreement across packages', () => { + it('parses both lists — neither is silently empty', () => { + // Guards the guard: a parser that read nothing would make every containment + // assertion below vacuously true. + expect(cliNames().length).toBeGreaterThan(20); + expect(webNames().length).toBeGreaterThan(5); + }); + + it('every name the browser filter drops is one the analyzer also ignores', () => { + const cli = new Set(cliNames()); + const unmatched = webNames().filter( + (name) => !cli.has(name) && !WEB_ONLY_ALLOWLIST.includes(name), + ); + expect(unmatched).toEqual([]); + }); + + it('carries the documented web-only exemption, and only that one', () => { + // Asserted separately from the containment above so the intent survives if + // that assertion is ever relaxed. + expect(webNames()).toContain('.gitnexus'); + expect(cliNames()).not.toContain('.gitnexus'); + }); + + it('shares the build-output names the reported bug was about', () => { + const cli = new Set(cliNames()); + const web = new Set(webNames()); + for (const name of ['_next', '.next', 'dist', 'build', 'out']) { + expect(web.has(name), `${name} missing from the browser upload filter`).toBe(true); + expect(cli.has(name), `${name} missing from the analyzer ignore list`).toBe(true); + } + }); +}); From 3113803c8bcc6895fd0051d9fc3666f0dcbac4d8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:29:24 +0100 Subject: [PATCH 12/17] chore(deps)(deps-dev): bump @testing-library/user-event in /gitnexus-web (#3054) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [@testing-library/user-event](https://github.com/testing-library/user-event) from 14.6.1 to 14.6.6. - [Release notes](https://github.com/testing-library/user-event/releases) - [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md) - [Commits](https://github.com/testing-library/user-event/compare/v14.6.1...v14.6.6) --- updated-dependencies: - dependency-name: "@testing-library/user-event" dependency-version: 14.6.6 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Gergő Magyar --- gitnexus-web/package-lock.json | 8 ++++---- gitnexus-web/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index d0c3bf24f..14594e8fd 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -51,7 +51,7 @@ "@playwright/test": "^1.62.0", "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "^14.6.1", + "@testing-library/user-event": "^14.6.6", "@types/dompurify": "^3.2.0", "@types/node": "^26.0.1", "@types/react": "^19.2.14", @@ -2149,9 +2149,9 @@ } }, "node_modules/@testing-library/user-event": { - "version": "14.6.1", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", - "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "version": "14.6.6", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.6.tgz", + "integrity": "sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw==", "dev": true, "license": "MIT", "engines": { diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index a4642f63d..277d85945 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -61,7 +61,7 @@ "@playwright/test": "^1.62.0", "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "^14.6.1", + "@testing-library/user-event": "^14.6.6", "@types/dompurify": "^3.2.0", "@types/node": "^26.0.1", "@types/react": "^19.2.14", From f1b8faec93a73a8e9fdc9fc2b23de000df48472e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:29:37 +0100 Subject: [PATCH 13/17] chore(deps)(deps): bump uuid from 14.0.1 to 14.0.2 in /gitnexus-web (#3055) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [uuid](https://github.com/uuidjs/uuid) from 14.0.1 to 14.0.2. - [Release notes](https://github.com/uuidjs/uuid/releases) - [Changelog](https://github.com/uuidjs/uuid/blob/main/CHANGELOG.md) - [Commits](https://github.com/uuidjs/uuid/compare/v14.0.1...v14.0.2) --- updated-dependencies: - dependency-name: uuid dependency-version: 14.0.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Gergő Magyar --- gitnexus-web/package-lock.json | 8 ++++---- gitnexus-web/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 14594e8fd..82f082d15 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -43,7 +43,7 @@ "remark-gfm": "^4.0.1", "sigma": "^3.0.3", "tailwindcss": "^4.3.3", - "uuid": "^14.0.1", + "uuid": "^14.0.2", "zod": "^4.4.3" }, "devDependencies": { @@ -8316,9 +8316,9 @@ } }, "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" diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 277d85945..440bf4a54 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -53,7 +53,7 @@ "remark-gfm": "^4.0.1", "sigma": "^3.0.3", "tailwindcss": "^4.3.3", - "uuid": "^14.0.1", + "uuid": "^14.0.2", "zod": "^4.4.3" }, "devDependencies": { From 31c9d9223eb5c1f3491c9f8fa94d9fd60f11fa86 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:29:52 +0100 Subject: [PATCH 14/17] chore(deps): bump the codeql-action group with 3 updates (#3056) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the codeql-action group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action). Updates `github/codeql-action/init` from 4.37.6 to 4.37.7 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/5595ccaf912efad79be6eef63a5619ff05969be3...ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd) Updates `github/codeql-action/analyze` from 4.37.6 to 4.37.7 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/5595ccaf912efad79be6eef63a5619ff05969be3...ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd) Updates `github/codeql-action/upload-sarif` from 4.37.6 to 4.37.7 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/5595ccaf912efad79be6eef63a5619ff05969be3...ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd) --- updated-dependencies: - dependency-name: github/codeql-action/init dependency-version: 4.37.7 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: codeql-action - dependency-name: github/codeql-action/analyze dependency-version: 4.37.7 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: codeql-action - dependency-name: github/codeql-action/upload-sarif dependency-version: 4.37.7 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: codeql-action ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Gergő Magyar --- .github/workflows/codeql.yml | 4 ++-- .github/workflows/scorecard.yml | 2 +- .github/workflows/trivy.yml | 2 +- .github/workflows/workflow-lint.yml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 16d3e15cc..41440d557 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -48,7 +48,7 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 with: languages: ${{ matrix.language }} queries: security-and-quality @@ -73,6 +73,6 @@ jobs: - '**/test/**/fixtures/**' - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 with: category: '/language:${{ matrix.language }}' diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 04d722160..f6e54108d 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -53,6 +53,6 @@ jobs: retention-days: 5 - name: Upload to Security tab - uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 with: sarif_file: results.sarif diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 2bc3701f3..699289656 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -76,7 +76,7 @@ jobs: exit-code: '0' - name: Upload to Security tab - uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 with: sarif_file: trivy-${{ matrix.image.name }}.sarif category: trivy-${{ matrix.image.name }} diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml index f771406a0..9c3b45ab6 100644 --- a/.github/workflows/workflow-lint.yml +++ b/.github/workflows/workflow-lint.yml @@ -76,7 +76,7 @@ jobs: continue-on-error: true - name: Upload SARIF - uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 + uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 with: sarif_file: zizmor.sarif category: zizmor From 0f793558adeb193461c6f6e21779db0ea5b694e7 Mon Sep 17 00:00:00 2001 From: DuduPhudu <34869259+ReidenXerx@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:27:32 +0300 Subject: [PATCH 15/17] fix(group)!: stop group sync claiming matching it never did (#3020) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(group)!: remove the matching cascade that was advertised but never built `gitnexus group create` wrote `matching.bm25_threshold` and `matching.embedding_threshold` into every generated group.yaml, and no matcher ever read either one. That was not the whole of it — an entire feature surface described a BM25/embedding cascade that does not exist: - `matching.bm25_threshold` / `matching.embedding_threshold` — parsed, persisted, unread - `detect.embedding_fallback` — defaulted and templated, unread - `MatchType` declared `'bm25' | 'embedding'`; both variants unreachable - `SyncOptions.skipEmbeddings` — declared in sync.ts and never read - `gitnexus group sync --skip-embeddings` — accepted, threaded through GroupService, ignored - CLI help in en and zh-CN promised "Exact + BM25 only (no embedding fallback)" - the MCP `group_sync` schema exposed `skipEmbeddings`, described as "Exact + BM25 only (Demo PR: same as default exact path)" `sync.ts` imports exactly `buildProviderIndex`, `runExactMatch` and `runWildcardMatch`, and the printed cascade has one stage. An operator whose links do not match reaches for those thresholds first, and turning either knob changes nothing — config that silently does nothing is how people conclude a feature is broken. Evidence that the cascade should be deleted rather than implemented, from a real backend/frontend pair: of 165 consumer contracts, 149 link exactly and 16 do not. Nine of the sixteen are third-party APIs (Google OAuth, Apple public keys, PostHog, image annotation) with no in-group provider by construction — similarity matching cannot recover them, it can only invent false links. Two are verb mismatches: the frontend calls `POST /links` and `GET /links/check-exists` while the backend declares `GET /links` and eleven other `/links/*` routes but neither of those, so a fuzzy path match would link a POST consumer to a GET provider. The rest are path-extraction artifacts. Roughly none of the sixteen would be correctly recovered, and several would be actively mis-linked. BREAKING CHANGE: `gitnexus group sync --skip-embeddings` and the MCP `group_sync` `skipEmbeddings` parameter are removed. Both were accepted and ignored, so no behavior changes — but a script passing the flag now fails with `unknown option` instead of being silently misled. Existing group.yaml files keep loading: the removed keys are simply no longer part of the schema, and a regression test pins that a legacy config carrying all three still parses. Closes #3006 Co-Authored-By: Claude Opus 5 (1M context) * fix(group)!: honour --exact-only, drop inert --allow-stale, report every matching stage Addresses the review findings on #3020, all of which are the same defect the PR itself is about: group-sync surface that describes behaviour the pipeline does not have. `exactOnly` was inert in exactly the way `skipEmbeddings` was — declared on `SyncOptions`, threaded through the CLI and the MCP tool, and read by nothing — and strictly worse, because the stage it promised to suppress DOES run and DOES write `matchType:'wildcard'` links into contracts.json and the bridge, which `group impact` and cross-repo `trace` then traverse. It is now honoured rather than deleted: unlike the never-built BM25/embedding stages, the stage it names exists, so the flag describes a real choice. The substituted result is `{ matched: [], remaining: unmatched }`, not an empty result — `wildcard.remaining` IS `SyncResult.unmatched`, so skipping the stage has to leave its input unmatched rather than dropping it from the count an operator reads. `allowStale` had no such stage to gate: `syncGroup` emits no stale warning at any point (the `checkStaleness` call lives in `groupStatus`, a different path), so it is removed under the same rationale as `skipEmbeddings`. `group sync` now prints every matching stage instead of `exact` alone. The old block printed a `Matching cascade:` header and counted only exact links while the next line reported `result.crossLinks.length` — which also includes `manifest` and `wildcard` — so for any group with those the two numbers disagreed with nothing on screen explaining why. Counting is an exhaustive `Record`, so a new MatchType fails the build here instead of going silently uncounted, and reads through `?? 0` so a legacy registry carrying a removed matchType prints an honest count rather than `NaN`. Also: the MCP `group_sync` description no longer omits the wildcard stage that always runs, `exactOnly`'s description no longer refers to a "cascade", and bench/cross-repo-trace/verify.mjs no longer generates the removed threshold keys into a fresh group.yaml. Tests: `sync-exact-only.test.ts` pins both directions of the gate (mutation-verified: removing the gate, or returning `remaining: []`, both go red). `group-tools.test.ts` pins that the MCP schema dropped `skipEmbeddings` and kept `exactOnly`. `group-cli.test.ts` pins that both removed flags are rejected, with `--exact-only` as an accepted-flag control. `config-parser.test.ts` now pins that legacy keys are PRESERVED (measured, not assumed) rather than only that parsing does not throw. The type narrowing's fallout in test files is cleared: `tsc -p tsconfig.test.json` is 987 errors at head against 987 measured on origin/main, with the two error sets identical — zero net, zero new, zero masked. Verification: `tsc --noEmit` exit 0; prettier clean; eslint 0 errors (2 warnings, both pre-existing on base); 69 test files / 1169 tests green across test/unit/group, test/integration/group, tools, cli-i18n and cli-index-help. Co-Authored-By: Claude Opus 5 (1M context) * fix(group): reject malformed and retired group_sync parameters (U1) `GroupService.groupSync` read `exactOnly` off an untyped MCP payload with `Boolean(params.exactOnly)`. While the flag was inert that coercion was harmless; now that it gates the wildcard matching stage, the string "false" -- a routine shape for an LLM caller emitting JSON -- is truthy, so a caller that asked to KEEP wildcard matching got it suppressed and a registry with fewer cross-links persisted to disk. The opposite of the request, written down. Validate instead of coercing, at the service boundary: the MCP SDK does not enforce a tool's advertised inputSchema and `callTool` is reachable directly, so this method is the real gate. The validator mirrors `validateImpactMode`'s `{ value } | { error }` shape -- the established idiom for this boundary, and the one groupSync's other guards already return through. Also refuse `skipEmbeddings` and `allowStale` by name. The CLI rejects them outright because commander errors on an unknown option; the MCP path accepted and silently dropped them, so an agent working from a cached tool schema was never told. Removing them took away discoverability, not acceptance. Both guards run before the group is read off disk, so a rejected call performs no work. Every test asserts the sync did NOT run -- an error string alone cannot distinguish "refused" from "refused but synced anyway". The tool description gains the validation note AFTER the registryOutcome paragraph: `tools.test.ts` slices that description by ordinal position of the 'preserved' / 'superseded' / 'no-prior-registry' literals, so appending past all three leaves those slices intact (verified, 44/44). tsc clean; 1039/1039 group unit tests pass. * fix(group): record the matching stages a sync was told to skip (U2) An `--exact-only` sync wrote a contracts.json and bridge with fewer cross-links and nothing recorded that the wildcard stage had been suppressed by request. `group_impact` and cross-repo `trace` read that registry as authoritative, so a narrowed graph was indistinguishable from a complete one -- and because `group_sync` is MCP-exposed, one agent call durably narrowed the shared answer for every later reader with no signal at all. Add `suppressedMatchStages` to ContractRegistry and SyncResult, following the `unreadableRepos` tri-state end to end: absent means a registry written before the field existed, `[]` is the measurement "this run suppressed nothing", and a populated list names the stages. The writer always emits it, because omitting the empty case is what made "measured, none" unreachable for `unreadableRepos`. Two properties that are easy to get backwards, and are why the split matters: - SyncResult carries the marker on EVERY outcome. The sync genuinely did skip the stage whatever happened to the file afterwards, and the CLI summary (U3) renders from this rather than re-deriving it from the caller's options. - The PERSISTED registry stamps it only on the `written` outcome. The preserve path re-writes `{ ...prior }`, so a carried-forward registry keeps the marker of the sync that actually produced its contracts instead of being relabelled with this run's request. That holds by construction: the registry literal carrying the field is only reachable on the written path. `loadContractRegistryResilient` gains an explicit line, because it rebuilds the envelope field by field with no spread of the parsed root -- a new on-disk field is silently dropped unless named there. Its reader is `recordedMatchStages`, not the existing `recordedRepoList`: that one validates `string[]`, which is right for repo names and one notch too weak here. This repo has already retired MatchType members ('bm25', 'embedding'), so a stale value on disk is a real shape, and dropping non-members keeps an unknown stage name from reaching a caller typed as a live one. Surfaced on `group_contracts` and on `group_sync`'s own return -- deliberately kept separate from the truncated/truncationReason/riskEpistemic triple. That triple reports limits a run hit by accident, whose remedy is to fix the repo; a suppressed stage was asked for, and its remedy is to re-sync without the flag. Conflating them would tell an agent to retry something that returns identically. tsc clean; 1043/1043 group unit tests pass. * fix(group): name a skipped matching stage as skipped, and pin it (U3, U4) Two facts were printing as the same line. `wildcard: 0 cross-links` meant both "the stage ran and matched nothing" and "the stage never ran because you passed --exact-only" -- the same conflation this summary block was introduced to remove one line up, reintroduced by the flag that made the block necessary. Render a suppressed stage as `skipped (--exact-only)`, driven by the sync's own `suppressedMatchStages` rather than by `opts.exactOnly`. The renderer reports what the sync did, not what the caller asked for, so it stays correct on the outcomes where the run ended without writing a registry -- which is where the summary is least legible and a re-derivation from the options would have been wrong. Also drops the `?? 0` fallback and the comment justifying it. The comment claimed a legacy registry could carry a retired matchType into this loop. It cannot: `syncGroup` returns a freshly computed `crossLinks` array on every outcome, and even on the preserve path the prior links go to disk while the fresh array is returned. The code was harmless; the stated reason was false, and a comment that explains an unreachable path is worse than no comment. U4 pins both halves through the CLI. A manifest fixture is sufficient: the stage counts must sum to the total on the `Wrote contracts.json (…)` line, and the skipped rendering does not need a stage to have matched anything, because --exact-only records the suppression whatever the fixture holds. That is why this coverage did not need indexed gRPC/Thrift fixture repos. Verified by mutation, not assertion: removing the skipped-rendering branch turns `names a stage it was told to skip as skipped` red and leaves the other 22 green. A control case pins the opposite direction -- the same group without the flag still reports the stage as zero -- so `skipped` cannot be printed unconditionally and pass. U3 and U4 land together: the test has no value without the renderer, so one commit keeps a revert clean. Both depend on U2, which introduced the field they read. tsc clean; 1066/1066 across the group unit and CLI integration suites. * fix(group): make every description of --exact-only match what it does (U5) Two descriptions this branch wrote or touched still misstated behavior. The MCP `exactOnly` description carries "Manifest links still apply." The CLI help and both locale strings, rewritten in the same commit, omit it -- so the surface most operators read understated what still runs. Manifest cross-links are computed before the gate and are genuinely unaffected by the flag, so the caveat is the accurate half and the CLI now says it too. The `group_sync` tool description opened with "extract HTTP contracts". That clause was carried forward byte-identical while only the trailing cross-linking half was rewritten, and it is wrong: the detect config has six non-HTTP extraction toggles, and this branch's own new test fixture is Thrift. `help-i18n.ts` is deliberately untouched. It maps an option to its translation key and that key already exists; only the commander string and the two locale values carry text, so a text-only change does not reach it. The tool-description edit sits ahead of the registryOutcome paragraph, leaving the relative order of the 'preserved' / 'superseded' / 'no-prior-registry' literals intact -- `tools.test.ts` slices that description by their positions. tsc clean; 64/64 across the locale-parity, help-registration, tool-schema and group-tool suites. * fix(group)!: remove max_candidates_per_step and shared_libs (U6) Both keys were declared, defaulted, written into every generated group.yaml, and read by nothing -- the same three-station dead surface this PR removed for bm25_threshold, embedding_threshold and detect.embedding_fallback. Every other DetectConfig field gates a real extractor in sync.ts; shared_libs gates nothing, because 'lib' contracts come only from the operator-declared manifest extractor. MatchingConfig reaches matching.ts solely through buildNoisyContractFilter, which reads exclude_links_paths and exclude_links_param_only_paths and nothing else. Existing group.yaml files keep loading and keep their keys. parseGroupConfig spreads the raw block over its defaults, so a key the schema no longer knows about survives into the returned config -- which matters because `group add` and `group remove` round-trip the operator's file through loadGroupConfig -> yaml.dump -> write, so anything the parser dropped would be deleted from their checked-in file. The legacy-config test now pins both keys in the same cast form as its three siblings, and the fixture carries shared_libs so that assertion is not vacuous. Two stations that are easy to miss and are swept here: - gitnexus/bench/cross-repo-trace/verify.mjs GENERATES a fresh group.yaml. It is not a preserve-path fixture, so "leave YAML fixtures alone" does not cover it; the repo has two generators and both are updated. It is a .mjs file outside tsconfig's include, so no type gate would have caught it. - config-parser.test.ts asserted the removed default at runtime, which vitest DOES run. That assertion is gone from the defaults case (the key no longer has a default) and re-formed as a preserve assertion in the legacy case. Verification gate, corrected: "zero net new errors against origin/main" would have measured the whole branch delta and been red through no fault of this commit. Measured instead against the branch tip immediately before it -- tsc -p tsconfig.test.json --noEmit reports 989 before and 989 after. Twenty-four typed-literal sites across ten test files, none of them CI-gated, plus the two runtime sites above which are. Note the deliberate side effect: removing a key from the defaults also stops the group add round-trip from re-adding it to a file that never carried it. Nothing in src reads either key, so no behavior changes. BREAKING CHANGE: `matching.max_candidates_per_step` and `detect.shared_libs` are no longer part of the group.yaml schema and are no longer written into generated templates. Existing files carrying them continue to parse and retain them. src tsc clean; 1189/1189 across the group unit, group integration, locale-parity, help-registration and tool-schema suites. * docs(group): map PR #3020 review findings to the commits that close them Retitles the ledger to hold one section per reviewed PR and adds #3020's ten findings. Two things are stated rather than claimed away: `abda0d041` closes three findings because they are one code block plus the test that pins it, and the suppressed-stage marker is a coupled set because the renderer consumes the field the earlier commit introduces. Also records what is NOT closed here -- the PR description's false claim about `max_candidates_per_step` lives outside this branch. * refactor(group): apply simplify-pass findings Four cleanup agents (reuse, simplification, efficiency, altitude) over this run's diff. Efficiency was clean. The rest found five things worth fixing, two of which were real gaps rather than style. `recordedMatchStages` filtered unknown values instead of rejecting the list. That inverted the tri-state on the one field built to prevent exactly this conflation: a stale `['bm25']` -- the scenario its own comment cites as the motivation -- survived as `[]`, which on this field MEANS "measured, nothing was suppressed". A confident clean answer manufactured from a value we could not read. Now all-or-nothing, matching `recordedRepoList`. `gitnexus group contracts` showed nothing after an exact-only sync. The human renderer destructures a fixed field list and gates its incompleteness warning on `truncated`, so the marker reached the MCP payload and the JSON output but not the listing an operator actually reads. It now warns, separately from the `truncated` warning, because the remedies differ: one says fix the repo, this one says re-run without the flag. `verbose` was still coerced with `Boolean()` in the same call whose tool description this branch changed to promise "PARAMETERS ARE VALIDATED". Validated now, and added to the tool schema -- it was read by the backend and advertised nowhere. Reuse: the thrift wildcard-matchable pair existed twice, near-verbatim, in `sync-exact-only` and `registry-suppressed-stages`. Both now call a shared `makeWildcardPair` fixture, so the shape `runWildcardMatch` fires on is defined once. Simplification: dropped a `Set` built per sync over a list that only ever holds zero or one entries; iterating `Object.keys(STAGE_COUNTS) as MatchType[]` also keeps the exhaustiveness the `Record` was built for, which `Object.entries` had discarded. Deliberately not done, with reasons: a schema-driven unknown-parameter layer at the MCP chokepoint (five parameters are read by backends and declared in no schema, so a strict layer rejects working calls today, and it cannot produce the "was removed" message finding 3 is about); folding the marker into `GROUP_IMPACT_TRUNCATION_REASONS` (reverses a recorded plan decision and the bridge scope is an open question for the maintainer); a per-stage suppression cause `Record` (no second suppressor exists -- speculative); collapsing the six `detect` extractor branches into a table (a real generalization, but a refactor outside this diff); and converging an untouched pre-existing CLI test onto the new manifest helper (it captures a value the helper does not return, so the change risks more than the duplication costs). tsc clean; eslint 0 errors (1 pre-existing warning); 1085/1085. * docs(group): remove REVIEW-FINDINGS-MAP.md Removes the findings-to-commits ledger from the source tree. Note for anyone reading this in history: the file was introduced on main by #3012 and carried that PR's findings map; this branch had appended a #3020 section. Deleting it drops both. #3012's content is recoverable with `git show 2c0fb7753:gitnexus/src/core/group/REVIEW-FINDINGS-MAP.md`. * fix(group): stop cross-repo impact and trace claiming a narrowed graph is complete Closes the half of the suppressed-stage finding that was deferred. The reviewers were right that deferring it was the weak point: the motivating harm was named as `group_impact` and cross-repo `trace` traversing a graph missing real edges, and those were exactly the surfaces left uncovered. The deferral rested on an assumption that does not hold. "It is already blind to this, so we do not make it worse" is false: `--exact-only` was inert before this PR, so the number of narrowed registries in the world goes from zero to nonzero exactly when this lands. The blindness was harmless only while narrowing was impossible. And silence there is not neutral -- `cross-impact.ts` documents `truncated: false` as an affirmative completeness claim, so those tools were about to start asserting a complete answer over a knowingly short graph. `suppressedMatchStages` now rides the bridge the same way `unreadableRepos` does: persisted in meta.json (no BRIDGE_SCHEMA_VERSION bump -- meta fields have this precedent), read back all-or-nothing, and carried across the preserve path through `refreshPreservedBridgeMeta`'s diagnostics so a preserved bridge keeps the marker of the sync that actually built it. `crossRepoCompleteness` folds it in, which is what makes this one change reach all three surfaces -- that function is by design the ONE computation behind the truncation triple. Precedence is explicit: an unreadable or unaccounted repo outranks a suppressed stage, because it is the more serious structural gap and its remedy has to be the one reported. `'suppressed-stage'` is a new member of the truncation-reason union rather than a reuse of `'incomplete-sync'`. The earlier decision not to touch that union was about not conflating remedies -- telling an agent to repair a repo that read fine, for a narrowing it requested. A distinct member preserves that reasoning while letting the answer stop claiming completeness, which is what reusing the existing member would have destroyed. The union's guard test did its job: adding a member failed the check that every reason is explained on the agent-facing surface, so the impact tool description now names this one and its distinct remedy (re-run WITHOUT the flag; nothing failed to read). `group status` and its CLI renderer surface it too, on the populated case only -- absent is a registry predating the field and empty is the ordinary clean sync; neither earns a line. Deliberately still not done, and why: a repo-wide unknown-parameter layer for every MCP tool. Five parameters are read by backends and declared in no schema (`subgroupExact`, `unmatchedOnly`, `showClusters`, `showProcesses`, and `verbose` until this branch declared it), and three tools dispatch with no schema entry at all, so a strict layer rejects working calls until each is reconciled. That reconciliation is the work; the layer is the cheap part. It also cannot produce the "was removed and is no longer accepted" message the retired-parameter guard exists to give. tsc clean; eslint 0 errors (2 pre-existing warnings); 1159/1159 across the group unit, group integration and tool-schema suites. * fix(group): make the suppressed-stage signal actually reach its readers Applies the mechanical findings from the code review of the previous commit. That commit claimed cross-repo impact and trace stop reporting a narrowed graph as complete. Trace did; impact did not, and two operator-facing messages said something false. Four reviewers plus the cross-model pass converged on the same two defects, and the untested seams were exactly where they were. `runGroupImpact` recomputed the truncation reason and hardcoded its fallback, so it could never emit 'suppressed-stage' -- the value the previous commit added to the union and documented in the tool description. Every narrowed-but-readable bridge was reported as 'incomplete-sync', telling the caller to repair a repo that read fine. It now propagates the bridge's own reason, as cross-trace.ts already did. The preserve path stamped this run's request onto an older bridge. When no repo can be read the database and registry are kept from an earlier sync, so meta.json has to keep describing that sync; instead `{ ...existing, ...diagnostics }` overwrote its marker, leaving contracts.json, meta.json and bridge.lbug describing three different runs. Currently masked by unreadable-repo precedence, one loosened condition from a live wrong verdict. `group contracts` printed "the last sync did not record which repos it could read" after any exact-only sync: truncated was set with both repo lists empty, so the message fell through to the wrong branch. It is now gated on the reason, not the flag. `group impact` likewise blamed the local walk for a floor the flag caused. The tri-state reader is now defined once, in the leaf module whose own comment says it exists so this exact duplication cannot recur -- it had been copied into bridge-db.ts within one commit of that comment being true. Both agent-facing descriptions now name the field. The previous commit added it to three payloads and documented it on none. Tests cover what shipped green: the preserve path for both artifacts (verified by mutation -- reintroducing the stamp turns exactly one test red), and the reason's REACHABILITY. The existing guard only asserted each reason is described, which is why a documented-but-unemittable value passed it. Also corrects a comment that said the marker is deliberately not folded into the truncation triple. True when written; false one commit later. tsc clean; eslint 0 errors; 1163/1163 across the group unit, group integration and tool-schema suites. * fix(group): drop verbose from the MCP surface, fail a superseded bridge closed Two maintainer-directed findings from the review. verbose is removed from the group_sync MCP schema and from GroupService, and kept on the CLI. The parameter never did what either description claimed: the gates emit workspace-dependency discovery stats and one aggregate manifest line, not "each cross-link". Worse, they emit them through the server's logger, which an MCP caller cannot read at all -- so advertising it introduced precisely the kind of knob this PR exists to delete, in the PR that deletes them. SyncOptions keeps the field and the CLI keeps --verbose, because a CLI user really can see that output; its help now says "Show additional sync diagnostics", which is what it shows. It was added to the MCP schema earlier in this same PR, so there is no published compatibility burden in taking it back out. A caller that still sends it is ignored rather than refused: it was never a documented parameter, and the retired-name guard is reserved for ones this tool actually withdrew. The second fixes a split-brain the completeness work made materially worse. When contracts.json commits and the bridge write then fails, the previous database stays in place describing an EARLIER sync. Until now it kept vouching for itself, so group_impact could traverse the superseded graph and call its answer complete while group_contracts reported the advanced registry -- two public surfaces making contradictory epistemic claims out of one sync. That was tolerable when the disagreement was about counts. It is not, now that suppressed-stage makes completeness a correctness property. markBridgeProvenanceUnknown withdraws the claim without touching the database: bridgeMetaMatchesFile already gives provenanceUnknown highest precedence and refuses to vouch for the pair, so cross-repo answers downgrade to a floor until a sync succeeds. Deliberately not a re-stamp -- the metadata still describes the database it was written for, and saying otherwise recreates the mis-pairing the preserve path avoids. Deliberately not a delete -- the old graph is still worth having as a floor, it just stops being called complete. Best-effort, because it runs inside a failure handler and must not replace a reported bridge failure with an unrelated one; the warning now states which of the two happened. Shared registry+bridge generation identity is the architectural fix and is deliberately NOT attempted here. This is the PR-sized containment. Verified by mutation, both directions: neutering the withdrawal turns the new test red, and a control pins that a healthy sync does not withdraw provenance -- otherwise every successful run would report its own answers as a floor. tsc clean; eslint 0 errors; 1185/1185. * refactor(group): apply simplify-pass findings Four cleanup agents over the last five commits. Efficiency was clean and traced why: the containment helper is failure-path only, the reason ternary sits after the fan-out loop, and the tri-state readers run once per artifact read. The strongest finding was one the diff itself proved. `refreshPreservedBridgeMeta` enforced the never-persisted rule for `repoListsUnreadable` and `pairedWithDatabase` with two deletes in its own body, under a comment noting it was the only code that read metadata and wrote it back. That held exactly as long as there was one such caller. `markBridgeProvenanceUnknown` made it two, and inherited nothing. The strip now lives in `writeBridgeMeta`, so every writer gets it and no future one can forget; `pairedWithDatabase` is the dangerous one, because persisted it tells every later reader the pair was verified when nothing verified it. `group impact` still printed "fan-out stopped early" whenever `truncatedRepos` was non-empty — but the bridge's incomplete repos are unioned into that list even when zero crossings were attempted, so a structural gap was reported as a runtime one, with the only working remedy omitted. That is the same false-cause shape the contract listing was re-gated for one commit ago, left live one command over because the new reason was bolted in front of the old branch rather than replacing the thing it branched on. Now keyed on the reason. The `?? 'incomplete-sync'` arm in cross-impact was unreachable: reaching it needs `truncated` true with all three of its inputs false, which `truncated = runtimeTruncated || bridge.truncated` forbids. Flattened. Also: a `recordedMatchStages` insert had split `crossRepoCompleteness` from its own JSDoc; one new test was a strict subset of another; and the bridge-failure warning interleaved concatenation with a mid-chain ternary. The new invariant assertion was caught being VACUOUS by mutation before it shipped — seeded with a valid repo list, `readBridgeMeta` never sets the reader-only field, so it passed with or without the strip. The fixture now seeds an unreadable list, and both it and the pre-existing assertion go red when the strip is removed. Deliberately skipped, with reasons: a shared `firstTruncated` fold over `TruncationFields` (the right altitude, but it changes cross-trace's return assembly and that surface separately documents a 'timeout' rung it cannot emit — a behavior change, not a cleanup); a reason-keyed `explainFloor` helper across all four CLI renderers (real, but a four-site refactor); narrowing the persisted stage vocabulary to a `SuppressibleStage` alias (would be undone by the very extension the field was modelled as a list to allow); moving `verbose` to `logger.debug` and deleting `SyncOptions.verbose` (the maintainer explicitly directed keeping both); and merging the two tri-state readers behind a predicate (they are adjacent in one file now, so a tightening applies to both by inspection — the duplication the comment warned about was cross-FILE). tsc clean; eslint 0 errors; 1164/1164. * fix(group): address gitnexus-check findings Seven bot comments across two review rounds; five distinct after dedup. Four were valid and are fixed, two were already resolved by later commits the bot had not seen. The validator could throw from its own error path. `JSON.stringify` is the right renderer there — it is what distinguishes the string "false" from the boolean, which is the entire point of the message — but it throws on a BigInt and on a cyclic object. So a validator promising a structured `{ error }` instead rejected, and `callTool` is reachable directly, so neither input is hypothetical. Guarded, keeping the distinction and falling back for the shapes that cannot serialize. An unreadable suppression record read as "nothing was suppressed". `recordedMatchStages` is all-or-nothing by design, so garbage collapses to `undefined` — and the consumer treated `undefined` as an empty measurement, throwing that safety away and reporting a registry it could not parse as complete. Present-but-unreadable now forces the floor, while absent stays legitimate: a registry written before the field existed has no opinion and should not be dragged to a floor for it. Two test-side findings, both real and both invisible to CI because `tsconfig.json` is src-only. Three `mock.calls[0][1]` accesses did not type-check against a zero-arg mock, and four assertions read `truncationReason` / `riskEpistemic` straight off `CrossRepoCompleteness`, which is a discriminated union carrying them on one arm. Also removed a `StoredContract` import that went dead when those fixtures moved to `makeWildcardPair`. Worth recording: U6 set a test-config gate at 989 errors and later commits walked it to 994 without anyone re-measuring — the bot caught three of the five. Now 987, below the original baseline. Already fixed, not by this commit: the preserve-path stamp the bot flagged against 6ceac8b1f (fixed in 1fbe0dc6b) and the displaced completeness JSDoc (fixed in 2d2ef8c47). Both behavior fixes are mutation-verified: restoring the unguarded stringify turns the new unserializable-value test red, and a control pins that an absent record still reads as complete so the fails-closed change cannot pass by forcing every registry to a floor. src tsc clean; eslint clean; 1167/1167. * chore(autofix): apply prettier + eslint fixes via /autofix command --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Gergo Magyar Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- gitnexus/bench/cross-repo-trace/verify.mjs | 3 - gitnexus/src/cli/group.ts | 124 ++++++++-- gitnexus/src/cli/help-i18n.ts | 2 - gitnexus/src/cli/i18n/en.ts | 7 +- gitnexus/src/cli/i18n/zh-CN.ts | 7 +- .../src/core/group/REVIEW-FINDINGS-MAP.md | 107 --------- gitnexus/src/core/group/bridge-db.ts | 80 +++++- gitnexus/src/core/group/completeness.ts | 36 ++- gitnexus/src/core/group/config-parser.ts | 5 - gitnexus/src/core/group/cross-impact.ts | 23 +- gitnexus/src/core/group/cross-trace.ts | 1 + gitnexus/src/core/group/service.ts | 118 ++++++++- gitnexus/src/core/group/storage.ts | 5 - gitnexus/src/core/group/sync.ts | 57 ++++- gitnexus/src/core/group/types.ts | 43 +++- gitnexus/src/mcp/resources.ts | 6 +- gitnexus/src/mcp/tools.ts | 12 +- .../test/integration/group/group-cli.test.ts | 98 ++++++++ .../group/group-sync-lock-concurrency.test.ts | 4 +- .../test/unit/group/config-parser.test.ts | 46 +++- gitnexus/test/unit/group/fixtures.ts | 35 +++ gitnexus/test/unit/group/group-tools.test.ts | 19 ++ gitnexus/test/unit/group/matching.test.ts | 24 -- .../group/registry-suppressed-stages.test.ts | 227 ++++++++++++++++++ .../group/service-group-sync-payload.test.ts | 133 ++++++++++ .../test/unit/group/sync-exact-only.test.ts | 81 +++++++ .../group/sync-partial-extraction.test.ts | 4 +- .../unit/group/sync-unreadable-repos.test.ts | 104 +++++++- .../group/sync-windowed-resolution.test.ts | 4 +- gitnexus/test/unit/group/sync.test.ts | 32 +-- gitnexus/test/unit/group/types.test.ts | 8 +- .../repo-manager-registry-strict-read.test.ts | 4 +- 32 files changed, 1198 insertions(+), 261 deletions(-) delete mode 100644 gitnexus/src/core/group/REVIEW-FINDINGS-MAP.md create mode 100644 gitnexus/test/unit/group/registry-suppressed-stages.test.ts create mode 100644 gitnexus/test/unit/group/sync-exact-only.test.ts diff --git a/gitnexus/bench/cross-repo-trace/verify.mjs b/gitnexus/bench/cross-repo-trace/verify.mjs index 8497af38d..e965bfc07 100644 --- a/gitnexus/bench/cross-repo-trace/verify.mjs +++ b/gitnexus/bench/cross-repo-trace/verify.mjs @@ -58,9 +58,6 @@ packages: {} detect: http: true matching: - bm25_threshold: 0.7 - embedding_threshold: 0.65 - max_candidates_per_step: 3 `; } diff --git a/gitnexus/src/cli/group.ts b/gitnexus/src/cli/group.ts index c68f7142d..7bb7bc180 100644 --- a/gitnexus/src/cli/group.ts +++ b/gitnexus/src/cli/group.ts @@ -2,6 +2,7 @@ import { createRequire } from 'node:module'; import type { Command } from 'commander'; import type { RegistryWriteOutcome } from '../core/group/sync.js'; +import type { MatchType } from '../core/group/types.js'; import { logger } from '../core/logger.js'; const _require = createRequire(import.meta.url); @@ -134,6 +135,7 @@ export function registerGroupCommands(program: Command): void { >; missingRepos?: string[]; unreadableRepos?: string[]; + suppressedMatchStages?: string[]; }; console.log(' Repo index / contracts staleness:'); @@ -188,6 +190,18 @@ export function registerGroupCommands(program: Command): void { if ((st.missingRepos || []).length > 0) { console.log(`\n Last sync missing repos: ${st.missingRepos!.join(', ')}`); } + // Only the populated case prints. Absent means a registry that predates + // the field, and empty is the ordinary clean sync — neither is worth a + // line, whereas a narrowed registry changes how every later answer + // should be read. + const skippedStages = st.suppressedMatchStages ?? []; + if (skippedStages.length > 0) { + console.log( + `\n Last sync skipped matching stages: ${skippedStages.join(', ')}` + + `\n Cross-links those stages would have found are absent by request.` + + `\n Re-run \`gitnexus group sync\` without --exact-only for the complete set.`, + ); + } } finally { await backend.dispose().catch(() => {}); } @@ -196,10 +210,11 @@ export function registerGroupCommands(program: Command): void { group .command('sync ') .description('Sync Contract Registry — extract contracts and build cross-links') - .option('--skip-embeddings', 'Exact + BM25 only (no embedding fallback)') - .option('--exact-only', 'Exact match only') - .option('--allow-stale', 'Skip stale index warnings') - .option('--verbose', 'Show each cross-link detail') + .option( + '--exact-only', + 'Skip wildcard service matching; cross-link on exact contract-id match only (manifest links still apply)', + ) + .option('--verbose', 'Show additional sync diagnostics') .option('--json', 'JSON output') .action(async (name: string, opts: Record) => { const { getGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js'); @@ -216,9 +231,7 @@ export function registerGroupCommands(program: Command): void { try { result = await syncGroup(config, { groupDir, - allowStale: Boolean(opts.allowStale), verbose: Boolean(opts.verbose), - skipEmbeddings: Boolean(opts.skipEmbeddings), exactOnly: Boolean(opts.exactOnly), }); } catch (err) { @@ -257,10 +270,40 @@ export function registerGroupCommands(program: Command): void { `\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`); + // Every stage that produced a link, not just `exact`. This used to print + // `Matching cascade:` and then count `exact` alone, while the `Wrote + // contracts.json (…)` line below reports `result.crossLinks.length` — + // which also includes `manifest` and `wildcard` links. For any group with + // those, the two numbers disagreed with nothing on screen explaining why. + // Summing the stages here makes them reconcile by construction. + console.log(`\nMatching:`); + // Exhaustive by construction, same idiom as OUTCOME_LINE below: adding a + // MatchType fails the build here instead of silently going uncounted and + // reopening the very mismatch this replaced. Every stage prints even at + // zero — a stage that is absent reads as "did not apply", not "found none". + const STAGE_COUNTS: Record = { + exact: 0, + manifest: 0, + wildcard: 0, + }; + for (const link of result.crossLinks) STAGE_COUNTS[link.matchType] += 1; + // A stage the sync was told to skip is reported as skipped, not as a + // zero count. The two are different facts — "ran, matched nothing" and + // "never ran" — and printing both as `0` is the same conflation this + // block replaced. Driven by what the sync did (`suppressedMatchStages`) + // rather than by what the caller asked for, so it stays correct on the + // outcomes where the run ended without writing a registry. + for (const stage of Object.keys(STAGE_COUNTS) as MatchType[]) { + const count = STAGE_COUNTS[stage]; + const label = `${stage}:`.padEnd(10); + if (result.suppressedMatchStages.includes(stage)) { + console.log(` ${label} skipped (--exact-only)`); + continue; + } + const confidence = stage === 'exact' ? ' (confidence 1.0)' : ''; + console.log(` ${label} ${count} cross-links${confidence}`); + } + console.log(` ${'unmatched:'.padEnd(10)} ${result.unmatched.length} contracts`); // 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 @@ -391,11 +434,28 @@ export function registerGroupCommands(program: Command): void { // repos — reporting it as crossings understates a fan-out cap the // same way #2787's totals did. const dropped = (raw as { truncatedRepos?: string[] })?.truncatedRepos ?? []; - console.log( - dropped.length > 0 - ? ` risk is a LOWER BOUND — fan-out stopped early; crossings to ${dropped.length} repo(s) not traversed: ${dropped.join(', ')}` - : ' risk is a LOWER BOUND — the local impact walk did not complete (every bridge crossing was traversed)', - ); + const reason = (raw as { truncationReason?: string })?.truncationReason; + // Keyed on the REASON, not on which incidental fact happens to be + // non-empty. `truncatedRepos` is populated for a structural gap too + // — the bridge's incomplete repos are unioned into it even when ZERO + // crossings were attempted — so branching on its length first + // reported "fan-out stopped early" for a run where nothing stopped + // early, and omitted the only remedy that works. Same false-cause + // shape the contract listing was just re-gated for, one command over. + const floorReason = (): string => { + if (reason === 'suppressed-stage') { + return 'the last sync skipped a matching stage (--exact-only); re-run `gitnexus group sync` without it for the complete graph'; + } + if (reason === 'incomplete-sync') { + return dropped.length > 0 + ? `the last sync could not account for ${dropped.join(', ')}; their contracts are absent from every query against this bridge — re-run \`gitnexus group sync\`` + : 'the last sync could not say which repos it read — re-run `gitnexus group sync`'; + } + return dropped.length > 0 + ? `fan-out stopped early; crossings to ${dropped.length} repo(s) not traversed: ${dropped.join(', ')}` + : 'the local impact walk did not complete (every bridge crossing was traversed)'; + }; + console.log(` risk is a LOWER BOUND — ${floorReason()}`); } } } finally { @@ -480,7 +540,15 @@ export function registerGroupCommands(program: Command): void { return; } - const { contracts, crossLinks, truncated, unreadableRepos, missingRepos } = raw as { + const { + contracts, + crossLinks, + truncated, + unreadableRepos, + missingRepos, + suppressedMatchStages, + truncationReason, + } = raw as { contracts: Array<{ role: string; contractId: string; @@ -495,6 +563,8 @@ export function registerGroupCommands(program: Command): void { contractId: string; }>; truncated?: boolean; + suppressedMatchStages?: string[]; + truncationReason?: string; unreadableRepos?: string[]; missingRepos?: string[]; }; @@ -518,7 +588,27 @@ export function registerGroupCommands(program: Command): void { ` ${l.from.repo} -> ${l.to.repo} [${l.matchType}, conf=${l.confidence}] ${l.contractId}`, ); } - if (truncated) { + // Separate from `truncated` below, and deliberately so: that one means + // the sync could not read something and the remedy is to fix the repo. + // This one means the sync was ASKED to skip a stage, and the remedy is + // to re-run without the flag. A listing narrowed on purpose is still + // narrowed, and without this the human view showed nothing at all. + if (suppressedMatchStages && suppressedMatchStages.length > 0) { + console.log( + `\n⚠️ This listing is a lower bound: the last sync skipped ${suppressedMatchStages.join(', ')} matching` + + `\n (--exact-only), so cross-links that stage would have found are absent.` + + `\n Re-run \`gitnexus group sync\` without --exact-only for the complete set.`, + ); + } + // Gated on the REASON, not just the flag. A suppressed stage sets + // `truncated` with both repo lists empty, which sent this block down + // its else-branch and printed "the last sync did not record which + // repos it could read" — a false statement, with the wrong remedy, + // about a sync that recorded them fine. The suppressed-stage warning + // above already said the true thing. When a repo gap co-occurs the + // reason is 'incomplete-sync' (the repo side takes precedence in + // `crossRepoCompleteness`), so this block still runs for it. + if (truncated && truncationReason !== 'suppressed-stage') { // 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. diff --git a/gitnexus/src/cli/help-i18n.ts b/gitnexus/src/cli/help-i18n.ts index 58f28d11a..eeea3eeae 100644 --- a/gitnexus/src/cli/help-i18n.ts +++ b/gitnexus/src/cli/help-i18n.ts @@ -155,9 +155,7 @@ const OPTION_DESCRIPTION_KEYS = { 'embeddings install|--cuda': 'help.option.embeddings.install.cuda', 'embeddings install|--force': 'help.option.embeddings.install.force', 'group create|--force': 'help.option.group.create.force', - 'group sync|--skip-embeddings': 'help.option.group.sync.skipEmbeddings', 'group sync|--exact-only': 'help.option.group.sync.exactOnly', - 'group sync|--allow-stale': 'help.option.group.sync.allowStale', 'group sync|--verbose': 'help.option.group.sync.verbose', 'group sync|--json': 'help.option.json', 'group impact|--target ': 'help.option.group.impact.target', diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index aaaf442cb..e893e61ac 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -293,10 +293,9 @@ export const en = { 'help.option.embeddings.install.force': 'Install into the runtime prefix even when the stack already resolves', 'help.option.group.create.force': 'Overwrite existing group', - 'help.option.group.sync.skipEmbeddings': 'Exact + BM25 only (no embedding fallback)', - 'help.option.group.sync.exactOnly': 'Exact match only', - 'help.option.group.sync.allowStale': 'Skip stale index warnings', - 'help.option.group.sync.verbose': 'Show each cross-link detail', + 'help.option.group.sync.exactOnly': + 'Skip wildcard service matching; cross-link on exact contract-id match only (manifest links still apply)', + 'help.option.group.sync.verbose': 'Show additional sync diagnostics', 'help.option.status.json': 'Emit machine-readable index and analyzer provenance', 'help.option.json': 'JSON output', 'help.option.group.impact.target': 'Symbol or file name to analyze', diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index 0ed1c7f1e..2c066f010 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -273,10 +273,9 @@ export const zhCN = { '同时下载 CUDA GPU 二进制文件(运行 onnxruntime-node 的 NuGet postinstall;代理后请设置 GLOBAL_AGENT_HTTPS_PROXY)', 'help.option.embeddings.install.force': '即使嵌入组件已可解析,也强制安装到运行时目录', 'help.option.group.create.force': '覆盖现有仓库组', - 'help.option.group.sync.skipEmbeddings': '仅使用 exact + BM25(不使用嵌入回退)', - 'help.option.group.sync.exactOnly': '仅精确匹配', - 'help.option.group.sync.allowStale': '跳过过期索引警告', - 'help.option.group.sync.verbose': '显示每条跨仓库链接详情', + 'help.option.group.sync.exactOnly': + '跳过通配符服务匹配,仅按契约 ID 精确匹配建立跨仓链接(清单声明的链接仍然生效)', + 'help.option.group.sync.verbose': '显示额外的同步诊断信息', 'help.option.status.json': '输出机器可读的索引和分析器来源信息', 'help.option.json': 'JSON 输出', 'help.option.group.impact.target': '要分析的符号或文件名', diff --git a/gitnexus/src/core/group/REVIEW-FINDINGS-MAP.md b/gitnexus/src/core/group/REVIEW-FINDINGS-MAP.md deleted file mode 100644 index aebe73303..000000000 --- a/gitnexus/src/core/group/REVIEW-FINDINGS-MAP.md +++ /dev/null @@ -1,107 +0,0 @@ -# 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. diff --git a/gitnexus/src/core/group/bridge-db.ts b/gitnexus/src/core/group/bridge-db.ts index 17eb24b76..3c8c07bf2 100644 --- a/gitnexus/src/core/group/bridge-db.ts +++ b/gitnexus/src/core/group/bridge-db.ts @@ -3,9 +3,16 @@ import path from 'node:path'; import { createHash } from 'node:crypto'; import lbug from '@ladybugdb/core'; import type { LbugValue } from '@ladybugdb/core'; -import type { BridgeHandle, BridgeMeta, StoredContract, CrossLink, RepoSnapshot } from './types.js'; +import type { + BridgeHandle, + BridgeMeta, + StoredContract, + CrossLink, + RepoSnapshot, + MatchType, +} from './types.js'; import { BRIDGE_SCHEMA_QUERIES, BRIDGE_SCHEMA_VERSION } from './bridge-schema.js'; -import { recordedRepoList } from './completeness.js'; +import { recordedMatchStages, recordedRepoList } from './completeness.js'; import { closeLbugConnection, openLbugConnection, @@ -649,7 +656,15 @@ export async function closeBridgeDb(handle: BridgeHandle): Promise { /* ------------------------------------------------------------------ */ export async function writeBridgeMeta(groupDir: string, meta: BridgeMeta): Promise { - await writeFileAtomic(path.join(groupDir, 'meta.json'), JSON.stringify(meta, null, 2)); + // Strip the reader-only fields HERE rather than at each writer. `readBridgeMeta` + // sets both on what it returns, so any caller that reads-modifies-writes would + // round-trip them to disk — and `pairedWithDatabase` is the poisonous one: + // persisted, it tells every future reader the pair was verified when nothing + // verified it. That rule used to live in the body of the only such caller, + // which held exactly as long as there was one. There are now three writers and + // two of them read first. Enforced at the boundary, no writer can get it wrong. + const { repoListsUnreadable: _reader1, pairedWithDatabase: _reader2, ...persisted } = meta; + await writeFileAtomic(path.join(groupDir, 'meta.json'), JSON.stringify(persisted, null, 2)); } /** @@ -826,6 +841,10 @@ export async function readBridgeMeta(groupDir: string): Promise { // was there and could not be read. if (unreadableRepos) meta.unreadableRepos = unreadableRepos; else delete meta.unreadableRepos; + // Same absent-vs-empty rule, through the one shared reader. + const suppressed = recordedMatchStages(raw.suppressedMatchStages); + if (suppressed) meta.suppressedMatchStages = suppressed; + else delete meta.suppressedMatchStages; if (repoListsUnreadable) meta.repoListsUnreadable = true; return meta; } @@ -902,6 +921,12 @@ async function fileExists(filePath: string): Promise { */ export async function refreshPreservedBridgeMeta( groupDir: string, + // Deliberately NOT `suppressedMatchStages`. This path preserves an EARLIER + // sync's database, so stamping it with this run's request would claim the + // untouched bridge was built with a flag it never saw. The registry's own + // preserve write (`{ ...prior, missingRepos, unreadableRepos }`) omits it for + // exactly this reason, and the two artifacts have to agree about which run + // they describe. diagnostics: { missingRepos: string[]; unreadableRepos: string[] }, ): Promise { const dbPath = path.join(groupDir, 'bridge.lbug'); @@ -921,10 +946,8 @@ export async function refreshPreservedBridgeMeta( 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; + // writes it back. The strip itself now lives in `writeBridgeMeta`, so every + // writer inherits it rather than each remembering. if (paired) { const stat = await fsp.stat(dbPath).catch(() => null); @@ -944,6 +967,39 @@ export async function refreshPreservedBridgeMeta( return 'provenance-unknown'; } +/** + * Withdraw the bridge's claim to be complete, without touching the database. + * + * The one path this exists for: `contracts.json` committed, then the bridge + * replacement failed. The old database is still physically usable and still + * answers queries, but it now describes an EARLIER sync than the canonical + * registry beside it — so `group_contracts` can report a narrowed or advanced + * contract set while `group_impact` traverses the old graph and calls its + * answer complete. Two public surfaces, contradictory epistemic claims, from + * one sync. + * + * Setting `provenanceUnknown` is the smallest thing that makes that safe: + * `bridgeMetaMatchesFile` gives it highest precedence and refuses to vouch for + * the pair, so every cross-repo answer downgrades to a floor until a sync + * succeeds. Deliberately NOT a re-stamp — the metadata still describes the + * database it was written for, and claiming otherwise is the mis-pairing the + * preserve path is careful to avoid. Deliberately not a delete either: the + * previous graph is better than nothing as long as nobody calls it complete. + * + * Best-effort by construction. It runs inside a failure handler, so a throw + * here would replace a reported bridge failure with an unrelated one. + */ +export async function markBridgeProvenanceUnknown(groupDir: string): Promise { + try { + const existing = await readBridgeMeta(groupDir); + if (existing.version === 0) return false; + await writeBridgeMeta(groupDir, { ...existing, provenanceUnknown: true }); + return true; + } catch { + return false; + } +} + /* ------------------------------------------------------------------ */ /* writeBridge — atomic write-to-temp-then-rename */ /* ------------------------------------------------------------------ */ @@ -969,6 +1025,13 @@ export interface WriteBridgeInput { * contract those repos own. */ unreadableRepos?: string[]; + /** + * Matching stages the sync was asked to skip. Recorded here for the same + * reason `unreadableRepos` is: a later cross-repo query reads this bridge + * with no access to the run that built it, and a graph narrowed by request + * looks exactly like a complete one. + */ + suppressedMatchStages?: MatchType[]; } /** @@ -1322,6 +1385,9 @@ export async function writeBridgeUnlocked( // different claim from a bridge that never recorded the field. Omitted // only when the caller passed nothing to record. ...(input.unreadableRepos ? { unreadableRepos: input.unreadableRepos } : {}), + ...(input.suppressedMatchStages + ? { suppressedMatchStages: input.suppressedMatchStages } + : {}), }); return report; diff --git a/gitnexus/src/core/group/completeness.ts b/gitnexus/src/core/group/completeness.ts index 326d10b2d..16aee0473 100644 --- a/gitnexus/src/core/group/completeness.ts +++ b/gitnexus/src/core/group/completeness.ts @@ -13,7 +13,7 @@ * 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'; +import type { GroupImpactTruncationReason, MatchType } from './types.js'; /** * A union rather than `Pick` so the two states are @@ -69,6 +69,12 @@ export interface CrossRepoCompletenessInput { */ unreadableRepos?: readonly string[]; missingRepos?: readonly string[]; + /** + * Matching stages the sync was asked to skip. Absent or empty means it + * suppressed none; a populated list makes the answer a floor for a reason + * that is neither a runtime limit nor an unreadable repo. + */ + suppressedMatchStages?: readonly string[]; /** Computed by the caller; see `bridgeProvenanceUnknown` for the bridge one. */ provenanceUnknown: boolean; /** @@ -93,6 +99,23 @@ export type CrossRepoCompleteness = TruncationFields & { incompleteRepos: string[]; }; +/** + * Read a persisted `suppressedMatchStages` list. + * + * Sibling of `recordedRepoList` and here for the same stated reason: it had + * lived in two files verbatim, so tightening one would silently leave the other. + * All-or-nothing like its sibling — a stale member (this repo has already + * retired `'bm25'` and `'embedding'`) makes the whole list unreadable rather + * than filtering down to `[]`, which on this field would mean "measured, + * nothing suppressed": a clean answer manufactured from a value we could not + * read. + */ +export function recordedMatchStages(value: unknown): MatchType[] | undefined { + if (!Array.isArray(value)) return undefined; + const known: MatchType[] = ['exact', 'manifest', 'wildcard']; + return value.every((v): v is MatchType => known.includes(v as MatchType)) ? value : undefined; +} + /** * The ONE computation of "is this cross-repo answer complete?" (KTD10). * @@ -111,8 +134,17 @@ export function crossRepoCompleteness(input: CrossRepoCompletenessInput): CrossR const incompleteRepos = [ ...new Set([...(input.unreadableRepos ?? []), ...(input.missingRepos ?? [])]), ].filter((repoPath) => input.inScope(repoPath)); + // An unreadable or unaccounted repo outranks a suppressed stage: it is the + // more serious structural gap and its remedy (repair the repo, re-sync) has + // to be the one reported. A suppressed stage only decides the reason when + // the repo side is otherwise clean. + const suppressed = (input.suppressedMatchStages ?? []).length > 0; + const repoSideIncomplete = input.provenanceUnknown || incompleteRepos.length > 0; return { - ...truncationFields(input.provenanceUnknown || incompleteRepos.length > 0, 'incomplete-sync'), + ...truncationFields( + repoSideIncomplete || suppressed, + repoSideIncomplete ? 'incomplete-sync' : 'suppressed-stage', + ), incompleteRepos, }; } diff --git a/gitnexus/src/core/group/config-parser.ts b/gitnexus/src/core/group/config-parser.ts index 29c868171..754f578f9 100644 --- a/gitnexus/src/core/group/config-parser.ts +++ b/gitnexus/src/core/group/config-parser.ts @@ -29,16 +29,11 @@ const DEFAULT_DETECT = { grpc: true, thrift: true, topics: true, - shared_libs: true, - embedding_fallback: true, includes: false, workspace_deps: false, }; const DEFAULT_MATCHING = { - bm25_threshold: 0.7, - embedding_threshold: 0.65, - max_candidates_per_step: 3, exclude_links_paths: [] as string[], exclude_links_param_only_paths: false, }; diff --git a/gitnexus/src/core/group/cross-impact.ts b/gitnexus/src/core/group/cross-impact.ts index 21cbbe055..19ddd6fee 100644 --- a/gitnexus/src/core/group/cross-impact.ts +++ b/gitnexus/src/core/group/cross-impact.ts @@ -856,6 +856,7 @@ export async function runGroupImpact( const bridge = crossRepoCompleteness({ unreadableRepos: bridgePrep.meta.unreadableRepos, missingRepos: bridgePrep.meta.missingRepos, + suppressedMatchStages: bridgePrep.meta.suppressedMatchStages, provenanceUnknown, inScope: (candidate) => repoInSubgroup(candidate, subgroup) || repoInSubgroup(candidate, repoPath, true), @@ -878,15 +879,23 @@ 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. - // 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. + // Runtime limits first — they are what the caller can retry. Past those, the + // BRIDGE's own reason wins: it already distinguished an unreadable repo + // ('incomplete-sync', remedy: re-sync) from a stage the sync was asked to + // skip ('suppressed-stage', remedy: re-sync WITHOUT the flag). Hardcoding + // the fallback here overrode that and told every caller to repair a repo + // that read fine — and made the second value unreachable from this surface + // while the tool description promised it. `cross-trace.ts` re-spreads the + // bridge's fields for the same reason. ...truncationFields( truncated, - fanoutTimedOut ? 'timeout' : runtimeTruncated ? 'partial' : 'incomplete-sync', + fanoutTimedOut + ? 'timeout' + : runtimeTruncated + ? 'partial' + : bridge.truncated + ? bridge.truncationReason + : 'incomplete-sync', ), truncatedRepos: [...new Set([...truncatedRepos, ...bridge.incompleteRepos])], summary: { diff --git a/gitnexus/src/core/group/cross-trace.ts b/gitnexus/src/core/group/cross-trace.ts index ddb771703..92c52e789 100644 --- a/gitnexus/src/core/group/cross-trace.ts +++ b/gitnexus/src/core/group/cross-trace.ts @@ -264,6 +264,7 @@ function bridgeCompletenessFor( return crossRepoCompleteness({ unreadableRepos: meta.unreadableRepos, missingRepos: meta.missingRepos, + suppressedMatchStages: meta.suppressedMatchStages, provenanceUnknown: bridgeProvenanceUnknown(meta), inScope, }); diff --git a/gitnexus/src/core/group/service.ts b/gitnexus/src/core/group/service.ts index 79f67abe6..af1f1385a 100644 --- a/gitnexus/src/core/group/service.ts +++ b/gitnexus/src/core/group/service.ts @@ -15,7 +15,7 @@ import { type RepoMeta, } from '../../storage/repo-manager.js'; import { crossRepoCompleteness } from './completeness.js'; -import { recordedRepoList } from './completeness.js'; +import { recordedMatchStages, recordedRepoList } from './completeness.js'; import { GroupNotFoundError, loadGroupConfig } from './config-parser.js'; import { fileMatchesServicePrefix, @@ -262,7 +262,8 @@ function registryIdentifies(entries: RegistryEntry[], registryName: string): boo async function loadContractRegistryResilient( groupDir: string, ): Promise< - { ok: true; registry: ContractRegistry; skippedCorrupt: number } | { ok: false; error: string } + | { ok: true; registry: ContractRegistry; skippedCorrupt: number; suppressionUnreadable: boolean } + | { ok: false; error: string } > { const filePath = path.join(groupDir, 'contracts.json'); let raw: string; @@ -327,6 +328,15 @@ 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 recordedSuppressed = recordedMatchStages(base.suppressedMatchStages); + // Present-but-unreadable is NOT the same as absent. `recordedMatchStages` is + // all-or-nothing, so garbage collapses to `undefined` — and a consumer that + // reads `undefined` as "nothing was suppressed" would throw that safety away + // and report a registry it could not parse as complete. Absent stays + // legitimate (a registry predating the field); only a value that was there + // and unreadable forces the answer to a floor. + const suppressionUnreadable = + base.suppressedMatchStages !== undefined && recordedSuppressed === undefined; const registry: ContractRegistry = { version: typeof base.version === 'number' ? base.version : 0, generatedAt: typeof base.generatedAt === 'string' ? base.generatedAt : '', @@ -348,11 +358,75 @@ async function loadContractRegistryResilient( // rendered as a clean result, which is the same conflation this whole // change removes. ...(recordedUnreadable ? { unreadableRepos: recordedUnreadable } : {}), + // Same omit-when-unrecorded rule. This reader rebuilds the envelope field + // by field with no spread of `base`, so a new on-disk field is dropped + // unless it is named here. + ...(recordedSuppressed ? { suppressedMatchStages: recordedSuppressed } : {}), contracts, crossLinks, }; - return { ok: true, registry, skippedCorrupt }; + return { ok: true, registry, skippedCorrupt, suppressionUnreadable }; +} + +/** + * Validate a boolean MCP parameter — reject, never coerce. + * + * `Boolean(params.x)` is the trap this exists to close: the string `"false"` + * is truthy, and an LLM caller emitting JSON produces that shape routinely. + * While `exactOnly` was inert the coercion was harmless; now that it gates a + * matching stage, a coerced `"false"` suppresses that stage and persists a + * registry with fewer cross-links than the caller asked for. + * + * Absent stays absent-as-false (the unchanged default). Anything that is not + * a real boolean returns a structured `{ error }`, mirroring + * `validateImpactMode` — the established shape for this boundary, and the one + * `groupSync`'s other guards already use. + */ +function validateBooleanParam(name: string, raw: unknown): { value: boolean } | { error: string } { + if (raw === undefined) return { value: false }; + if (typeof raw === 'boolean') return { value: raw }; + return { error: `Invalid "${name}": expected true or false, got ${describeValue(raw)}.` }; +} + +/** + * Render an untrusted value for an error message, without throwing. + * + * `JSON.stringify` is the right shape here — it distinguishes the string + * `"false"` from the boolean, which is the whole point of the message — but it + * throws on a BigInt and on a cyclic object. A validator whose ERROR path can + * throw does not return the structured `{ error }` it promises: the caller gets + * a rejected promise instead of feedback it can act on, and `callTool` is + * reachable directly, so neither input is hypothetical. + */ +function describeValue(raw: unknown): string { + try { + const rendered = JSON.stringify(raw); + // `undefined`, a function, or a symbol serialize to `undefined`. + return rendered ?? String(raw); + } catch { + return typeof raw === 'bigint' ? `${raw}n` : Object.prototype.toString.call(raw); + } +} + +/** + * Refuse parameters this tool used to accept and no longer does. + * + * The CLI rejects a removed flag outright because commander errors on an + * unknown option. The MCP path had no equivalent, so an agent working from a + * cached tool schema kept sending a retired key and was told nothing — the + * removal took away discoverability, not acceptance. Naming the parameter is + * what lets the caller correct itself on the next call. + */ +function rejectRetiredSyncParams(params: Record): { error: string } | null { + for (const retired of ['skipEmbeddings', 'allowStale']) { + if (params[retired] !== undefined) { + return { + error: `"${retired}" was removed and is no longer accepted. Drop it from the call.`, + }; + } + } + return null; } export class GroupService { @@ -384,6 +458,13 @@ export class GroupService { async groupSync(params: Record): Promise { const name = String(params.name ?? '').trim(); if (!name) return { error: 'name is required' }; + // Before anything reads the group off disk: the MCP SDK does not enforce a + // tool's advertised `inputSchema` and `callTool` is reachable directly, so + // this method is the real validation boundary. + const exactOnly = validateBooleanParam('exactOnly', params.exactOnly); + if ('error' in exactOnly) return exactOnly; + const retired = rejectRetiredSyncParams(params); + if (retired) return retired; const groupDir = getGroupDir(getDefaultGitnexusDir(), name); let config: GroupConfig; try { @@ -404,10 +485,11 @@ export class GroupService { try { result = await syncGroup(config, { groupDir, - exactOnly: Boolean(params.exactOnly), - skipEmbeddings: Boolean(params.skipEmbeddings), - allowStale: Boolean(params.allowStale), - verbose: Boolean(params.verbose), + exactOnly: exactOnly.value, + // `verbose` is deliberately NOT accepted here. It gates diagnostics on + // the server's logger, which an MCP caller cannot observe — advertising + // it would be exactly the kind of knob that does not do what the caller + // expects. `SyncOptions.verbose` stays for the CLI, which can see them. }); } catch (err) { // Fails closed (R9): this sync could not be protected against a concurrent @@ -423,6 +505,10 @@ export class GroupService { unmatched: result.unmatched.length, missingRepos: result.missingRepos, unreadableRepos: result.unreadableRepos, + // The agent-facing half of the skipped-stage signal. A human sees it in + // the CLI summary; without this an agent would have to issue a second + // `group_contracts` call to discover its own sync was narrowed. + suppressedMatchStages: result.suppressedMatchStages, // 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. @@ -465,9 +551,14 @@ export class GroupService { const { incompleteRepos: _incompleteRepos, ...truncation } = crossRepoCompleteness({ unreadableRepos, missingRepos, + suppressedMatchStages: registry.suppressedMatchStages, // 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, + // Either kind of unreadable provenance forces the floor: a sync that + // could not say which repos it read, or a suppression record that was + // present and could not be parsed. Reading the second as "nothing was + // suppressed" would report an unparseable registry as complete. + provenanceUnknown: unreadableRepos === undefined || loaded.suppressionUnreadable, // 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, @@ -482,6 +573,13 @@ export class GroupService { // convention `skippedCorrupt` follows below, and the difference between // "the sync measured zero unreadable repos" and "the sync never said". ...(unreadableRepos ? { unreadableRepos } : {}), + // Same omit-when-unrecorded rule, and deliberately NOT folded into the + // truncation triple below: that triple reports limits a run hit by + // accident, whose remedy is to fix the repo. A suppressed stage was + // asked for, and its remedy is to re-sync without that flag. + ...(registry.suppressedMatchStages + ? { suppressedMatchStages: registry.suppressedMatchStages } + : {}), // The structured triple, verbatim from the impact surface (KTD10): // `truncated` always, `truncationReason` + `riskEpistemic` with it. ...truncation, @@ -802,6 +900,10 @@ export class GroupService { // "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), + // Same tri-state, same reason: `group status` is where an operator goes + // to ask "is this group's answer trustworthy right now", and a registry + // narrowed on purpose is a different answer from a complete one. + suppressedMatchStages: recordedMatchStages(registry?.suppressedMatchStages), repos: repoStatuses, }; } diff --git a/gitnexus/src/core/group/storage.ts b/gitnexus/src/core/group/storage.ts index ab12599d5..6e82fbb0a 100644 --- a/gitnexus/src/core/group/storage.ts +++ b/gitnexus/src/core/group/storage.ts @@ -98,13 +98,8 @@ detect: http: true grpc: true topics: true - shared_libs: true - embedding_fallback: true matching: - bm25_threshold: 0.7 - embedding_threshold: 0.65 - max_candidates_per_step: 3 # exclude_links_paths: [/ping, /health, /healthcheck] # exclude_links_param_only_paths: false `; diff --git a/gitnexus/src/core/group/sync.ts b/gitnexus/src/core/group/sync.ts index 992378236..7efd65ef7 100644 --- a/gitnexus/src/core/group/sync.ts +++ b/gitnexus/src/core/group/sync.ts @@ -19,6 +19,7 @@ import type { StoredContract, CrossLink, GroupManifestLink, + MatchType, } from './types.js'; import { HttpRouteExtractor } from './extractors/http-route-extractor.js'; import { GrpcExtractor } from './extractors/grpc-extractor.js'; @@ -28,10 +29,15 @@ import { IncludeExtractor } from './extractors/include-extractor.js'; import { ManifestExtractor } from './extractors/manifest-extractor.js'; import { discoverWorkspaceLinks } from './extractors/workspace-extractor.js'; import { buildProviderIndex, runExactMatch, runWildcardMatch } from './matching.js'; +import type { WildcardMatchResult } from './matching.js'; import { detectServiceBoundaries, assignService } from './service-boundary-detector.js'; import type { CypherExecutor } from './contract-extractor.js'; import { getContractRegistryPath, readContractRegistry, writeContractRegistry } from './storage.js'; -import { refreshPreservedBridgeMeta, writeBridgeUnlocked } from './bridge-db.js'; +import { + markBridgeProvenanceUnknown, + refreshPreservedBridgeMeta, + writeBridgeUnlocked, +} from './bridge-db.js'; import { withGroupSyncLock } from './group-lock.js'; import type { ContractRegistry } from './types.js'; @@ -43,10 +49,8 @@ export interface SyncOptions { resolveRepoHandle?: (registryName: string, groupPath: string) => Promise; skipWrite?: boolean; groupDir?: string; - allowStale?: boolean; verbose?: boolean; exactOnly?: boolean; - skipEmbeddings?: boolean; } /** @@ -95,6 +99,14 @@ export interface SyncResult { */ unreadableRepos: string[]; repoSnapshots: Record; + /** + * Matching stages this run was asked to skip. Populated on EVERY outcome, + * not just `written`: the sync genuinely did skip the stage whatever + * happened to the file afterwards, and the CLI summary renders from this + * rather than re-deriving it from the caller's options. Only the persisted + * registry stamps it conditionally — see the write below. + */ + suppressedMatchStages: MatchType[]; /** * What this sync did to `contracts.json`. Callers must not announce a write * they did not get: without this, `group sync` printed "Wrote contracts.json @@ -561,7 +573,21 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis const providerIndex = buildProviderIndex(autoContracts, config.matching); const { matched, unmatched } = runExactMatch(autoContracts, providerIndex, config.matching); - const wildcard = runWildcardMatch(unmatched, providerIndex); + // `exactOnly` had the same defect this PR removes `skipEmbeddings` for: it was + // declared, threaded through the CLI and MCP, and never read, so `--exact-only` + // silently produced wildcard links anyway. It is honoured here rather than + // deleted because — unlike the never-built BM25/embedding stages — the stage it + // names does exist, so the flag describes a real choice. Contracts left + // unmatched by the exact pass stay unmatched, which is exactly what it promises. + // `remaining`, not `unmatched`: this feeds SyncResult.unmatched below, so every + // contract the exact pass could not place has to be reported as unmatched + // rather than silently dropped from the count. + const wildcard: WildcardMatchResult = opts?.exactOnly + ? { matched: [], remaining: unmatched } + : runWildcardMatch(unmatched, providerIndex); + // Measured, not assumed: `[]` says this run suppressed nothing, which is a + // different statement from a registry that never recorded the field at all. + const suppressedMatchStages: MatchType[] = opts?.exactOnly ? ['wildcard'] : []; // Dedupe cross-links. Manifest contracts participate in runExactMatch, so a // manifest-declared link can also emit a matchType:'exact' CrossLink with the @@ -583,6 +609,11 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis // not recorded, telling the operator to re-run the sync that had just // succeeded. The tri-state only works if the writer commits to it. unreadableRepos, + // Stamped only on the path that writes THIS run's contracts. The preserve + // path below re-writes `{ ...prior }`, so a carried-forward registry keeps + // the marker of the sync that actually produced its contracts instead of + // being relabelled with this run's request. + suppressedMatchStages, contracts: allContracts, crossLinks, }; @@ -765,6 +796,7 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis repoSnapshots, missingRepos, unreadableRepos, + suppressedMatchStages, }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -777,11 +809,23 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis // true is the one thing not to do — it would recreate exactly the // metadata/database mis-pairing the stamping on the preserve path above // exists to prevent. + // Fail the stale bridge CLOSED. contracts.json has advanced and the + // database has not, so the two describe different syncs; leaving the + // bridge vouching for itself lets `group_impact` call a superseded + // graph complete while `group_contracts` reports the new one. This + // withdraws the claim without touching the database — the previous + // graph stays queryable, it just stops being called complete. + const withdrawn = await markBridgeProvenanceUnknown(groupDir); + const provenanceNote = withdrawn + ? 'Its metadata has been marked provenance-unknown, so those answers now report as ' + + 'a lower bound rather than as complete.' + : 'Its metadata could NOT be marked provenance-unknown, so those answers may still ' + + 'report as complete despite describing an older sync.'; logger.warn( - { err: msg, groupDir }, + { err: msg, groupDir, bridgeProvenanceWithdrawn: withdrawn }, '⚠️ writeBridge failed; contracts.json is intact and is the canonical copy, ' + 'but bridge.lbug was not replaced: cross-repo queries may still answer from ' + - "the previous sync's contracts, and nothing marks them as superseded. " + + `the previous sync's contracts. ${provenanceNote} ` + 'Re-run `gitnexus group sync` to retry.', ); } @@ -792,6 +836,7 @@ export async function syncGroup(config: GroupConfig, opts?: SyncOptions): Promis return { contracts: allContracts, crossLinks, + suppressedMatchStages, unmatched: wildcard.remaining, missingRepos, unreadableRepos, diff --git a/gitnexus/src/core/group/types.ts b/gitnexus/src/core/group/types.ts index ee6b7cda5..cf7191664 100644 --- a/gitnexus/src/core/group/types.ts +++ b/gitnexus/src/core/group/types.ts @@ -1,5 +1,5 @@ export type ContractType = 'http' | 'grpc' | 'thrift' | 'topic' | 'lib' | 'custom' | 'include'; -export type MatchType = 'exact' | 'manifest' | 'wildcard' | 'bm25' | 'embedding'; +export type MatchType = 'exact' | 'manifest' | 'wildcard'; export type ContractRole = 'provider' | 'consumer'; export interface GroupConfig { @@ -26,16 +26,11 @@ export interface DetectConfig { grpc: boolean; thrift: boolean; topics: boolean; - shared_libs: boolean; - embedding_fallback: boolean; includes: boolean; workspace_deps: boolean; } export interface MatchingConfig { - bm25_threshold: number; - embedding_threshold: number; - max_candidates_per_step: number; /** * HTTP paths to exclude from cross-link matching. Contracts at these paths * are still extracted and visible in the registry, but they don't produce @@ -114,6 +109,20 @@ export interface ContractRegistry { * absent means "not recorded", not "none". */ unreadableRepos?: string[]; + /** + * Matching stages this sync was ASKED to skip, so a later reader can tell a + * short cross-link list from a complete one. `--exact-only` / `exactOnly` + * suppresses the wildcard stage, and the registry it writes is otherwise + * indistinguishable from one where that stage ran and matched nothing. + * + * Same tri-state as `unreadableRepos` and for the same reason: absent means + * "not recorded" (written before this field existed), `[]` means "measured, + * nothing was suppressed", and a populated list names the stages. Distinct + * from `truncated` / `truncationReason`, which report limits this run hit by + * accident — a suppressed stage is a deliberate request, and its remedy is + * "re-sync without exactOnly", not "fix the unreadable repo". + */ + suppressedMatchStages?: MatchType[]; contracts: StoredContract[]; crossLinks: CrossLink[]; } @@ -137,6 +146,13 @@ export interface RepoHandle { * 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. + * `'suppressed-stage'` is structural too but has its own remedy: the sync was + * ASKED to skip a matching stage (`--exact-only`), so cross-links that stage + * would have found are absent by request. Retrying returns the same floor, and + * so does re-running the sync — the fix is to re-run it WITHOUT the flag. Kept a + * separate member rather than folded into `'incomplete-sync'` precisely because + * that remedy differs; telling an agent to repair a repo it read fine is the + * failure this distinction exists to prevent. * * 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 @@ -145,7 +161,12 @@ export interface RepoHandle { * 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 const GROUP_IMPACT_TRUNCATION_REASONS = [ + 'timeout', + 'partial', + 'incomplete-sync', + 'suppressed-stage', +] as const; export type GroupImpactTruncationReason = (typeof GROUP_IMPACT_TRUNCATION_REASONS)[number]; @@ -357,4 +378,12 @@ export interface BridgeMeta { * Optional: a bridge written before this field existed does not record it. */ unreadableRepos?: string[]; + /** + * Matching stages the sync that built this bridge was asked to skip. + * PERSISTED, like `unreadableRepos` and unlike `repoListsUnreadable` — a + * later `group_impact` or `trace` reads this bridge with no access to the run + * that produced it, and a narrowed graph is otherwise indistinguishable from + * a complete one. Same tri-state: absent is "not recorded". + */ + suppressedMatchStages?: MatchType[]; } diff --git a/gitnexus/src/mcp/resources.ts b/gitnexus/src/mcp/resources.ts index d58a13a79..3411878bd 100644 --- a/gitnexus/src/mcp/resources.ts +++ b/gitnexus/src/mcp/resources.ts @@ -112,7 +112,11 @@ export function getResourceTemplates(): ResourceTemplate[] { 'three-state, and an ABSENT key is not an empty one: absent means the last sync never ' + 'recorded which repos it could read (provenance unknown — treat cross-repo answers for ' + 'this group as a floor), an empty list means the sync measured none, and a populated list ' + - 'names the repos whose contracts are missing from the registry.', + 'names the repos whose contracts are missing from the registry. suppressedMatchStages is ' + + 'three-state the same way: absent is a registry predating the field, an empty list means ' + + 'the sync skipped no matching stage, and a populated list names stages it was ASKED to ' + + 'skip — those cross-link counts are a lower bound by request, and the remedy is to re-sync ' + + 'without that flag rather than to repair a repo.', mimeType: 'text/yaml', }, ]; diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 7fea547a5..e7e453a1b 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -505,7 +505,7 @@ Handles disambiguation: when multiple symbols share the target name, returns ran EdgeType: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, METHOD_OVERRIDES, METHOD_IMPLEMENTS, ACCESSES Confidence: 1.0 = certain, <0.8 = fuzzy match -GROUP MODE: set "repo" to "@" for cross-repo impact anchored at the default member (lexicographically first key in group.yaml "repos"), or "@/" to choose the member (same path keys as in group.yaml). Phase-1 walk runs in that member; cross-boundary fan-out uses the group bridge. A cross entry with fanout_status:"not_attempted" proves the declared repository boundary, but its far endpoint has no graph symbol; do not interpret empty by_depth or affected_processes on that entry as a completed zero-impact walk. The fan-out attempts at most 50 neighbour crossings, strongest-confidence first. Any short answer carries truncated:true, truncatedRepos, riskEpistemic:"lower-bound" AND a truncationReason — dropping a crossing can only move risk DOWN, so treat that risk as a floor, never as a verdict. truncated:true does NOT always mean the fan-out ran out of room, so branch on truncationReason: the remedy differs. 'timeout' (the fan-out's wall-clock budget expired) and 'partial' (a neighbour crossing, or the local walk, was cut short) are runtime limits — the same query can return more on a retry or with a larger timeoutMs. 'incomplete-sync' is structural: the group bridge was built by a sync that could not say which repos it read, or that could not read an in-scope repo, so those repos' contracts are absent from EVERY query against this bridge, and truncatedRepos names them even when ZERO crossings to them were attempted. Retrying returns the same floor — run group_sync (\`gitnexus group sync\`) and query again. +GROUP MODE: set "repo" to "@" for cross-repo impact anchored at the default member (lexicographically first key in group.yaml "repos"), or "@/" to choose the member (same path keys as in group.yaml). Phase-1 walk runs in that member; cross-boundary fan-out uses the group bridge. A cross entry with fanout_status:"not_attempted" proves the declared repository boundary, but its far endpoint has no graph symbol; do not interpret empty by_depth or affected_processes on that entry as a completed zero-impact walk. The fan-out attempts at most 50 neighbour crossings, strongest-confidence first. Any short answer carries truncated:true, truncatedRepos, riskEpistemic:"lower-bound" AND a truncationReason — dropping a crossing can only move risk DOWN, so treat that risk as a floor, never as a verdict. truncated:true does NOT always mean the fan-out ran out of room, so branch on truncationReason: the remedy differs. 'timeout' (the fan-out's wall-clock budget expired) and 'partial' (a neighbour crossing, or the local walk, was cut short) are runtime limits — the same query can return more on a retry or with a larger timeoutMs. 'incomplete-sync' is structural: the group bridge was built by a sync that could not say which repos it read, or that could not read an in-scope repo, so those repos' contracts are absent from EVERY query against this bridge, and truncatedRepos names them even when ZERO crossings to them were attempted. Retrying returns the same floor — run group_sync (\`gitnexus group sync\`) and query again. 'suppressed-stage' is also structural but has a DIFFERENT remedy: the sync was asked to skip a matching stage (\`--exact-only\` / exactOnly), so cross-links that stage would have found are absent BY REQUEST. Re-running the sync unchanged returns the same floor — re-run it WITHOUT that flag. Do not report a repo as broken for this reason; nothing failed to read. SERVICE: optional monorepo path prefix (case-sensitive path segments). When "repo" starts with "@", scopes the local impact walk and cross-repo symbol paths to files under that prefix; ignored for a normal indexed repo name.`, annotations: READ_ONLY_TOOL_ANNOTATIONS, @@ -850,11 +850,11 @@ WHEN TO USE: Discover groups before group_sync. Optional "name" returns a single }, { name: 'group_sync', - description: `Rebuild the Contract Registry (contracts.json) for a group: extract HTTP contracts, apply manifest links, exact-match cross-links. + description: `Rebuild the Contract Registry (contracts.json) for a group: extract contracts (HTTP, gRPC, Thrift, topics, includes), apply manifest links, then cross-link by exact contract-id match followed by wildcard service match. WHEN TO USE: After changing group.yaml or re-indexing member repos. -READ THE RESULT: \`missingRepos\` are configured repos with no entry in the registry (index them, or drop them from group.yaml); \`unreadableRepos\` ARE registered but this sync could not extract from them — the index would not open (version skew, lock, corruption), or an extractor failed partway — so NONE of their contracts are in this sync and a following group_impact / group_contracts is a lower bound, not a verdict. \`registryOutcome\` says what happened to the file, and the three values a call here can return each need a different response: 'written' — this run's contracts replaced contracts.json; 'preserved' — nothing could be read, so contracts.json was rewritten keeping the previous sync's contracts and cross-links verbatim and refreshing only \`missingRepos\`/\`unreadableRepos\` to describe THIS run (the file changed, the contracts in it did not, and they are as old as the last sync that succeeded); 'superseded' — nothing could be read, and another sync replaced contracts.json while this one waited for the group lock; that file was left untouched and this run's lists were NOT recorded, because they describe an older group state than what is on disk (so the registry is fresher than this response's diagnostics, not staler); 'no-prior-registry' — nothing could be read AND there was no previous contracts.json to carry forward, so none was written and this group has no contract registry on disk. Only 'no-prior-registry' means there is nothing to read: after it, group_contracts / group_impact have no registry at all rather than a stale one, so fix the repos above and re-run before trusting either.`, +READ THE RESULT: \`missingRepos\` are configured repos with no entry in the registry (index them, or drop them from group.yaml); \`unreadableRepos\` ARE registered but this sync could not extract from them — the index would not open (version skew, lock, corruption), or an extractor failed partway — so NONE of their contracts are in this sync and a following group_impact / group_contracts is a lower bound, not a verdict. \`registryOutcome\` says what happened to the file, and the three values a call here can return each need a different response: 'written' — this run's contracts replaced contracts.json; 'preserved' — nothing could be read, so contracts.json was rewritten keeping the previous sync's contracts and cross-links verbatim and refreshing only \`missingRepos\`/\`unreadableRepos\` to describe THIS run (the file changed, the contracts in it did not, and they are as old as the last sync that succeeded); 'superseded' — nothing could be read, and another sync replaced contracts.json while this one waited for the group lock; that file was left untouched and this run's lists were NOT recorded, because they describe an older group state than what is on disk (so the registry is fresher than this response's diagnostics, not staler); 'no-prior-registry' — nothing could be read AND there was no previous contracts.json to carry forward, so none was written and this group has no contract registry on disk. Only 'no-prior-registry' means there is nothing to read: after it, group_contracts / group_impact have no registry at all rather than a stale one, so fix the repos above and re-run before trusting either. \`suppressedMatchStages\` names matching stages this sync was ASKED to skip, with the same three states as the repo lists: ABSENT means a registry written before the field existed, \`[]\` means this sync suppressed nothing, and a populated list means the cross-link set is a lower bound BY REQUEST — a later group_impact / group_contracts on it reports truncationReason 'suppressed-stage'.\n\nPARAMETERS ARE VALIDATED: \`exactOnly\` must be a real boolean — the string "false" is rejected, not coerced to true. The retired \`skipEmbeddings\` and \`allowStale\` parameters are refused by name; drop them from the call.`, // Usually writes contracts.json, so conservatively non-idempotent even // though output is deterministic for identical input. When no configured // repo could be read it still rewrites the file, keeping the previous @@ -866,11 +866,11 @@ READ THE RESULT: \`missingRepos\` are configured repos with no entry in the regi type: 'object', properties: { name: { type: 'string', description: 'Group name' }, - skipEmbeddings: { + exactOnly: { type: 'boolean', - description: 'Exact + BM25 only (Demo PR: same as default exact path)', + description: + 'Skip the wildcard service-match stage; cross-link only on exact contract-id match. Manifest links still apply.', }, - exactOnly: { type: 'boolean', description: 'Exact match only in cascade' }, }, required: ['name'], }, diff --git a/gitnexus/test/integration/group/group-cli.test.ts b/gitnexus/test/integration/group/group-cli.test.ts index 8e51ce1c5..a9c1840cf 100644 --- a/gitnexus/test/integration/group/group-cli.test.ts +++ b/gitnexus/test/integration/group/group-cli.test.ts @@ -71,6 +71,41 @@ describe('group CLI', () => { expect(source).not.toMatch(blanketClosePattern); }); + /** + * `--skip-embeddings` and `--allow-stale` were both accepted by commander and + * then read by nothing: the first named a BM25/embedding cascade that was + * never built, the second a stale-index warning that no sync path ever + * emitted. An operator who passed either got a silent no-op and a clean exit, + * which is worse than the flag not existing — so they are gone, and the CLI + * must now say so. + * + * `unknown option` is asserted rather than just a nonzero exit because + * `group sync ` ALSO exits nonzero (GroupNotFoundError), so + * the exit code alone cannot tell "the flag is rejected" from "the group is + * not there". The control below is what makes that distinction visible. + */ + it('test_sync_rejects_removed_skip_embeddings_flag', () => { + const r = runGroup(['sync', 'acme', '--skip-embeddings']); + expect(r.status).not.toBe(0); + expect(r.stderr).toContain("unknown option '--skip-embeddings'"); + }); + + it('test_sync_rejects_removed_allow_stale_flag', () => { + const r = runGroup(['sync', 'acme', '--allow-stale']); + expect(r.status).not.toBe(0); + expect(r.stderr).toContain("unknown option '--allow-stale'"); + }); + + it('control: the surviving --exact-only flag is still parsed', () => { + // Without this, the two cases above would also pass against a `group sync` + // that rejected EVERY option. This one reaches the action handler and + // fails on the group instead, which is the proof that commander accepted + // the flag itself. + const r = runGroup(['sync', 'no-such-group', '--exact-only']); + expect(r.stderr).not.toContain('unknown option'); + expect(`${r.stderr}\n${r.stdout}`).toContain('no-such-group'); + }); + it('group impact requires --target and --repo', () => { const c = runGroup(['create', 'impcli']); expect(c.status).toBe(0); @@ -441,6 +476,69 @@ describe('group sync says what it did to contracts.json', () => { expect(fs.existsSync(path.join(groupDir, 'contracts.json'))).toBe(true); }); + /** + * The per-stage `Matching:` block. It used to print `Matching cascade:` and + * count `exact` alone, while the `Wrote contracts.json (…)` line beneath it + * reported every cross-link — so for any group with manifest or wildcard + * links the two numbers disagreed with nothing on screen explaining why. + * + * A manifest fixture is enough to pin both halves. The stage counts have to + * sum to the printed total, and the skipped rendering does not depend on a + * stage having matched anything: `--exact-only` records the suppression + * whatever the fixture contains. + */ + const writeManifestGroup = (name: string): void => { + writeGroupYaml( + home, + name, + { 'app/backend': `${name}-backend`, 'app/frontend': `${name}-frontend` }, + ` + - from: app/frontend + to: app/backend + type: custom + contract: rotateSigningKey + role: consumer`, + ); + fs.writeFileSync(path.join(home, 'registry.json'), '[]', 'utf8'); + }; + + it('prints a count for every matching stage, and they sum to the written total', () => { + writeManifestGroup('stages'); + + const r = runGroupIn(home, ['sync', 'stages']); + + expect(r.status).toBe(0); + expect(r.stdout).toContain('exact: 0 cross-links (confidence 1.0)'); + expect(r.stdout).toContain('manifest: 1 cross-links'); + expect(r.stdout).toContain('wildcard: 0 cross-links'); + // The reconciliation this block exists for: 0 + 1 + 0 is the total below. + expect(r.stdout).toContain('Wrote contracts.json (2 contracts, 1 cross-links)'); + }); + + it('names a stage it was told to skip as skipped, not as zero', () => { + writeManifestGroup('skipped'); + + const r = runGroupIn(home, ['sync', 'skipped', '--exact-only']); + + expect(r.status).toBe(0); + expect(r.stdout).toContain('wildcard: skipped (--exact-only)'); + // "ran and matched nothing" must not be printable for a stage that never ran. + expect(r.stdout).not.toContain('wildcard: 0 cross-links'); + // Manifest links are unaffected by the flag, so the total still says so. + expect(r.stdout).toContain('manifest: 1 cross-links'); + }); + + // control: the skipped rendering tracks the flag, not the fixture. Without + // this, printing `skipped` unconditionally would pass the case above. + it('control: the same group without the flag reports the stage as zero', () => { + writeManifestGroup('unskipped'); + + const r = runGroupIn(home, ['sync', 'unskipped']); + + expect(r.stdout).toContain('wildcard: 0 cross-links'); + expect(r.stdout).not.toContain('skipped (--exact-only)'); + }); + it('says the previous contracts.json was KEPT when no repo could be read', () => { // "Did NOT write contracts.json" was false here: this path REWRITES the // file, keeping the previous sync's contracts and replacing only the two diff --git a/gitnexus/test/integration/group/group-sync-lock-concurrency.test.ts b/gitnexus/test/integration/group/group-sync-lock-concurrency.test.ts index fff0dd746..84a90c55d 100644 --- a/gitnexus/test/integration/group/group-sync-lock-concurrency.test.ts +++ b/gitnexus/test/integration/group/group-sync-lock-concurrency.test.ts @@ -51,12 +51,10 @@ const makeConfig = (name: string): GroupConfig => ({ grpc: false, thrift: false, topics: false, - shared_libs: false, includes: false, workspace_deps: false, - embedding_fallback: false, }, - matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, + matching: {}, }); const parentContract: StoredContract = { diff --git a/gitnexus/test/unit/group/config-parser.test.ts b/gitnexus/test/unit/group/config-parser.test.ts index 22bb2ad26..2ede066f4 100644 --- a/gitnexus/test/unit/group/config-parser.test.ts +++ b/gitnexus/test/unit/group/config-parser.test.ts @@ -59,11 +59,55 @@ repos: expect(config.links).toEqual([]); expect(config.packages).toEqual({}); expect(config.detect.http).toBe(true); - expect(config.matching.bm25_threshold).toBe(0.7); expect(config.matching.exclude_links_paths).toEqual([]); expect(config.matching.exclude_links_param_only_paths).toBe(false); }); + it('still parses a legacy config carrying the removed matching knobs', () => { + // `bm25_threshold`, `embedding_threshold` and `detect.embedding_fallback` + // were written into every generated group.yaml but read by no matcher, so + // they are gone from the schema and the template. Every group.yaml already + // on disk still has them, and must keep loading without complaint. + const legacy = ` +version: 1 +name: test +repos: + app: my-app +detect: + http: true + embedding_fallback: true + shared_libs: true +matching: + bm25_threshold: 0.7 + embedding_threshold: 0.65 + max_candidates_per_step: 3 +`; + const config = parseGroupConfig(legacy); + expect(config.name).toBe('test'); + expect(config.repos).toEqual({ app: 'my-app' }); + expect(config.detect.http).toBe(true); + + // Pinned behavior: PRESERVE, not strip. The parser spreads the raw block + // over its defaults (`{ ...DEFAULT_MATCHING, ...raw.matching }`), so a key + // it no longer knows about survives into the returned config. + // + // Every assertion above is satisfied by the defaults alone, so without this + // the test only proves "does not throw" — it would stay green under a + // future strict validator that silently DROPPED the operator's legacy keys. + // That is not a harmless drop: `group add` and `group remove` in + // gitnexus/src/cli/group.ts round-trip the file through `loadGroupConfig` + // → `yaml.dump` → write, so anything the parser discards is deleted from + // the operator's checked-in group.yaml the next time they add a repo. + expect((config.matching as unknown as Record).bm25_threshold).toBe(0.7); + expect((config.matching as unknown as Record).embedding_threshold).toBe(0.65); + expect((config.detect as unknown as Record).embedding_fallback).toBe(true); + // The two keys this commit removes, pinned the same way and for the same + // reason: an operator's group.yaml carries them today because + // `gitnexus group create` wrote them there. + expect((config.matching as unknown as Record).max_candidates_per_step).toBe(3); + expect((config.detect as unknown as Record).shared_libs).toBe(true); + }); + it('defaults thrift detection to true', () => { const minimal = ` version: 1 diff --git a/gitnexus/test/unit/group/fixtures.ts b/gitnexus/test/unit/group/fixtures.ts index ad28a7166..a496c332a 100644 --- a/gitnexus/test/unit/group/fixtures.ts +++ b/gitnexus/test/unit/group/fixtures.ts @@ -35,6 +35,41 @@ export function makeContract(overrides: Partial = {}): StoredCon }; } +/** + * The one contract shape `runWildcardMatch` fires on: a thrift service-wildcard + * consumer and a matching method-level provider. `runExactMatch` skips wildcard + * consumers, so this pair links through the wildcard stage or through no stage + * at all — which is what makes it the fixture for anything testing that stage + * being run or skipped. + * + * Shared because two suites need exactly this pair; if the predicate in + * `isServiceWildcard` ever changes, it changes here once. + */ +export function makeWildcardPair(): { provider: StoredContract; consumer: StoredContract } { + return { + provider: makeContract({ + contractId: 'thrift::billing.v1.OrderService/PlaceOrder', + type: 'thrift', + role: 'provider', + symbolUid: 'uid-provider-place-order', + symbolRef: { filePath: 'src/provider.ts', name: 'OrderService.PlaceOrder' }, + symbolName: 'OrderService.PlaceOrder', + confidence: 0.9, + repo: 'app/provider', + }), + consumer: makeContract({ + contractId: 'thrift::OrderService/*', + type: 'thrift', + role: 'consumer', + symbolUid: 'uid-consumer-order-service', + symbolRef: { filePath: 'src/consumer.ts', name: 'callOrderService' }, + symbolName: 'callOrderService', + confidence: 0.9, + repo: 'app/consumer', + }), + }; +} + /** * Write the `waveful` group's `group.yaml` into `groupDir` (creating it), with * every detector disabled so a suite's own bridge rows are the only thing that diff --git a/gitnexus/test/unit/group/group-tools.test.ts b/gitnexus/test/unit/group/group-tools.test.ts index 85ba45f0a..a5412e198 100644 --- a/gitnexus/test/unit/group/group-tools.test.ts +++ b/gitnexus/test/unit/group/group-tools.test.ts @@ -18,4 +18,23 @@ describe('Group MCP tools', () => { const tool = GITNEXUS_TOOLS.find((t) => t.name === 'group_sync')!; expect(tool.inputSchema.required).toContain('name'); }); + + it('group_sync no longer advertises skipEmbeddings, and still advertises exactOnly', () => { + // `skipEmbeddings` named a BM25/embedding cascade that was never built — + // the handler read the parameter and every value took the same code path, + // so the schema advertised a choice an agent could not actually make. It is + // gone from `SyncOptions`, the CLI and here. + // + // `exactOnly` is asserted in the same test on purpose: it is the parameter + // NEXT TO the deleted one, it survived, and it now does what it says (see + // sync-exact-only.test.ts). Pinning only the absence would stay green if a + // later edit removed the wrong one of the two. + const tool = GITNEXUS_TOOLS.find((t) => t.name === 'group_sync')!; + expect(tool.inputSchema.properties).not.toHaveProperty('skipEmbeddings'); + // `verbose` gates diagnostics on the server's logger, which an MCP caller + // cannot observe. Advertising it would promise a knob whose effect is + // invisible to the only audience that reads this schema. + expect(tool.inputSchema.properties).not.toHaveProperty('verbose'); + expect(tool.inputSchema.properties.exactOnly).toMatchObject({ type: 'boolean' }); + }); }); diff --git a/gitnexus/test/unit/group/matching.test.ts b/gitnexus/test/unit/group/matching.test.ts index 6e5b80786..a10f1d017 100644 --- a/gitnexus/test/unit/group/matching.test.ts +++ b/gitnexus/test/unit/group/matching.test.ts @@ -636,9 +636,6 @@ describe('buildNoisyContractFilter (via runExactMatch)', () => { it('exclude_links_paths prevents cross-links for configured paths', () => { const matchingConfig: MatchingConfig = { - bm25_threshold: 0.7, - embedding_threshold: 0.65, - max_candidates_per_step: 3, exclude_links_paths: ['/ping'], exclude_links_param_only_paths: false, }; @@ -659,9 +656,6 @@ describe('buildNoisyContractFilter (via runExactMatch)', () => { it('excluded providers do not appear in matched', () => { const matchingConfig: MatchingConfig = { - bm25_threshold: 0.7, - embedding_threshold: 0.65, - max_candidates_per_step: 3, exclude_links_paths: ['/health'], exclude_links_param_only_paths: false, }; @@ -679,9 +673,6 @@ describe('buildNoisyContractFilter (via runExactMatch)', () => { it('excluded contracts do not appear in unmatched', () => { const matchingConfig: MatchingConfig = { - bm25_threshold: 0.7, - embedding_threshold: 0.65, - max_candidates_per_step: 3, exclude_links_paths: ['/ping'], exclude_links_param_only_paths: false, }; @@ -700,9 +691,6 @@ describe('buildNoisyContractFilter (via runExactMatch)', () => { it('exclude_links_param_only_paths filters /{param} and /{param}/{param}', () => { const matchingConfig: MatchingConfig = { - bm25_threshold: 0.7, - embedding_threshold: 0.65, - max_candidates_per_step: 3, exclude_links_paths: [], exclude_links_param_only_paths: true, }; @@ -723,9 +711,6 @@ describe('buildNoisyContractFilter (via runExactMatch)', () => { it('mixed routes like /users/{param} are NOT excluded by param_only', () => { const matchingConfig: MatchingConfig = { - bm25_threshold: 0.7, - embedding_threshold: 0.65, - max_candidates_per_step: 3, exclude_links_paths: [], exclude_links_param_only_paths: true, }; @@ -757,9 +742,6 @@ describe('buildNoisyContractFilter (via runExactMatch)', () => { it('trailing slash on contractId still matches configured exclusion', () => { const matchingConfig: MatchingConfig = { - bm25_threshold: 0.7, - embedding_threshold: 0.65, - max_candidates_per_step: 3, exclude_links_paths: ['/ping'], exclude_links_param_only_paths: false, }; @@ -778,9 +760,6 @@ describe('buildNoisyContractFilter (via runExactMatch)', () => { it('root path exclusion ["/"] suppresses http::GET::/ contracts', () => { const matchingConfig: MatchingConfig = { - bm25_threshold: 0.7, - embedding_threshold: 0.65, - max_candidates_per_step: 3, exclude_links_paths: ['/'], exclude_links_param_only_paths: false, }; @@ -802,9 +781,6 @@ describe('buildNoisyContractFilter (via runExactMatch)', () => { it('non-HTTP contracts are never filtered', () => { const matchingConfig: MatchingConfig = { - bm25_threshold: 0.7, - embedding_threshold: 0.65, - max_candidates_per_step: 3, exclude_links_paths: ['/ping'], exclude_links_param_only_paths: true, }; diff --git a/gitnexus/test/unit/group/registry-suppressed-stages.test.ts b/gitnexus/test/unit/group/registry-suppressed-stages.test.ts new file mode 100644 index 000000000..88a9ecea5 --- /dev/null +++ b/gitnexus/test/unit/group/registry-suppressed-stages.test.ts @@ -0,0 +1,227 @@ +/** + * `suppressedMatchStages` — what a sync says about the stages it was told to skip. + * + * `--exact-only` / `exactOnly` suppresses the wildcard matching stage. The + * registry it writes is otherwise indistinguishable from one where that stage + * ran and matched nothing, and `group_impact` / cross-repo `trace` read that + * registry as authoritative. So the sync has to say so. + * + * The tri-state is the same one `unreadableRepos` uses, and the reason is the + * same: ABSENT means a registry written before the field existed and therefore + * has no opinion; EMPTY is a measurement — this run suppressed nothing; + * POPULATED names the stages. Normalizing absent to `[]` would report an + * unmeasured registry as a clean one. + * + * Two properties here are easy to get wrong and are pinned deliberately: + * the returned result carries the marker on EVERY outcome (the sync really did + * skip the stage whatever happened to the file), while the persisted registry + * stamps it only on the outcome that writes this run's contracts. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { syncGroup } from '../../../src/core/group/sync.js'; +import { makeWildcardPair } from './fixtures.js'; +import { + crossRepoCompleteness, + type CrossRepoCompleteness, +} from '../../../src/core/group/completeness.js'; +import { GROUP_IMPACT_TRUNCATION_REASONS } from '../../../src/core/group/types.js'; +import type { GroupConfig, ContractRegistry } from '../../../src/core/group/types.js'; + +const config: GroupConfig = { + version: 1, + name: 'suppressed', + description: '', + repos: { 'app/provider': 'provider-repo', 'app/consumer': 'consumer-repo' }, + links: [], + packages: {}, + detect: { + http: true, + grpc: false, + thrift: false, + topics: false, + includes: false, + workspace_deps: false, + }, + matching: { + exclude_links_paths: [], + exclude_links_param_only_paths: false, + }, +}; + +const { provider, consumer } = makeWildcardPair(); + +let groupDir: string; + +beforeEach(() => { + groupDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-suppressed-')); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(groupDir, { recursive: true, force: true }); +}); + +const run = (exactOnly: boolean, opts: { write: boolean } = { write: false }) => + syncGroup(config, { + extractorOverride: async () => [provider, consumer], + exactOnly, + ...(opts.write ? { groupDir } : { skipWrite: true }), + }); + +const readRegistry = (): ContractRegistry => + JSON.parse(fs.readFileSync(path.join(groupDir, 'contracts.json'), 'utf8')) as ContractRegistry; + +/** + * `CrossRepoCompleteness` is a discriminated union: `truncationReason` and + * `riskEpistemic` exist only on the `truncated: true` arm, so reading them off + * the union directly does not type-check. These read them positionally, the + * same way this suite reads a preserved key its type no longer carries. + */ +const fieldOf = (out: CrossRepoCompleteness, key: string): unknown => + (out as unknown as Record)[key]; +const reasonOf = (out: CrossRepoCompleteness): unknown => fieldOf(out, 'truncationReason'); + +describe('a sync records the matching stages it was told to skip', () => { + it('names the wildcard stage when exactOnly suppressed it', async () => { + const result = await run(true); + + expect(result.suppressedMatchStages).toEqual(['wildcard']); + expect(result.crossLinks).toEqual([]); + }); + + // control: the marker tracks the request, not a constant. Without this, a + // hardcoded `['wildcard']` would pass the case above. + it('control: measures an empty list when no stage was suppressed', async () => { + const result = await run(false); + + expect(result.suppressedMatchStages).toEqual([]); + expect(result.crossLinks).toHaveLength(1); + expect(result.crossLinks[0].matchType).toBe('wildcard'); + }); + + it('persists the marker into contracts.json on a written sync', async () => { + await run(true, { write: true }); + + expect(readRegistry().suppressedMatchStages).toEqual(['wildcard']); + }); + + it('persists an empty measurement, not an absent key, on an unsuppressed sync', async () => { + await run(false, { write: true }); + + const registry = readRegistry(); + expect(registry.suppressedMatchStages).toEqual([]); + // The distinction the tri-state exists for: a measured zero is not silence. + expect(registry).toHaveProperty('suppressedMatchStages'); + }); +}); + +/** + * The half that matters to a later reader: does a narrowed graph still claim + * to be complete? `crossRepoCompleteness` is the ONE computation behind the + * truncation triple that `group_impact`, cross-repo `trace` and the contract + * listing all return, so pinning it here covers all three. + */ +describe('cross-repo completeness reflects a suppressed stage', () => { + it('reports a floor, with its own reason, when a stage was suppressed', () => { + const out = crossRepoCompleteness({ + unreadableRepos: [], + missingRepos: [], + suppressedMatchStages: ['wildcard'], + provenanceUnknown: false, + inScope: () => true, + }); + + expect(out.truncated).toBe(true); + expect(reasonOf(out)).toBe('suppressed-stage'); + expect(fieldOf(out, 'riskEpistemic')).toBe('lower-bound'); + }); + + // control: without a suppressed stage the same clean input is complete. + // Without this, hardcoding `truncated: true` would pass the case above. + it('control: a clean sync with nothing suppressed is not truncated', () => { + const out = crossRepoCompleteness({ + unreadableRepos: [], + missingRepos: [], + suppressedMatchStages: [], + provenanceUnknown: false, + inScope: () => true, + }); + + expect(out.truncated).toBe(false); + expect(reasonOf(out)).toBeUndefined(); + }); + + // An unreadable repo is the more serious gap and its remedy differs, so it + // has to win the reason slot rather than being masked by the flag. + it('lets an unreadable repo outrank a suppressed stage in the reason', () => { + const out = crossRepoCompleteness({ + unreadableRepos: ['app/backend'], + missingRepos: [], + suppressedMatchStages: ['wildcard'], + provenanceUnknown: false, + inScope: () => true, + }); + + expect(out.truncated).toBe(true); + expect(reasonOf(out)).toBe('incomplete-sync'); + }); +}); + +/** + * The reason must stay a DECLARED member of the union agents branch on. + * + * That it is reachable from real code is already pinned above, by a case that + * drives `crossRepoCompleteness` and gets the value back. What that cannot see + * is the union itself shrinking: a guard in `tools.test.ts` asserts every + * member is documented, so dropping a member keeps that guard green while every + * consumer silently loses the value. + */ +describe('the suppressed-stage reason is reachable, not just documented', () => { + it('is a declared member of the reason union agents branch on', () => { + // If a future change drops it from the union, the tool description guard + // would still pass while every consumer lost the value. + expect(GROUP_IMPACT_TRUNCATION_REASONS).toContain('suppressed-stage'); + }); +}); + +/** + * An UNREADABLE suppression record must not read as "nothing was suppressed". + * + * `recordedMatchStages` is all-or-nothing on purpose: garbage collapses to + * `undefined`. A consumer that then treats `undefined` as an empty measurement + * throws that safety away and reports a registry it could not parse as + * complete. Absent stays legitimate — a registry written before the field + * existed has no opinion and should not be forced to a floor. + */ +describe('an unreadable suppression record fails closed', () => { + it('does not report a registry it could not parse as complete', () => { + const garbage = crossRepoCompleteness({ + unreadableRepos: [], + missingRepos: [], + suppressedMatchStages: [], + // What `loadContractRegistryResilient` now passes when the stored value + // was present and could not be read. + provenanceUnknown: true, + inScope: () => true, + }); + + expect(garbage.truncated).toBe(true); + expect(reasonOf(garbage)).toBe('incomplete-sync'); + }); + + // control: an absent record is not an unreadable one. Without this, forcing + // every pre-existing registry to a floor would pass the case above. + it('control: a clean registry with nothing recorded stays complete', () => { + const clean = crossRepoCompleteness({ + unreadableRepos: [], + missingRepos: [], + provenanceUnknown: false, + inScope: () => true, + }); + + expect(clean.truncated).toBe(false); + }); +}); diff --git a/gitnexus/test/unit/group/service-group-sync-payload.test.ts b/gitnexus/test/unit/group/service-group-sync-payload.test.ts index f96841d77..ddfa32d71 100644 --- a/gitnexus/test/unit/group/service-group-sync-payload.test.ts +++ b/gitnexus/test/unit/group/service-group-sync-payload.test.ts @@ -82,10 +82,20 @@ const syncResult = (overrides: Partial = {}): SyncResult => ({ missingRepos: [], unreadableRepos: [], repoSnapshots: {}, + suppressedMatchStages: [], registryOutcome: 'written', ...overrides, }); +/** + * `syncGroupMock` is declared zero-arg, so `mock.calls` is typed as an array of + * the empty tuple and indexing `[1]` does not type-check. The runtime call + * genuinely has two arguments (config, options); this reads the second without + * restating a signature the rest of the suite does not need. + */ +const syncOptsOf = (call: number): Record => + (syncGroupMock.mock.calls[call] as unknown as unknown[])[1] as Record; + const CONTRACT = makeContract({ repo: 'app/backend' }); const CROSS_LINK: CrossLink = { contractId: CONTRACT.contractId, @@ -172,6 +182,7 @@ describe('group_sync forwards what the sync learned about the repos and the file unmatched: 1, missingRepos: ['app/frontend'], unreadableRepos: ['app/backend'], + suppressedMatchStages: [], registryOutcome: 'preserved', }); }); @@ -190,6 +201,7 @@ describe('group_sync forwards what the sync learned about the repos and the file unmatched: 0, missingRepos: [], unreadableRepos: [], + suppressedMatchStages: [], registryOutcome: 'written', }); }); @@ -302,3 +314,124 @@ describe('group_contracts forwards its structured incompleteness', () => { }); }); }); + +/** + * What `group_sync` REFUSES to run on. + * + * The MCP SDK does not enforce a tool's advertised `inputSchema` and + * `callTool` is reachable directly, so this service method is the real + * validation boundary. Two consequences this block pins: + * + * - `exactOnly` now gates a matching stage, so `Boolean(params.exactOnly)` + * turned the string `"false"` — a common shape for an LLM caller emitting + * JSON — into `true` and persisted a registry with the wildcard stage + * suppressed. The opposite of what the caller asked for, written to disk. + * - `skipEmbeddings` and `allowStale` were retired. The CLI rejects them + * outright; the MCP path accepted and silently dropped them, so an agent + * working from a cached schema was told nothing. + * + * Every case asserts `syncGroupMock` was NOT called: a rejection that still + * runs the sync is the failure mode, and an error string alone cannot tell + * the two apart. + */ +describe('group_sync rejects malformed and retired parameters', () => { + it.each([['false'], ['true'], [0], [1], [null], [{}], [[]]])( + 'rejects a non-boolean exactOnly (%j) and runs no sync', + async (bad) => { + const payload = await new GroupService(port).groupSync({ name: GROUP, exactOnly: bad }); + + expect(payload).toEqual({ + error: `Invalid "exactOnly": expected true or false, got ${JSON.stringify(bad)}.`, + }); + expect(syncGroupMock).not.toHaveBeenCalled(); + }, + ); + + // `verbose` is no longer part of this tool's surface: not advertised, not + // validated, not forwarded. A caller that still sends it is ignored rather + // than refused — it was never a documented parameter, so there is nothing to + // reject on behalf of, and the retired-name guard is reserved for parameters + // this tool actually withdrew. + it('ignores verbose entirely rather than validating or forwarding it', async () => { + syncGroupMock.mockResolvedValue(syncResult()); + + const payload = (await new GroupService(port).groupSync({ + name: GROUP, + verbose: 'not-a-boolean', + })) as Record; + + expect(payload.error).toBeUndefined(); + expect(syncGroupMock).toHaveBeenCalledTimes(1); + expect(syncOptsOf(0)).not.toHaveProperty('verbose'); + }); + + // The error path must not throw. `JSON.stringify` — the right renderer here, + // because it distinguishes the string "false" from the boolean — throws on a + // BigInt and on a cyclic object, and `callTool` is reachable directly, so a + // validator that rejects instead of returning `{ error }` breaks its own + // contract on inputs a caller can actually send. + it('returns a structured error rather than throwing on an unserializable value', async () => { + const cyclic: Record = {}; + cyclic.self = cyclic; + + const fromBigInt = (await new GroupService(port).groupSync({ + name: GROUP, + exactOnly: 1n, + })) as Record; + const fromCyclic = (await new GroupService(port).groupSync({ + name: GROUP, + exactOnly: cyclic, + })) as Record; + + expect(String(fromBigInt.error)).toContain('Invalid "exactOnly"'); + expect(String(fromCyclic.error)).toContain('Invalid "exactOnly"'); + expect(syncGroupMock).not.toHaveBeenCalled(); + }); + + it.each([['skipEmbeddings'], ['allowStale']])( + 'rejects the retired %s parameter by name and runs no sync', + async (retired) => { + const payload = await new GroupService(port).groupSync({ name: GROUP, [retired]: true }); + + expect(payload).toEqual({ + error: `"${retired}" was removed and is no longer accepted. Drop it from the call.`, + }); + expect(syncGroupMock).not.toHaveBeenCalled(); + }, + ); + + it.each([[true], [false]])( + 'passes a real boolean exactOnly (%j) through unchanged', + async (ok) => { + syncGroupMock.mockResolvedValue(syncResult()); + + await new GroupService(port).groupSync({ name: GROUP, exactOnly: ok }); + + expect(syncGroupMock).toHaveBeenCalledTimes(1); + expect(syncOptsOf(0)).toMatchObject({ exactOnly: ok }); + }, + ); + + it('treats an omitted exactOnly as false', async () => { + syncGroupMock.mockResolvedValue(syncResult()); + + await new GroupService(port).groupSync({ name: GROUP }); + + expect(syncOptsOf(0)).toMatchObject({ exactOnly: false }); + }); + + // control: the guards above reject specific shapes, not every call. Without + // this, deleting the whole method body and returning an error would pass. + it('control: a valid call with only a name still syncs', async () => { + syncGroupMock.mockResolvedValue(syncResult({ registryOutcome: 'written' })); + + const payload = (await new GroupService(port).groupSync({ name: GROUP })) as Record< + string, + unknown + >; + + expect(payload.error).toBeUndefined(); + expect(payload.registryOutcome).toBe('written'); + expect(syncGroupMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/gitnexus/test/unit/group/sync-exact-only.test.ts b/gitnexus/test/unit/group/sync-exact-only.test.ts new file mode 100644 index 000000000..32623763f --- /dev/null +++ b/gitnexus/test/unit/group/sync-exact-only.test.ts @@ -0,0 +1,81 @@ +/** + * `--exact-only` / `exactOnly` had the same defect this PR removes + * `skipEmbeddings` for: it was declared on `SyncOptions`, threaded through the + * CLI and the MCP tool, and never read. A sync asked for exact matching only + * still ran the wildcard stage and still emitted `matchType: 'wildcard'` + * cross-links, so the flag was a promise the pipeline never kept. + * + * It is kept (rather than deleted alongside the never-built BM25/embedding + * stages) because the stage it names does exist, so the flag describes a real + * choice. These two cases run the SAME synthetic input through `syncGroup` + * twice and differ only in the flag, so the wildcard link is the only thing + * that can move between them. + */ +import { describe, it, expect } from 'vitest'; +import { syncGroup } from '../../../src/core/group/sync.js'; +import { makeWildcardPair } from './fixtures.js'; +import type { GroupConfig } from '../../../src/core/group/types.js'; + +describe('syncGroup exactOnly gates the wildcard stage', () => { + const config: GroupConfig = { + version: 1, + name: 'test', + description: '', + repos: { 'app/provider': 'provider-repo', 'app/consumer': 'consumer-repo' }, + links: [], + packages: {}, + detect: { + http: true, + grpc: false, + thrift: false, + topics: false, + includes: false, + workspace_deps: false, + }, + matching: {}, + }; + + const { provider, consumer } = makeWildcardPair(); + + it('runs the wildcard stage when exactOnly is not set', async () => { + const result = await syncGroup(config, { + extractorOverride: async () => [provider, consumer], + skipWrite: true, + }); + + expect(result.crossLinks).toHaveLength(1); + expect(result.crossLinks[0].matchType).toBe('wildcard'); + expect(result.crossLinks[0].contractId).toBe('thrift::OrderService/*'); + expect(result.crossLinks[0].from.repo).toBe('app/consumer'); + expect(result.crossLinks[0].to.repo).toBe('app/provider'); + // The consumer was placed, so only the provider is left over. + expect(result.unmatched.map((c) => c.contractId)).toEqual([ + 'thrift::billing.v1.OrderService/PlaceOrder', + ]); + }); + + it('emits no wildcard cross-link when exactOnly is true, and still reports the contract as unmatched', async () => { + const result = await syncGroup(config, { + extractorOverride: async () => [provider, consumer], + exactOnly: true, + skipWrite: true, + }); + + expect(result.crossLinks).toEqual([]); + + // The second half of the gate, and the reason it substitutes + // `{ matched: [], remaining: unmatched }` rather than an empty result: + // `wildcard.remaining` IS `SyncResult.unmatched`. A gate that returned + // `remaining: []` would also produce zero cross-links and pass the + // assertion above, while silently deleting both contracts from the + // unmatched count an operator reads to decide whether the flag cost them + // anything. Skipping the stage must leave its input unmatched, not gone. + expect(result.unmatched.map((c) => c.contractId)).toEqual([ + 'thrift::billing.v1.OrderService/PlaceOrder', + 'thrift::OrderService/*', + ]); + // Extraction itself is untouched by the flag — both contracts are still in + // the registry, only the link between them is withheld. + expect(result.contracts).toHaveLength(2); + }); +}); diff --git a/gitnexus/test/unit/group/sync-partial-extraction.test.ts b/gitnexus/test/unit/group/sync-partial-extraction.test.ts index a8fbc2c45..06b3bf450 100644 --- a/gitnexus/test/unit/group/sync-partial-extraction.test.ts +++ b/gitnexus/test/unit/group/sync-partial-extraction.test.ts @@ -92,12 +92,10 @@ const config = (): GroupConfig => ({ grpc: true, thrift: false, topics: false, - shared_libs: false, - embedding_fallback: false, includes: false, workspace_deps: false, }, - matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, + matching: {}, }); describe('syncGroup when one extractor fails partway through a repo', () => { diff --git a/gitnexus/test/unit/group/sync-unreadable-repos.test.ts b/gitnexus/test/unit/group/sync-unreadable-repos.test.ts index d5b38cfd5..12a85439c 100644 --- a/gitnexus/test/unit/group/sync-unreadable-repos.test.ts +++ b/gitnexus/test/unit/group/sync-unreadable-repos.test.ts @@ -169,12 +169,10 @@ const makeConfig = (repos: Record): GroupConfig => ({ grpc: false, thrift: false, topics: false, - shared_libs: false, - embedding_fallback: false, includes: false, workspace_deps: false, }, - matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, + matching: {}, }); /** @@ -331,6 +329,55 @@ describe('syncGroup with an unreadable index', () => { expect(onDisk.unreadableRepos).toEqual(['app/backend']); }); + it('keeps the prior suppressedMatchStages instead of stamping this run request', async () => { + // The preserved registry describes an EARLIER sync. If this run's request + // were stamped onto it, a graph narrowed by `--exact-only` would be + // relabelled complete the moment a later plain sync failed to read + // anything — and `group_impact` reads exactly that field to decide whether + // its answer is a floor. + initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR)); + + const contractsPath = path.join(groupDir, 'contracts.json'); + fs.writeFileSync( + contractsPath, + JSON.stringify({ ...PRIOR_REGISTRY, suppressedMatchStages: ['wildcard'] }), + ); + + // This run asks for NO suppression, and fails to read anything. + const result = await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir }); + + expect(result.registryOutcome).toBe('preserved'); + // The result describes THIS run — it really did suppress nothing. + expect(result.suppressedMatchStages).toEqual([]); + + const onDisk = JSON.parse(fs.readFileSync(contractsPath, 'utf8')) as Record; + // The file still describes the sync that produced its contracts. + expect(onDisk.suppressedMatchStages).toEqual(['wildcard']); + }); + + it('does not stamp this run request onto the preserved bridge metadata', async () => { + // Same property one artifact over. `bridge.lbug` is untouched on this path, + // so its meta.json must keep describing the sync that built it; otherwise + // contracts.json, meta.json and the database describe three different runs. + initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR)); + + fs.writeFileSync(path.join(groupDir, 'contracts.json'), JSON.stringify(PRIOR_REGISTRY)); + await writeBridgeMeta(groupDir, { + version: 1, + generatedAt: '2026-01-01T00:00:00.000Z', + missingRepos: [], + unreadableRepos: [], + suppressedMatchStages: ['wildcard'], + }); + + await syncGroup(makeConfig({ 'app/backend': 'backend-repo' }), { groupDir }); + + const meta = await readBridgeMeta(groupDir); + expect(meta.suppressedMatchStages).toEqual(['wildcard']); + // ...while the diagnostics describing THIS run are refreshed, as before. + expect(meta.unreadableRepos).toEqual(['app/backend']); + }); + it('writes nothing at all when there is no previous registry to preserve', async () => { initLbugMock.mockRejectedValue(new Error(LBUG_VERSION_ERROR)); @@ -923,6 +970,57 @@ describe('the warning after a failed bridge write', () => { const bridgeWarning = (cap: ReturnType) => cap.records().find((r) => r.level === 40 && typeof r.groupDir === 'string'); + it('withdraws the old bridge provenance when the registry advanced but the bridge write failed', async () => { + // The split-brain this guards: contracts.json commits, the bridge write + // then fails, and the previous database stays in place describing an + // EARLIER sync. Left vouching for itself, `group_impact` traverses that + // older graph and calls its answer complete while `group_contracts` + // reports the new registry — two public surfaces, contradictory claims, + // out of one sync. Marking provenance unknown withdraws the completeness + // claim without deleting a graph still useful as a floor. + // `unreadableRepos` is deliberately UNREADABLE here, not merely empty: that + // is what makes `readBridgeMeta` set the reader-only `repoListsUnreadable` + // on what it returns, so the assertion below can actually catch a + // read-modify-write writer round-tripping it back to disk. Seeded with a + // valid list the check passes whether or not the strip exists. + fs.writeFileSync( + path.join(groupDir, 'meta.json'), + JSON.stringify({ + version: 1, + generatedAt: '2026-01-01T00:00:00.000Z', + missingRepos: [], + unreadableRepos: 'not-a-list', + }), + ); + expect((await readBridgeMeta(groupDir)).repoListsUnreadable).toBe(true); + expect((await readBridgeMeta(groupDir)).provenanceUnknown).toBeUndefined(); + + writeBridgeFailure = new Error('ENOSPC: no space left on device'); + await runSync(); + + expect((await readBridgeMeta(groupDir)).provenanceUnknown).toBe(true); + + // ...and the withdrawal must not persist the reader-only fields. + // `readBridgeMeta` sets both on what it returns, so a read-modify-write + // writer round-trips them unless the write boundary strips them. + // `pairedWithDatabase` is the poisonous one: persisted, it would tell every + // later reader the pair was verified when nothing verified it. + const raw = JSON.parse(fs.readFileSync(path.join(groupDir, 'meta.json'), 'utf8')) as Record< + string, + unknown + >; + expect(raw).not.toHaveProperty('pairedWithDatabase'); + expect(raw).not.toHaveProperty('repoListsUnreadable'); + }); + + // control: a sync whose bridge write SUCCEEDS must not withdraw provenance — + // otherwise every healthy sync would report its own answers as a floor. + it('control: a successful bridge write leaves provenance intact', async () => { + await runSync(); + + expect((await readBridgeMeta(groupDir)).provenanceUnknown).toBeFalsy(); + }); + it('names the registry as intact and does not promise a truncation this branch never reports', async () => { writeBridgeFailure = new Error('ENOSPC: no space left on device'); const cap = _captureLogger(); diff --git a/gitnexus/test/unit/group/sync-windowed-resolution.test.ts b/gitnexus/test/unit/group/sync-windowed-resolution.test.ts index 4c44ef778..aaf361b87 100644 --- a/gitnexus/test/unit/group/sync-windowed-resolution.test.ts +++ b/gitnexus/test/unit/group/sync-windowed-resolution.test.ts @@ -213,12 +213,10 @@ describe('syncGroup windowed resolution bounds pool residency (real pool, #2189) grpc: false, thrift: false, topics: false, - shared_libs: false, - embedding_fallback: false, includes: false, workspace_deps: false, }, - matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, + matching: {}, }; await syncGroup(config, { diff --git a/gitnexus/test/unit/group/sync.test.ts b/gitnexus/test/unit/group/sync.test.ts index 51140d775..68fe13c50 100644 --- a/gitnexus/test/unit/group/sync.test.ts +++ b/gitnexus/test/unit/group/sync.test.ts @@ -26,12 +26,10 @@ describe('syncGroup', () => { grpc: false, thrift: false, topics: false, - shared_libs: false, - embedding_fallback: false, includes: false, workspace_deps: false, }, - matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, + matching: {}, }); it('returns SyncResult with contracts and cross-links', async () => { @@ -223,12 +221,10 @@ describe('syncGroup', () => { grpc: false, thrift: false, topics: false, - shared_libs: false, - embedding_fallback: false, includes: false, workspace_deps: false, }, - matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, + matching: {}, }; const result = await syncGroup(config, { @@ -678,12 +674,10 @@ service OrderService { grpc: false, thrift: false, topics: false, - shared_libs: false, - embedding_fallback: false, includes: false, workspace_deps: false, }, - matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, + matching: {}, }; const cap = _captureLogger(); @@ -749,12 +743,10 @@ service OrderService { grpc: false, thrift: false, topics: false, - shared_libs: false, - embedding_fallback: false, includes: false, workspace_deps: workspaceDeps, }, - matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, + matching: {}, }; } @@ -907,12 +899,10 @@ service OrderService { grpc: false, thrift: false, topics: false, - shared_libs: false, - embedding_fallback: false, includes: false, workspace_deps: true, }, - matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, + matching: {}, }; const result = await syncGroup(config, { @@ -999,12 +989,10 @@ service OrderService { grpc: false, thrift: false, topics: false, - shared_libs: false, - embedding_fallback: false, includes: false, workspace_deps: false, }, - matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, + matching: {}, }; const poolAdapter = await import('../../../src/core/lbug/pool-adapter.js'); @@ -1084,12 +1072,10 @@ service OrderService { grpc: false, thrift: false, topics: false, - shared_libs: false, - embedding_fallback: false, includes: false, workspace_deps: false, }, - matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, + matching: {}, }; const result = await syncGroup(config, { @@ -1129,12 +1115,10 @@ describe('syncGroup windowed manifest resolution (issue #2189 / PR #2191 review) grpc: false, thrift: false, topics: false, - shared_libs: false, - embedding_fallback: false, includes: false, workspace_deps: false, }, - matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, + matching: {}, }; }; diff --git a/gitnexus/test/unit/group/types.test.ts b/gitnexus/test/unit/group/types.test.ts index e1ffb77d7..cfa9abba2 100644 --- a/gitnexus/test/unit/group/types.test.ts +++ b/gitnexus/test/unit/group/types.test.ts @@ -23,12 +23,10 @@ describe('Group types', () => { grpc: true, thrift: true, topics: true, - shared_libs: true, - embedding_fallback: true, includes: true, workspace_deps: true, }, - matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, + matching: {}, }; expect(config.version).toBe(1); expect(config.name).toBe('company'); @@ -93,12 +91,10 @@ describe('Group types', () => { grpc: true, thrift: true, topics: true, - shared_libs: true, - embedding_fallback: true, includes: true, workspace_deps: true, }, - matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, + matching: {}, }; expect(config.detect.thrift).toBe(true); }); diff --git a/gitnexus/test/unit/repo-manager-registry-strict-read.test.ts b/gitnexus/test/unit/repo-manager-registry-strict-read.test.ts index d992b0083..76d79e295 100644 --- a/gitnexus/test/unit/repo-manager-registry-strict-read.test.ts +++ b/gitnexus/test/unit/repo-manager-registry-strict-read.test.ts @@ -68,12 +68,10 @@ describe('readRegistryStrict', () => { grpc: false, thrift: false, topics: false, - shared_libs: false, - embedding_fallback: false, includes: false, workspace_deps: false, }, - matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 }, + matching: {}, }); beforeEach(async () => { From 6088d2e309de134688cb465fc76988ce801e06c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Thu, 27 Aug 2026 23:21:40 +0100 Subject: [PATCH 16/17] chore: release v1.6.10 (#3064) * chore: release v1.6.10 * fix(eval): derive the pinned runtime version from package.json The containment suite mounts a GitNexus runtime built from this checkout and asserts its version equals PINNED_GITNEXUS_VERSION, a constant hardcoded to "1.6.9" when the harness landed in #2566. The first release after that lands 1.6.10 in gitnexus/package.json, the built runtime reports 1.6.10, and `eval / containment (ubuntu)` fails on drift the release itself created. Read the version from gitnexus/package.json instead. The check keeps its real job -- proving the mounted runtime came from this checkout rather than a published package -- without a copy that only ever drifts on release day. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Gergo Magyar Co-authored-by: Claude Opus 5 (1M context) --- .agents/plugins/marketplace.json | 2 +- .claude-plugin/marketplace.json | 2 +- eval/workflow_bench/runtime_mounts.py | 4 +- .../.claude-plugin/plugin.json | 2 +- .../.codex-plugin/plugin.json | 2 +- .../skills/gitnexus-cli/mcp.json | 2 +- .../skills/gitnexus-debugging/mcp.json | 2 +- .../skills/gitnexus-exploring/mcp.json | 2 +- .../skills/gitnexus-guide/mcp.json | 2 +- .../skills/gitnexus-impact-analysis/mcp.json | 2 +- .../skills/gitnexus-lfg/mcp.json | 2 +- .../skills/gitnexus-plan/mcp.json | 2 +- .../skills/gitnexus-refactoring/mcp.json | 2 +- .../skills/gitnexus-review/mcp.json | 2 +- .../skills/gitnexus-work/mcp.json | 2 +- gitnexus/CHANGELOG.md | 91 +++++++++++++++++++ gitnexus/package-lock.json | 4 +- gitnexus/package.json | 2 +- 18 files changed, 111 insertions(+), 18 deletions(-) diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 6494fac53..77a48e8ce 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -6,7 +6,7 @@ "plugins": [ { "name": "gitnexus", - "version": "1.6.9", + "version": "1.6.10", "source": { "source": "local", "path": "./gitnexus-claude-plugin" diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 576586d48..717a4c132 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,7 +11,7 @@ "plugins": [ { "name": "gitnexus", - "version": "1.6.9", + "version": "1.6.10", "source": "./gitnexus-claude-plugin", "description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase." } diff --git a/eval/workflow_bench/runtime_mounts.py b/eval/workflow_bench/runtime_mounts.py index 3f2e6fcf2..c532ac1b8 100644 --- a/eval/workflow_bench/runtime_mounts.py +++ b/eval/workflow_bench/runtime_mounts.py @@ -28,8 +28,10 @@ from .proposer_sandbox import ( SandboxError, ) -PINNED_GITNEXUS_VERSION = "1.6.9" HARNESS_ROOT = Path(__file__).resolve().parents[2] +# The mounted runtime is built from this checkout, so the pin tracks the harness' +# own package version. A hardcoded copy only drifts on release day (#3064). +PINNED_GITNEXUS_VERSION = json.loads((HARNESS_ROOT / "gitnexus" / "package.json").read_text())["version"] CE_ARMS = frozenset({"ce_workflow", "ce_workflow_direct", "ce_review"}) SANDBOX_CE_PLUGIN = "/opt/compound-engineering-plugin" diff --git a/gitnexus-claude-plugin/.claude-plugin/plugin.json b/gitnexus-claude-plugin/.claude-plugin/plugin.json index ace058dad..9e9372dea 100644 --- a/gitnexus-claude-plugin/.claude-plugin/plugin.json +++ b/gitnexus-claude-plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "gitnexus", "description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase.", - "version": "1.6.9", + "version": "1.6.10", "author": { "name": "GitNexus" }, diff --git a/gitnexus-claude-plugin/.codex-plugin/plugin.json b/gitnexus-claude-plugin/.codex-plugin/plugin.json index c9a03db4d..67ef62af2 100644 --- a/gitnexus-claude-plugin/.codex-plugin/plugin.json +++ b/gitnexus-claude-plugin/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "gitnexus", "description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase.", - "version": "1.6.9", + "version": "1.6.10", "skills": "./skills", "mcpServers": "./.mcp.json", "hooks": "./hooks/hooks.json", diff --git a/gitnexus-claude-plugin/skills/gitnexus-cli/mcp.json b/gitnexus-claude-plugin/skills/gitnexus-cli/mcp.json index 4fe6590dd..1af6255fb 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-cli/mcp.json +++ b/gitnexus-claude-plugin/skills/gitnexus-cli/mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "gitnexus": { "command": "npx", - "args": ["-y", "gitnexus@1.6.9", "mcp"] + "args": ["-y", "gitnexus@1.6.10", "mcp"] } } } diff --git a/gitnexus-claude-plugin/skills/gitnexus-debugging/mcp.json b/gitnexus-claude-plugin/skills/gitnexus-debugging/mcp.json index 4fe6590dd..1af6255fb 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-debugging/mcp.json +++ b/gitnexus-claude-plugin/skills/gitnexus-debugging/mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "gitnexus": { "command": "npx", - "args": ["-y", "gitnexus@1.6.9", "mcp"] + "args": ["-y", "gitnexus@1.6.10", "mcp"] } } } diff --git a/gitnexus-claude-plugin/skills/gitnexus-exploring/mcp.json b/gitnexus-claude-plugin/skills/gitnexus-exploring/mcp.json index 4fe6590dd..1af6255fb 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-exploring/mcp.json +++ b/gitnexus-claude-plugin/skills/gitnexus-exploring/mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "gitnexus": { "command": "npx", - "args": ["-y", "gitnexus@1.6.9", "mcp"] + "args": ["-y", "gitnexus@1.6.10", "mcp"] } } } diff --git a/gitnexus-claude-plugin/skills/gitnexus-guide/mcp.json b/gitnexus-claude-plugin/skills/gitnexus-guide/mcp.json index 4fe6590dd..1af6255fb 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-guide/mcp.json +++ b/gitnexus-claude-plugin/skills/gitnexus-guide/mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "gitnexus": { "command": "npx", - "args": ["-y", "gitnexus@1.6.9", "mcp"] + "args": ["-y", "gitnexus@1.6.10", "mcp"] } } } diff --git a/gitnexus-claude-plugin/skills/gitnexus-impact-analysis/mcp.json b/gitnexus-claude-plugin/skills/gitnexus-impact-analysis/mcp.json index 4fe6590dd..1af6255fb 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-impact-analysis/mcp.json +++ b/gitnexus-claude-plugin/skills/gitnexus-impact-analysis/mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "gitnexus": { "command": "npx", - "args": ["-y", "gitnexus@1.6.9", "mcp"] + "args": ["-y", "gitnexus@1.6.10", "mcp"] } } } diff --git a/gitnexus-claude-plugin/skills/gitnexus-lfg/mcp.json b/gitnexus-claude-plugin/skills/gitnexus-lfg/mcp.json index 4fe6590dd..1af6255fb 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-lfg/mcp.json +++ b/gitnexus-claude-plugin/skills/gitnexus-lfg/mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "gitnexus": { "command": "npx", - "args": ["-y", "gitnexus@1.6.9", "mcp"] + "args": ["-y", "gitnexus@1.6.10", "mcp"] } } } diff --git a/gitnexus-claude-plugin/skills/gitnexus-plan/mcp.json b/gitnexus-claude-plugin/skills/gitnexus-plan/mcp.json index 4fe6590dd..1af6255fb 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-plan/mcp.json +++ b/gitnexus-claude-plugin/skills/gitnexus-plan/mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "gitnexus": { "command": "npx", - "args": ["-y", "gitnexus@1.6.9", "mcp"] + "args": ["-y", "gitnexus@1.6.10", "mcp"] } } } diff --git a/gitnexus-claude-plugin/skills/gitnexus-refactoring/mcp.json b/gitnexus-claude-plugin/skills/gitnexus-refactoring/mcp.json index 4fe6590dd..1af6255fb 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-refactoring/mcp.json +++ b/gitnexus-claude-plugin/skills/gitnexus-refactoring/mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "gitnexus": { "command": "npx", - "args": ["-y", "gitnexus@1.6.9", "mcp"] + "args": ["-y", "gitnexus@1.6.10", "mcp"] } } } diff --git a/gitnexus-claude-plugin/skills/gitnexus-review/mcp.json b/gitnexus-claude-plugin/skills/gitnexus-review/mcp.json index 4fe6590dd..1af6255fb 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-review/mcp.json +++ b/gitnexus-claude-plugin/skills/gitnexus-review/mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "gitnexus": { "command": "npx", - "args": ["-y", "gitnexus@1.6.9", "mcp"] + "args": ["-y", "gitnexus@1.6.10", "mcp"] } } } diff --git a/gitnexus-claude-plugin/skills/gitnexus-work/mcp.json b/gitnexus-claude-plugin/skills/gitnexus-work/mcp.json index 4fe6590dd..1af6255fb 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-work/mcp.json +++ b/gitnexus-claude-plugin/skills/gitnexus-work/mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "gitnexus": { "command": "npx", - "args": ["-y", "gitnexus@1.6.9", "mcp"] + "args": ["-y", "gitnexus@1.6.10", "mcp"] } } } diff --git a/gitnexus/CHANGELOG.md b/gitnexus/CHANGELOG.md index def77dce8..6b4520d63 100644 --- a/gitnexus/CHANGELOG.md +++ b/gitnexus/CHANGELOG.md @@ -4,6 +4,97 @@ All notable changes to GitNexus will be documented in this file. ## [Unreleased] +## [1.6.10] - 2026-08-27 + +### Added + +- **Spring framework modeling expanded end to end** — AOP transactions, caching and security (#2783), `@Bean` factories and `@Resource` injection (#2740), profiles/conditions/auto-configuration (#2678), constructor and standard injection (#2632), bean candidate inventory (#2494), configuration-property consumers, and non-HTTP handler entry points (#2891) +- **Receiver chains typed from AST structure across all 14 languages**, with an explicit epistemic lower bound on what the graph can claim (#2708, #2744, #2747) +- **Java enum constant bodies modeled as first-class instances**, with JLS 13.1 anonymous-class naming (#2558) +- **More route surfaces indexed** — Java constant-based route paths such as `@PostMapping(ApiPathConstants.X)` (#2980) and JavaScript data route tables (#2972) +- **MCP server hardening** — repository allowlist, fail-closed read-only mode, deterministic output budgets, and normalized `impact`/`context` aliases +- **`bunx` lane so bun-only machines can run GitNexus** (#2765) +- **Codex support** — hooks, plugin marketplace and setup (#2328, #2369) — plus CodeBuddy and Qoder coding-agent integrations (#2368) +- **Skills mirrored to `.agents/skills/`** when an `.agents/` directory exists +- **One-click Render deploy** (#2804) +- **`serve` origin/proxy configuration is validated and port-scoped** (#2820) +- **Expanded TypeScript/JavaScript taint sink model** (#2490) +- **Wiki generation accepts explicit HTTP LLM hosts** (#2491) +- **Embedding request-body dimensions configurable** via `GITNEXUS_EMBEDDING_REQUEST_DIMS` (#2574) +- **Refreshed MiniMax model and endpoint configuration** (#2780) +- **`MAX_CALLABLE_VALUE_TARGETS` and `MAX_PROPERTY_DISPATCH_FANOUT` configurable via env** (#2725, #2726) +- **Opt-in `analyze --self-commit`** for AGENTS.md/CLAUDE.md churn (#2640) +- **Buffer pool sized to the graph before the database opens**, with an adaptive size hint +- **CI review agent runs as a coordinated reviewer swarm** on Sonnet 5 with structured, linked reviews (#2570, #2572), alongside the GitNexus Engineering Tool Kit skills (#2566) and an online skill-evolution loop (#2571) +- **Icebug community-engine prototype behind a gate** (#2376) + +### Fixed + +- **`group sync` stops claiming matching it never did** — the advertised BM25/embedding cascade was config, help text and MCP schema with no matcher behind it; the unread `matching.bm25_threshold`, `matching.embedding_threshold`, `detect.embedding_fallback` and `--skip-embeddings` surfaces are removed (#3020) +- **Emitted Next.js build output is ignored during ingestion**, and the inert `public/build` entry is deleted (#3018) +- **NestJS decorator routes are indexed** so `api_impact` and `route_map` stop reporting live endpoints as non-existent (#3017) +- **Import resolution gated by real module configuration** instead of path-suffix guessing — TypeScript config (#2953, #2956), Java and Kotlin declared packages (#2955, #2990), Go module paths (#2984), PHP Composer autoload maps (#2987), Python `__init__.py` re-exports (#2864) and unaliased dotted namespace imports (#2826, #2828), and JavaScript module extensions (#3034) +- **Interface dispatch is generic-instantiation aware** (#2912, #2939), fans out from Case 3b receivers (#2832, #2842) and from C# record interface calls (#2904), and resolves through generic-typed field receivers in every language (#2833, #2855) +- **Go method sets modeled exactly** so interface satisfaction is decidable (#2813, #2829), out-of-repo package qualifiers resolve, and an undecided interface check is no longer reported as a decided negative (#2873, #2921) +- **Go pointer-receiver calls resolve**, reporting the program boundary instead of hedging (#2766, #2782) +- **Java record support** — graph nodes for `record_declaration`, component accessors, enum and record interface heritage (#2564, #2916, #2935, #2936), plus `E.CONST.method()` enum-constant receiver dispatch (#2561) and JLS binary-name identities for local classes, enums, records and interfaces (#2562, #2653) +- **Rust module-qualified calls resolve against the module tree** (#2730, #2741), items are qualified by their enclosing `mod` chain (#2742, #2745), duplicate type names stay ambiguous in range binding (#2514, #2652), and `Box` names normalize +- **Closure bindings are call sources in every language**, and function-local values carry their own identity (#2693, #2695, #2699, #2718) +- **A named receiver's member never resolves lexically** (#2714), platform builtins stop resolving to unrelated same-file symbols (#2549), and inline constructor receivers are typed in every spelling (#2708, #2737) +- **Python calls resolve through constructor-injected fields** (#2628) and module-imported classes (#2770) +- **Package directories that repeat higher in the path resolve correctly** (#2881, #2929) +- **`check` stops reporting erased and deferred imports as initialization cycles** (#2934) +- **`detect_changes` no longer scales its query with the diff's hunk count** (#2915, #2930), and CR-only line-ending diffs are ignored (#2839) +- **`group` stops reporting what could not be measured as a measurement of zero** (#3012), resolves HTTP consumers through configured clients and constant route tables (#3008), and preserves manifest-only impact crossings (#2784) +- **`impact` and `context` are reproducible** — deterministic ordering on every capped query (#2787, #2796) — and Convex caller results are marked incomplete rather than empty (#3044) +- **Object handler identity is preserved** during ingestion (#3046), nested source directories are discovered (#3043), and parse-node insertion is canonicalized +- **Large-repo analyze OOM and the false worker-timeout cascade are fixed** (#2649, #2679) +- **Single-writer lock on the index write path** (#2658, #2677), atomic index swap with read-pool staleness invalidation (#2614), and reliable large incremental writeback commits (#2409, #2425) +- **Remote URLs are stripped of credentials before they are persisted** (#2914, #2928), and every registry write gets its own tmp path (#2888, #2920) +- **Schema version derived from a DDL fingerprint** instead of a hand-incremented constant (#2798, #2808), and the scope-resolution relation cross product is fully declared (#2792, #2793) +- **FTS reliability** — binary payloads stay out of the description column and an unbuildable index is confined to its own table (#2919), FTS-indexed DML is gated before the incremental writeback (#2841, #2854), analyze degrades instead of aborting on index-build failure (#2548), real LOAD errors surface and broken extension files self-heal (#2374, #2375), and Windows missing-dependency load failures are diagnosed (#2383) +- **`VECTOR` is loaded only when needed** (#3045) and before the incremental writeback touches embedding rows (#2623, #2624) +- **Buffer pool bounded instead of taking the native 80%-of-RAM default** (#2560), scaled by the OS page-size granule ratio (#2631, #2636), with a COPY-safe floor and actionable diagnostics for non-4K page sizes (#2424) +- **`Napi::Error` SIGABRT on analyze eliminated** — C++ type lookups are indexed and workers terminate only at JS-safe points (#2432, #2436) +- **Native-load failures fail closed**, including truncated-binary SIGBUS (#2441, #2651), and glibc-too-old loads are no longer misdiagnosed (#2672, #2689) +- **Index staleness reporting fixed** — no false-stale status after analyze, with inline staleness in `query`/`context`/`impact`/`cypher` (#2655, #2668, #2683) +- **Windows path handling** — the `\\?\` long-path prefix no longer breaks repo path matching (#2667, #2700), `parts` negation is honored (#2720), and missing-shadow errors let `serve` repo-switch recover (#2382, #2387) +- **Embeddings survive partial failures** — unparseable 200 responses are retried (#2790, #2795), batch inserts are retry-safe (#2453), HTTP generation is resumable, resume checkpoints bind to their provider, and proxy-blocked installs self-heal (#2370, #2372) +- **Custom HTTP embedding endpoint failures are reported as themselves**, not as Hugging Face download errors (#2385, #2386) +- **Exact symbol content with 0-based line storage and 1-based MCP display** (#2377, #2379, #2380) +- **`rename` reports every edit that apply writes** and reconciles its report on partial failure (#2605) +- **Global registry transactions serialized across processes** (#2716) +- **Swift indented conditional directives are preprocessed** so class bodies survive parsing (#2771), and Swift member-containment pairs are declared in the `CONTAINS` DDL (#2769) +- **JavaScript `exports.foo = function () {}` CommonJS exports are indexed** (#2723, #2729), and `const X = () => {}` is no longer double-indexed as a Function plus an edgeless Const twin (#2687, #2691) +- **JVM sibling injection is proximity-bounded** (#2732), and C#/Kotlin free calls are gated by instance ownership (#2563, #2654) +- **Dart extension type symbols are extracted** (#2539), and declarations recover after embedded NUL bytes (#2430) +- **CLI and hooks fail loudly on backend error payloads**, with an MCP query hint when the server owns the DB lock (#2396, #2397) +- **Committed agent guides stop churning**, with an `--index-only` nudge (#2907, #2927), and `gitnexus-plan` artifacts publish on macOS without an interpreter (#2905, #2922) +- **The 300-flows cap is removed for large repositories** (#2198) + +### Changed + +- **BREAKING: Node `^22.18.0 || >=24.11.0` is now the supported floor**; the `@types/uuid` stub is dropped +- **BREAKING: the non-functional `group` matching knobs are gone** — `matching.bm25_threshold`, `matching.embedding_threshold`, `detect.embedding_fallback` in `group.yaml`, the `gitnexus group sync --skip-embeddings` flag, and the MCP `group_sync` `skipEmbeddings` argument (#3020) +- **Structural relationships are held out of the JS heap by default** during analyze (#2680, #2685) +- **Global ignore support** — `core.excludesFile`, `.git/info/exclude`, and a user-level global ignore file are honored (#2606) +- **Plugin manifests sync on every version bump** (#2445), and planning output under `docs/plans` is no longer tracked + +### Performance + +- **Import resolution indexed instead of scanned** — every scanning resolver with a consolidated memo (#2911), a per-run workspace index for Go/C#/Dart/Ruby (#2898), and Kotlin import resolution (#2872) +- **MCP server startup drops the analyze-only language-provider closure** (#2802, #2806) +- **C++ qualified namespace members indexed once per pipeline run** (#2788, #2794) +- **Vendored Leiden O(communities × N) copy removed**, with Icebug wired to its real API (#2337, #2692) +- **`core.excludesFile` / `info/exclude` resolution memoized** (#2606) + +### Chore / Dependencies + +- **`@ladybugdb/core` bumped to ^0.18.3** for the rel-property IN-predicate fix (#2508, #2634) +- **Security overrides** — `sharp` >=0.35.0 for libvips vulnerabilities (#2993) and `adm-zip` >=0.6.0 for a memory-allocation vulnerability (#2992) +- **~130 dependency bumps** across the CLI, web app and GitHub Actions, including `@modelcontextprotocol/sdk`, LangChain, Vite, Vitest, TypeScript, React and the Docker/CodeQL action suite +- **CI hardening** — Windows shard watchdog widened with exit diagnostics (#2449), platform-sensitive matrix sharded to fix the Windows cross-platform timeout (#2394), and CI Report no longer dies silently when the tests job fails (#2728) + ## [1.6.9] - 2026-07-04 ### Added diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 6d4e37d12..08aa3a863 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitnexus", - "version": "1.6.9", + "version": "1.6.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitnexus", - "version": "1.6.9", + "version": "1.6.10", "hasInstallScript": true, "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { diff --git a/gitnexus/package.json b/gitnexus/package.json index 7b496063d..87d83a090 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -1,6 +1,6 @@ { "name": "gitnexus", - "version": "1.6.9", + "version": "1.6.10", "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.", "author": "Abhigyan Patwari", "license": "PolyForm-Noncommercial-1.0.0", From 880f94d147893c14ba3dc88a752424a5f7e32c0e Mon Sep 17 00:00:00 2001 From: glier Date: Fri, 28 Aug 2026 16:30:09 +0300 Subject: [PATCH 17/17] feat(group): resolve Kotlin constant-based route paths (@PostMapping(ApiPaths.X)) (#3059) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ingestion): resolve Kotlin constant-based route paths `route-extractors/constant-resolver.ts` is the language-agnostic core for folding constant references into literal route paths. Bindings existed for Java, JavaScript/TypeScript and Python, but not Kotlin — so `@PostMapping(ApiPaths.ORDERS)` resolved on Java sources and silently produced no route on the identical Kotlin code. Add `route-extractors/kotlin-const-resolver.ts` as the fourth binding, mirroring `java-const-resolver.ts` (Kotlin shares the JVM package/import model) in structure, naming and skip-floor discipline: * `resolveKotlinImport` — import specifier -> file path. Tier 1 matches the `/.kt` convention; tier 2 falls back to the unique constant-defining file in the package directory, because Kotlin does not require a file to be named after the declaration it holds. Each tier is unique-or-nothing. * `extractKotlinModuleConstants` — parse tree -> `ModuleConstants`. * `parseKotlinConstOperands` / `foldKotlinOperands` — pre-bound wrappers so the calling side stays language-neutral, matching how `spring.ts` consumes `parseJavaConstOperands`. Kotlin-specific forms handled explicitly rather than translated from Java: top-level `const val`, `object` members, `companion object` members (keyed under the enclosing class, since `Companion` never appears in a reference), import aliases (`import a.b.C as D`), and the absence of a `String` type gate (Kotlin infers property types, so the initializer decides). String templates and multi-line raw strings are refused rather than folded with the interpolation dropped, which would publish a path the application does not serve. `var`, custom getters and delegates are not constants and are skipped. Wiring: `group/extractors/http-patterns/kotlin.ts` gains a `prepareRepo` pre-pass that builds the repo-wide constant map once per `extract()` run, and `scan` now folds constant-valued `@(Get|Post|Put|Delete|Patch)Mapping` arguments against it — the same shape the Java plugin already implements. A constant-valued class-level `@RequestMapping` prefix suppresses the routes under that class, as in `java.ts`: emitting them unprefixed would turn a missing fact into a wrong one. An ambiguous import returns null, never a guess — a duplicate fully-qualified name across modules, a package with two constant files, and a wildcard import all floor to skip. A wrong resolution is a false edge in the graph; a missing one is only a missing fact. The ingestion provider (`languages/kotlin.ts`) is deliberately left alone: the ingestion fold runs only over `decoratorRoutes`, and Kotlin declares no `extractDecoratorRoutes` because `spring.ts` is bound to tree-sitter-java. Declaring the constant hooks there today would harvest a map nothing consumes. The reasoning is recorded in the new module's header. Tests cover each reference form (qualified, fully-qualified, single-name import, concatenation incl. 3+ operand chains) plus every ambiguity case, at both the resolver layer and through `KOTLIN_HTTP_PLUGIN.prepareRepo` + `scan`. * test(group): cover the Kotlin route-fold guards left unpinned Follow-up to the Kotlin constant-route binding, closing the test gaps a review found. No behavior change: the only source edit is a comment. * Class-prefix suppression is now asserted for the NAMED spelling (`@RequestMapping(value = ApiPaths.BASE)`) as well as the positional one. The two take different branches of `kotlinRouteArgumentExpression`, and only the method-path side of the named branch was covered; a regression there would let a constant-prefixed class escape suppression and publish every method under it at an unprefixed path. * `MAX_FOLD_LENGTH` and both recursion caps are pinned from both sides. Output doubles per level while depth only increments, so 13 doublings of a one-character leaf land exactly on the limit and 14 overrun it; a 30-link reference chain resolves where a 40-link one hits the cross-file cap; an 80-term `+` chain hits the operand-parse cap where a 60-term one folds. A 30-level shared-descendant DAG folding inside a 5 s budget pins the success memo that keeps it out of O(2^depth). These are the guards that keep a pathological constant graph from building a gigabyte-scale string or recursing without bound during a group sync; they were inherited from the audited Java binding but nothing held them in place. * OpenFeign consumers are covered on both paths: a constant method path folds, and a constant interface-level `@RequestMapping` prefix suppresses the consumer. The latter is deliberate — Spring Cloud prepends a type-level `@RequestMapping` to every method of the client, so an unfoldable prefix makes the remote URL unknowable whether or not `@FeignClient(path)` is present, and a dropped edge beats a wrong one. The suppression reaches an interface because tree-sitter-kotlin models `interface` as a `class_declaration`; `java.ts` misses this case only because its `findEnclosingClass` skips `interface_declaration`, and aligning Java changes Java's behavior, so it is left to its own change. Documented at the guard so the divergence is not read as an oversight. Note for reviewers of the parent change: re-indexing a Kotlin Spring service will REMOVE routes that were previously emitted, unprefixed, from classes whose `@RequestMapping` prefix is a constant. Those paths were never served by the application; the drop is the fix, not a regression. * fix(group): suppress Kotlin routes only when the class prefix resolves to no literal Class-prefix suppression decided "is this prefix unresolvable?" from a three-element allow-list of node types (`simple_identifier`, `navigation_expression`, `additive_expression`). An allow-list is safe for FOLDING, where a forgotten shape yields no route, but it is the wrong shape for SUPPRESSION, where a forgotten shape means "emit unprefixed" — a route the application does not serve. `java.ts` gates on the ABSENCE of a literal (`if (!valueNode)`) for exactly this reason. The predicate is now inverted: a class is marked unless its `path`/`value` argument is provably literal, recursing into `[…]` and `arrayOf(…)` elements and refusing an interpolated `string_literal`. Measured against the previous behavior, with `@PostMapping(ApiPaths.ORDERS)` under each class prefix, on an app serving `/api/v1/orders`: * `[ApiPaths.BASE]` `POST /orders` -> dropped * `arrayOf(ApiPaths.BASE)` `POST /orders` -> dropped * `value = [ApiPaths.BASE]` `POST /orders` -> dropped * `buildPath()` `POST /orders` -> dropped * `if (USE_V2) "/api/v2" else …` `POST /orders` -> dropped * `"${ApiPaths.BASE}"` `POST /${ApiPaths.BASE}/orders` -> dropped The last one published raw source text as a served path; refusing an interpolated literal also fixes it for LITERAL method routes, which emitted `/${ApiPaths.BASE}/list` before this branch existed. Two regressions this suppression had introduced are repaired, both by consulting the literal-prefix map that the pass above already built and declining to mark a class that has an entry in it: * `@RequestMapping("/lit", ApiPaths.BASE)` + `@GetMapping("/list")` lost `GET /lit/list` entirely. Kotlin's vararg spelling leaves a resolvable arm behind, and suppression exists to avoid wrong routes, not to discard right ones. * `@FeignClient(path = "/api")` + `@RequestMapping(ApiPaths.BASE)` lost its consumer, though `path` outranks `@RequestMapping` when the URL is assembled and made the prefix perfectly knowable. Two Feign emission paths never consulted the unfoldable set at all: * `@FeignClient(path = CONST)` was invisible to the analysis, which matches `@RequestMapping` only, so the client fell through to the no-prefix fallback and published `GET /orders` for a call the service makes to `/api/v1/orders`. Collected as its own set, kept separate because `path` outranks `@RequestMapping` in both directions. * The `@RequestLine` loop resolves through the identical "path wins" fallback chain but had no guard, so one interface could suppress its `@(Get|…)Mapping` route and publish its `@RequestLine` route under the very same unresolvable prefix. Both lanes now judge alike. Note for reviewers: the `@RequestLine` guard is not a regression fix — that lane emitted a wrong unprefixed consumer before this branch too. It moves a wrong route to no route, on both sides of the change. Co-Authored-By: Claude Opus 5 (1M context) * fix(group): fold Kotlin route constants on Windows and when they fold to "" Two defects in the Kotlin constant-path fold, plus the documentation corrections review asked for. Windows keys. `resolveKotlinImport` turns an import specifier into `com/example/ApiPaths.kt` and asks whether a repository key ends with it. The orchestrator's key list comes from glob v13, which has no `posix: true` and joins with the platform separator, so on Windows every key arrives backslashed and that test can never pass: the pre-pass still ran, the repo context was still built, and every cross-file fold returned null — the headline feature silently absent on one platform. Every unit fixture spelled its keys POSIX, so CI could not see it. Normalized at the one boundary that produces the keys — `prepareRepo`'s map keys and `scan`'s `fileRel` — which is the fix `node.ts` and `python.ts` already apply for the same reason. `readFile` still receives the raw path. Normalizing inside the resolver cannot work: it returns the key it matched, so a normalized return value would miss in a map nobody normalized. Empty fold. `foldKotlinOperands` collapsed `''` into `null`, conflating "folded to the empty string" with "unresolvable". `const val ROOT = ""` is Spring's spelling for the class prefix itself, so under `@RequestMapping("/api")` the literal `@PostMapping("")` published `POST /api/` while `@GetMapping(ApiPaths.ROOT)` published nothing. Return the fold unfiltered: callers already guard on `=== null`, `resolveKotlinConstant` already returned `''` for the same constant, and this matches `foldJavaOperands`. Docs. The module header claimed the fold, the cycle guard and the depth cap all live in the agnostic core. They do not — roughly 200 lines are a local fork of the Java binding's already forked state machine, because the core keys its maps by simple name while a Kotlin operand can be qualified at any position. Say that, with the reason and the follow-up. The stated `isKotlinConstantFile` invariant ("never rejects a file the extractor accepts") is false: the extractor harvests a top-level non-`const` `val` that fails both gate arms. The cost is not nil, either — measured, such a constant in its own file loses every cross-file route, while the same declaration beside the route still folds through `scan`'s on-demand re-extract. Both recorded on the gate. The depth caps are now `MAX_OPERAND_PARSE_DEPTH` (64) and `MAX_FOLD_DEPTH` (32); the core's own `MAX_RESOLVE_DEPTH` is 8 and module-private, so it cannot simply be reused. Measured with a differential probe over 41 Kotlin fixtures against the PR base, in both key styles. Exactly one POSIX row moves — the empty fold — and every other row, controls included, is byte-identical to before. POSIX and Windows keys now yield identical detections on every fixture, on both sides. Deliberately not done: the `isKotlinConstantFile` gap is documented, not closed, because closing it means parsing every file that contains any `val`. `java-const-resolver.ts` still spells 64 and 32 inline. The PR body's rollout note still says re-indexing activates the change — `HttpRouteExtractor` runs during `group sync` (`sync.ts:297`), so that is a PR-body fix, not a code one. Co-Authored-By: Claude Opus 5 (1M context) * fix(group): key Kotlin constants by visibility and resolve imports on the declared package Two ways the Kotlin route fold could publish a path the application does not serve. Both were inherited from the merged Java binding, which documents each as accepted; the notes were wrong, not merely conservative, and both are left open as a Java follow-up rather than changed here. Simple-name flattening. Every `object`/companion member was recorded under BOTH its qualified name `Owner.NAME` and its bare `NAME` in one file-level namespace, so an initializer naming a sibling resolved through whichever object was walked LAST: object A { const val BASE = "/right"; const val ROUTE = BASE + "/m" } object B { const val BASE = "/wrong" } @GetMapping(A.ROUTE) // Kotlin serves /right/m; this emitted /wrong/m Swapping the two objects flipped the answer back — the same source, merely reordered, changed the emitted route. The bare key is also a binding Kotlin does not have: `BASE` alone never names `A.BASE` from outside `object A`'s body, and because the fold consults literals before imports, that fabricated key outranked a genuine `import com.example.api.Paths.ORDERS` and published the local object's value instead of the imported one. Keys now follow Kotlin's own visibility. A member of a named `object` gets only `Owner.NAME`; the simple name is recorded for a top-level `val` and for a companion member, which really is in scope unqualified throughout its enclosing class. Initializers resolve against their scope chain, innermost first, so `BASE` inside `object A` means `A.BASE` — collecting every declaration before recording any is what makes that independent of declaration order. An unfoldable object member no longer drops a same-named import either, since it shadows nothing. The known limit is now stated rather than argued away: a companion's bare key is still file-wide, so two companions in one file whose members collide still resolve last-wins for an unqualified reference. Kotlin scopes that to the enclosing class and this map cannot express it — the fold is entered with a file key and a name, and nothing says which class body the annotation sat in. Initializers are unaffected; only a bare annotation reference can land wrong. Import binding never read the `package` header. Both tiers picked candidates purely from the path, so a file whose PATH ended with the imported FQN beat the real declaration — and when the decoy declared the same constant the fold did not skip, it invented a value. Measured: `object ApiPaths { const val ORDERS = "/right" }` in `src/generated/Constants.kt` (`package com.example.api`) plus a decoy at `src/x/com/example/api/ApiPaths.kt` (`package x.com.example.api`) emitted `GET /wrong`. This falsifies the old docstring's safety argument, which only covered a wrong file that LACKS the name. Two further triggers: a root-level `package data` was impersonated by `com/example/data` on a path-suffix test, while the real root-level file was invisible to the package-directory tier at all; and a unique constant file under a test source tree folded into a production route. The declared `package` is now recorded per file and matched exactly. Candidates that declare a different package are rejected rather than guessed at, an entry with no recorded package is rejected too, and two files declaring the same fully-qualified name resolve to nothing — a duplicated FQN names no single declaration, so the test-source copy of a production constant is a skip, not a guess about build configuration this layer cannot see. The file-name convention survives only as a tie-break among candidates that already declare the right package. `packageName` rides on a Kotlin-local `KotlinModuleConstants` rather than widening the agnostic `ModuleConstants`, which Java, JS and Python share and none of them needs it. Measured with a differential probe over all 41 Kotlin fixtures, in both key styles. Seven rows move, all of them from a wrong route: * sibling shadow, A first /wrong/m -> /right/m * bare key beats import /wrong -> /right * path-suffix decoy /wrong -> /right * root-package suffix match /wrong -> /right * root-package suffix only /wrong -> (skip; not in the repo) * test copy into production /test-only -> (skip; FQN declared twice) * wrong file lacks the name (skip) -> /right The last row is the one control that changes, and it changes from emitting nothing to emitting the route Kotlin serves: its decoy declares a different package, so the unconventionally named real file is now the sole candidate. Every other row, all six remaining controls included, is byte-identical to before, and POSIX and Windows keys still agree on every fixture. Deliberately not done: `resolveKotlinImport` does not PREFER the candidate that declares the sought name when several share the package — it only rejects when two do. Preferring it would resolve more imports correctly (a package holding `ApiPaths.kt` that declares something else and `Constants.kt` that declares `ApiPaths`), but it is a separate skip-to-route improvement that would rewrite an assertion this suite already pins, and the review round did not ask for it. Co-Authored-By: Claude Opus 5 (1M context) * fix(group): scope Kotlin companion constants to their class, and stop an empty path array suppressing routes Three ways the Kotlin route fold still published the wrong answer, all measured on fixtures rather than reasoned about, plus one comment that was false. Empty path array. `hasResolvableLiteralPathElement` answered "does any element resolve to a literal?", and `[].some(...)` is `false`, so `@RequestMapping(arrayOf())` read as an UNRESOLVABLE prefix and suppressed every route under the class — including a plain `@GetMapping("/lit")` that no constant fold ever touched. Spring treats an empty array as NO prefix, so `/lit` is genuinely served. The same arithmetic hit `@FeignClient(path = arrayOf())`, dropping a consumer. Measured against the pre-suppression branch point: * `@RequestMapping(arrayOf())` + `@GetMapping("/lit")` nothing -> GET /lit * `@FeignClient(path = arrayOf())` nothing -> consumer GET /orders The predicate is now a three-valued `classifyPathArgument`: `'literal'`, `'none'` (an empty array — no prefix), `'unresolvable'`. Only the last may suppress, because "no prefix" is not "an unresolvable prefix" and only one of them makes the served path unknowable. `@RequestMapping([])` is the same idea spelled differently, but tree-sitter-kotlin does not parse the class carrying it as a `class_declaration` at all, so no class-prefix pattern matches it and no arm here can be reached — recorded rather than guarded against. Companion scope. A companion member's simple name was recorded into the SAME file-level namespace as top-level constants, and companions are recorded last, so it won every unqualified reference in the file — including from a class that is not its own. Kotlin binds it unqualified inside its enclosing class body and nowhere else: * top-level `/top` vs an unrelated `Holder`'s companion `/companion`, referenced from a third class `/companion` -> `/top` * two companions colliding on one name `/h2` -> `/h1` * `const val ROUTE = BASE + "/m"` at file level beside a companion `BASE` `/comp/m` -> `/top/m` * a single-name import losing to a same-named companion outside its class Only a TOP-LEVEL `val` now writes a bare key. The unqualified binding is reached from the reference site instead: `scan` collects the enclosing type chain of the annotation and `foldKotlinOperands` rewrites a bare operand to `.` when an enclosing type declares it — innermost first, before the file-level maps and before imports, which is Kotlin's own order. So the companion still wins inside its own class (the control that pinned this behavior keeps passing) and loses everywhere else. Nothing was skipped to get there: every one of the four cases now emits the route the application serves. An unfoldable companion member still drops a same-named import file-wide. The import map has no scopes, and over-deleting costs a route while under-deleting publishes the imported value at a reference the compiler binds to the unfoldable member. Backtick quoting. `` package com.example.`api` `` and `package com.example.api` are the same package to the compiler — the quotes are lexical syntax, not part of the name — but the grammar keeps them in the node text, `declaredPackage` joined them verbatim and `resolveKotlinImport` required an exact match, so the sole real candidate was rejected and `GET /right` was lost. Every identifier that becomes a map key or a lookup name is now read through `unquoteKotlinIdentifier`: package segments, import specifiers and aliases, declaration and member names, and references. Both directions matter — an import may quote a segment the declaration spells plainly, and the reverse — and a KEYWORD segment, which can only be spelled quoted, still folds. Comment correction. The previous commit's note claimed "Sibling INITIALIZERS are unaffected (they go through the scope chain above); only a bare reference from a route annotation can land on the wrong companion." That is false, and the `/comp/m` case above is the counterexample: a top-level initializer has an EMPTY scope chain, so `qualifyRef` leaves its operand bare and the file-wide companion key answered it. The source comment now describes what the code does; the claim also appears in the `ef402a4a` commit body, which is already published and is left as written. Not changed. `resolveKotlinImport` computes `declaring` — the unique in-package file that declares the sought name — and uses it only to REJECT when two files declare it, never to resolve. With two or more in-package candidates it falls through to the file-name convention and returns null, dropping a case Kotlin resolves unambiguously. Returning it would flip the pinned assertion "returns null when the package holds two constant files and no name matches" from skip to route (that fixture's `Paths.kt` does declare `object ApiPaths`, so `declaring` is not null there despite the test's title), so it is left open as a follow-up rather than traded against a skip-floor assertion. Fixture sweep: 82 cases in both POSIX and Windows key styles. Six move, all listed above; the other 76 are byte-identical on both key styles, including the companion-inside-its-own-class control, the qualified-reference cases, and the pre-existing interface-inheritance gap on a constant controller prefix, which is unchanged and remains a separate follow-up. Co-Authored-By: Claude Opus 5 (1M context) * fix(group): admit backtick-quoted constants, and overlay a same-file val that imports nothing Two gate defects, both reported by the review bot and both reproduced before being fixed. `isKotlinConstantFile` matched only `\w+` for a declaration's name, so a file whose constants are backtick-quoted — `const val ` + "`ORDERS`" + ` = "/orders"` — failed both arms and was never parsed into the repo constant map. The resolver supports backtick identifiers everywhere else: `unquoteKotlinIdentifier` strips the quoting at every point a name becomes a key or a lookup. So the gate was NARROWER than the extractor, which is the one direction its arms exist to exclude, and a cross-file reference to such a constant floored to skip. Measured: the route emitted nothing, and emits `GET /orders` now. The on-demand overlay in `scan` admitted the file's extraction only when it had imports. A file declaring a top-level non-`const` `val` is already excluded from the pre-pass map (no `const`, no `object`), so this branch is its only chance, and an import-only test discarded exactly the constants the route needed. The guard now matches the admission test the pre-pass itself applies. The bot stated this second one more broadly than it holds. Measured, any import at all masks it — a realistic Spring controller always has one — so the failure needs all three of: a top-level non-`const` `val`, no `object` in the file, and no imports. Narrow, but real, and the fix costs one predicate. Verified with the differential probe: both cases go from no detection to the correct route, and all 41 existing fixtures are byte-identical before and after. Co-Authored-By: Claude Opus 5 (1M context) * refactor(group): let the resolver own Kotlin enclosing-type qualification The route caller gated kotlinEnclosingTypeNames on its own copy of foldKotlinOperands' bare-ref predicate. That gate could not change the result — qualifyKotlinRefInEnclosingTypes returns a dotted name unchanged — so it only spread one rule across two modules that can drift apart. Also corrects a trimmed comment that claimed a collection_literal never reaches classifyPathArgument, which the non-empty branch there disproves. Co-authored-by: Cursor * fix(group): keep unfoldable Kotlin constants as skip, not a wrong route Record declared-but-unfoldable names so a companion or duplicate FQN cannot fall through to a foldable twin, and treat empty [] as no prefix on parsed RequestMapping arrays. Prefer the unique declaring file before package filename fallbacks so extra unfoldable files in the same package do not drop a real route. Co-authored-by: Cursor * fix(group): preserve full paths for nested Kotlin constants (#3059) Key nested objects and companions by their full enclosing type path so same-file, imported, and bare nested references resolve consistently. Co-authored-by: Cursor * fix(group): qualify a PARTIALLY qualified Kotlin reference, not just a bare one Both qualification points short-circuited on `name.includes('.')` — "already carries its owner". A dotted reference carries AN owner, not necessarily its own full one, and Kotlin resolves a partially qualified name against the enclosing scopes exactly as it resolves a bare one. Measured on the branch before this change: * `object Outer { object Inner { const val Q = "/orders" } @GetMapping(Inner.Q) … }` emitted NOTHING. The key is `Outer.Inner.Q`; left unchanged, `Inner.Q` matches nothing. * a top-level `object ApiPaths { ORDERS = "/orders" }` beside `class OrderController { object ApiPaths { ORDERS = "/inner" } }`, with `@GetMapping(ApiPaths.ORDERS)` inside that class, emitted `/orders`. The compiler binds the NESTED object, so the application serves `/inner`. That is a wrong route, not a missing one. * the same defect on the initializer side: `const val ROUTE = Inner.Q + "/m"` inside `object Outer` emitted nothing, where Kotlin gives `/orders/m`. Fixing only one side would leave the two halves disagreeing about what a dotted name means, which is the asymmetry the earlier defects in this file came from, so both move together: * `qualifyKotlinRefInEnclosingTypes` drops the early return. The scopes it walks are already qualified, so prefixing them onto whatever the reference spells is the whole rule. * `qualifyRef` splits at the last dot and prefixes the scope onto the OWNER, so the bare case is byte-for-byte what it was. The allocation gate in `foldKotlinOperands` loses its `!includes('.')` clause for the same reason. It was not merely a missed optimization: it decided the result per OPERAND LIST, so the same `Inner.Q` folded or not depending on whether a sibling operand happened to be bare. Verified with the differential probe: the three cases above go from wrong or missing to correct, and all 41 existing fixtures are byte-identical to face59ae in both POSIX and Windows key styles. Co-Authored-By: Claude Opus 5 (1M context) * fix(group): close Kotlin val gate and reuse import indexes (#3059) Recognize standalone top-level vals without parsing local declarations, and prepare exact-package/FQN constant indexes once per extraction so route folds avoid repeated repo scans while preserving ambiguity floors. Co-authored-by: Cursor --------- Co-authored-by: Gergő Magyar Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Gergo Magyar Co-authored-by: Cursor --- .../group/extractors/http-patterns/kotlin.ts | 550 ++++++- .../route-extractors/kotlin-const-resolver.ts | 1400 ++++++++++++++++ .../group/kotlin-const-route-fold.test.ts | 1265 ++++++++++++++ .../unit/kotlin-route-const-resolver.test.ts | 1448 +++++++++++++++++ 4 files changed, 4656 insertions(+), 7 deletions(-) create mode 100644 gitnexus/src/core/ingestion/route-extractors/kotlin-const-resolver.ts create mode 100644 gitnexus/test/unit/group/kotlin-const-route-fold.test.ts create mode 100644 gitnexus/test/unit/kotlin-route-const-resolver.test.ts diff --git a/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts b/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts index 10f195111..d9a7c1705 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/kotlin.ts @@ -18,6 +18,19 @@ import { joinPath, type SharedSpringType, } from '../../../ingestion/route-extractors/spring-shared.js'; +import { + buildKotlinConstantIndex, + extractKotlinModuleConstants, + foldKotlinOperands, + isKotlinConstantFile, + overlayKotlinConstantIndex, + parseKotlinConstOperands, + unfoldableDeclarationsOf, + unquoteKotlinIdentifier, + type KotlinConstantIndex, + type ModuleConstants, + type RepoConstants, +} from '../../../ingestion/route-extractors/kotlin-const-resolver.js'; import { REST_TEMPLATE_TO_HTTP, WEB_CLIENT_SHORT_TO_HTTP, @@ -42,6 +55,24 @@ import { * named annotation arguments (`@GetMapping(value = "/x")` and * `@GetMapping(path = "/x")`) are supported. * + * A method path that is a CONSTANT rather than a literal — + * `@GetMapping(ApiPaths.ORDERS)`, `@PostMapping(value = ApiPaths.BASE + "/create")` — + * is folded against a repo-wide Kotlin constant map built once per `extract()` + * run by `prepareRepo`, mirroring what the Java plugin does for the same shape + * in `java.ts`. An unresolvable fold skips the route (never a guessed path), and + * a class prefix that resolves to NO literal at all suppresses every method + * route under that class — the rule `java.ts` applies too, because emitting + * those routes unprefixed would publish paths the application does not serve. + * A prefix that resolves only PARTLY (Kotlin's vararg spelling + * `@RequestMapping("/lit", ApiPaths.BASE)`) still publishes its resolvable arm: + * suppression exists to avoid wrong routes, not to discard right ones. An EMPTY + * path array (`@RequestMapping(arrayOf())`) is not a prefix at all and + * suppresses nothing — see `classifyPathArgument`. On a + * `@FeignClient` the same rule is applied to whichever prefix GOVERNS, in the + * "path wins" order the URL is assembled in — `@FeignClient(path)` first, then + * the interface's `@RequestMapping` — and to both consumer lanes, `@(Get|...)Mapping` + * and `@RequestLine`. + * * **Consumers** — four call-site patterns common in Kotlin * Spring projects: * @@ -131,6 +162,180 @@ const arrayOfArg = (cap: string): string => `(call_expression (simple_identifier) @arrayOf (#eq? @arrayOf "arrayOf") (call_suffix (value_arguments (value_argument (string_literal) ${cap}))))`; +/** + * Expression node types a METHOD route path can be FOLDED from. A + * `string_literal` is deliberately absent: literal paths are already captured by + * the dedicated literal patterns, so admitting one here would emit the same + * route twice. + * + * This is an allow-list on purpose, and only safe because it gates FOLDING: a + * shape missing from it yields no route, which is the skip floor. The + * unfoldable-CLASS-PREFIX analysis must not be written this way — there a shape + * missing from the list means "emit unprefixed", a wrong route — so it inverts + * the test instead (see `classifyPathArgument`). + */ +const FOLDABLE_PATH_EXPRESSIONS: ReadonlySet = new Set([ + 'simple_identifier', + 'navigation_expression', + 'additive_expression', +]); + +/** + * Repo-relative path in the POSIX form the Kotlin constant map is keyed by. + * + * The orchestrator's file list comes from glob v13, which has no `posix: true` + * option and joins with the platform separator, so on Windows `prepareRepo` + * receives `src\main\kotlin\com\example\ApiPaths.kt` and `scan` receives the + * same for `fileRel`. `resolveKotlinImport` turns an import specifier into + * `com/example/ApiPaths.kt` and asks whether a key ENDS WITH it — a test no + * backslashed key can pass. Left unnormalized, every cross-file constant fold + * returns null on Windows and on Windows only: the pre-pass still runs, the + * context is still built, and the feature is simply, silently absent. The unit + * fixtures build POSIX keys by hand, so CI cannot see it. + * + * Normalizing at this boundary — write side (the map keys below) and read side + * (`fileRel`) — is the same fix `node.ts` (`normalizeRel`) and `python.ts` + * (`fileShortKey` / `fileLongKey`) already apply for the same reason, and it is + * the only coherent place: the resolver returns the key it matched, so + * normalizing inside it would hand back a value that misses in a map nobody + * normalized. `readFile` still receives the ORIGINAL `rel`, since the filesystem + * wants the platform's own spelling. + */ +function normalizeRel(rel: string): string { + return rel.replace(/\\/g, '/').replace(/^\.\//, ''); +} + +/** + * The path expression carried by one route-annotation argument, or null when the + * argument does not designate a path. + * + * tree-sitter-kotlin gives positional and named arguments the same + * `value_argument` node, distinguished only by a leading `simple_identifier` and + * an `=` token — so the key must be read here rather than constrained in the + * query. Non-route keys (`produces`, `consumes`, `headers`, …) return null, + * matching the `#match? @key "^(path|value)$"` guard the literal patterns use. + */ +function kotlinRouteArgumentExpression(arg: Parser.SyntaxNode): Parser.SyntaxNode | null { + const first = arg.namedChild(0); + if (!first) return null; + if (!arg.children.some((c) => c.type === '=')) return first; // positional + if (first.type !== 'simple_identifier') return null; + if (first.text !== 'path' && first.text !== 'value') return null; + return arg.namedChild(1); +} + +/** + * The `path = …` expression of one `@FeignClient` argument, or null. + * + * Deliberately narrower than {@link kotlinRouteArgumentExpression}: on a Feign + * client the positional argument and `value =` name a SERVICE, not a path, so + * only the explicit `path` key contributes a URL prefix. This mirrors the + * `#eq? @key "path"` guard the literal `@FeignClient` patterns use, and the + * `keyNode.text !== 'path'` guard `java.ts` applies to the same annotation. + */ +function kotlinFeignPathArgumentExpression(arg: Parser.SyntaxNode): Parser.SyntaxNode | null { + const first = arg.namedChild(0); + if (!first || first.type !== 'simple_identifier') return null; + if (!arg.children.some((c) => c.type === '=')) return null; + if (first.text !== 'path') return null; + return arg.namedChild(1); +} + +/** + * Is `node` a string literal whose value is fully known at parse time — that is, + * a literal carrying no interpolation? + * + * tree-sitter-kotlin models `"$base/x"` and `"${base}/x"` as a `string_literal` + * whose named children INTERLEAVE `string_content` runs with interpolation nodes + * — `interpolation_identifier_start`/`interpolated_identifier` for the `$name` + * form, `interpolation_expression_start`/`interpolated_expression`/ + * `interpolation_expression_end` for `${…}` — so the test has to be `every`, not + * `some`: `"pre${A.B}post"` carries `string_content` too. The route layer + * unquotes the RAW TEXT, so treating one as a literal publishes the source + * spelling — `/${ApiPaths.BASE}/orders` — as though the application served it. + * Escape sequences are NOT separate nodes in this grammar (`"/a\nb"` is one + * `string_content`), so this accepts exactly what it accepted before; a future + * grammar that split them would floor to "unknown" rather than to a de-escaped + * guess. Same test the constant resolver's `stringLiteralValue` applies, so a + * path is either literal on both sides or folded on neither. + */ +function isPlainStringLiteral(node: Parser.SyntaxNode): boolean { + if (node.type !== 'string_literal') return false; + return node.namedChildren.every((child) => child.type === 'string_content'); +} + +/** + * Element expressions of a Kotlin `arrayOf(...)` call, or null when `node` is + * not one. The JS mirror of the {@link arrayOfArg} query fragment, so the + * unfoldable-prefix analysis inspects exactly the elements the literal prefix + * patterns harvest. + */ +function kotlinArrayOfElements(node: Parser.SyntaxNode): Parser.SyntaxNode[] | null { + if (node.type !== 'call_expression') return null; + const callee = node.namedChild(0); + if (callee?.type !== 'simple_identifier' || callee.text !== 'arrayOf') return null; + const suffix = node.namedChildren.find((c) => c.type === 'call_suffix'); + const args = suffix?.namedChildren.find((c) => c.type === 'value_arguments'); + if (!args) return null; + return args.namedChildren + .filter((c) => c.type === 'value_argument') + .map((c) => c.namedChild(0)) + .filter((c): c is Parser.SyntaxNode => c !== null); +} + +/** + * What a route-annotation path argument says about the prefix it designates. + * Only `'unresolvable'` may suppress a route: + * + * - `'literal'` — at least one element is a plain literal, already harvested by + * the literal prefix patterns, so there is nothing to suppress. + * - `'none'` — no prefix. Empty `arrayOf()` or `[]` is Spring's "map at the root". + * Kept distinct from `'unresolvable'` because conflating them suppressed even + * plain literal routes below such a class, which no constant fold was ever + * involved in. tree-sitter-kotlin (fwcd) represents empty `[]` with a + * zero-width recovery child; filtering it is required for route interfaces, + * which do parse as `class_declaration`. + * - `'unresolvable'` — a non-empty argument with no literal element + * (`ApiPaths.BASE`, `buildPath()`, a template). Served path is unknowable. + */ +type PathArgumentPrefix = 'literal' | 'none' | 'unresolvable'; + +function classifyPathArgument(expr: Parser.SyntaxNode): PathArgumentPrefix { + if (isPlainStringLiteral(expr)) return 'literal'; + if (expr.type === 'collection_literal') { + const elements = expr.namedChildren.filter((child) => child.text.length > 0); + if (elements.length === 0) return 'none'; + return elements.some(isPlainStringLiteral) ? 'literal' : 'unresolvable'; + } + const elements = kotlinArrayOfElements(expr); + if (elements) { + if (elements.length === 0) return 'none'; + return elements.some(isPlainStringLiteral) ? 'literal' : 'unresolvable'; + } + return 'unresolvable'; +} + +/** + * Type declarations enclosing `node`, innermost first, by qualified type path. + * + * The scope a bare constant in a route annotation is resolved against; passed to + * `foldKotlinOperands`, which applies it. Collects `class_declaration` (including + * interfaces) and `object_declaration`. A `companion_object` adds no link of + * its own — members are keyed under the enclosing class one hop up. For a node + * inside `Outer.Inner`, returns `['Outer.Inner', 'Outer']`, matching the keys + * produced by `extractKotlinModuleConstants`. Skips unnamed types rather than + * guessing. + */ +function kotlinEnclosingTypeNames(node: Parser.SyntaxNode): string[] { + const simpleNames: string[] = []; + for (let cur = node.parent; cur; cur = cur.parent) { + if (cur.type !== 'class_declaration' && cur.type !== 'object_declaration') continue; + const ident = cur.children.find((c) => c.type === 'type_identifier'); + if (ident) simpleNames.push(unquoteKotlinIdentifier(ident.text)); + } + return simpleNames.map((_, index) => simpleNames.slice(index).reverse().join('.')); +} + // ─── Kotlin OkHttp builder verb-walk (parity with java-static-path.ts) ── // Mirrors `inferOkHttpMethod`, adapted to the Kotlin grammar: a call `X.name(args)` // is a `call_expression` whose callee is a `navigation_expression` (receiver + @@ -399,6 +604,151 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { ], } satisfies LanguagePatterns>); + // ─── Provider: constant-valued @RequestMapping / @(Get|...)Mapping ──── + // The literal patterns above pin the path node itself (`(string_literal) @path`), + // which structurally cannot match `@GetMapping(ApiPaths.ORDERS)`. These two + // capture the whole `value_argument` instead and let + // `kotlinRouteArgumentExpression` sort out positional vs `path =`/`value =` + // in JS — a query-level split is not available here, because tree-sitter-kotlin + // uses one `value_argument` node for both forms and 0.21.x has no negation to + // test the `=` token with. + // + // These deliberately match LITERAL arguments too (any `value_argument` does). + // The method-route loop drops those via `FOLDABLE_PATH_EXPRESSIONS` so a + // literal route is emitted once, by the literal patterns; the class-prefix + // collector instead KEEPS them and tests them for literalness, which is how a + // prefix that no literal pattern could resolve gets noticed at all. + const SPRING_CONST_CLASS_PREFIX_PATTERNS = compilePatterns({ + name: 'kotlin-spring-const-class-prefix', + language, + patterns: [ + { + meta: {}, + query: ` + (class_declaration + (modifiers + (annotation + (constructor_invocation + (user_type (type_identifier) @ann (#eq? @ann "RequestMapping")) + (value_arguments (value_argument) @arg)))) + (type_identifier) @cls) @class + `, + }, + ], + } satisfies LanguagePatterns>); + + const SPRING_CONST_METHOD_ROUTE_PATTERNS = compilePatterns({ + name: 'kotlin-spring-const-method-route', + language, + patterns: [ + { + meta: {}, + query: ` + (function_declaration + (modifiers + (annotation + (constructor_invocation + (user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$")) + (value_arguments (value_argument) @arg)))) + (simple_identifier) @method_name) @method + `, + }, + ], + } satisfies LanguagePatterns>); + + const SPRING_CONST_FEIGN_PATH_PATTERNS = compilePatterns({ + name: 'kotlin-spring-const-feign-path', + language, + patterns: [ + { + meta: {}, + query: ` + (class_declaration + (modifiers + (annotation + (constructor_invocation + (user_type (type_identifier) @ann (#eq? @ann "FeignClient")) + (value_arguments (value_argument) @arg))))) @class + `, + }, + ], + } satisfies LanguagePatterns>); + + /** + * Ids of classes whose `@RequestMapping` prefix cannot be resolved to any + * literal, so no route under them can be published at a path the application + * actually serves. + * + * The predicate is INVERTED rather than an allow-list of non-literal node + * types: a class is marked unless its `path`/`value` argument is provably + * literal (recursing into `[…]` and `arrayOf(…)` elements, and refusing an + * interpolated `string_literal`). An allow-list has to enumerate every + * non-literal spelling and silently passes the ones it forgot — + * `[ApiPaths.BASE]`, `arrayOf(ApiPaths.BASE)`, `buildPath()`, + * `if (…) "/a" else "/b"` — each of which then publishes its methods at their + * UNPREFIXED path, a route the application does not serve. `java.ts` gates on + * the ABSENCE of a literal (`if (!valueNode)`) for the same reason. + * + * `resolvedPrefixes` is the literal prefix map built by the pass ABOVE, and a + * class holding an entry there is deliberately NOT marked: Kotlin's vararg + * spelling `@RequestMapping("/lit", ApiPaths.BASE)` leaves a resolvable `/lit` + * behind, and suppressing it would drop a route that IS derivable — trading a + * wrong route for a missing one, which is not the bargain this suppression + * exists to make. The prefix set is then partial (the constant arm is absent) + * exactly as it was before constant folding existed. + * + * The prefix is never folded here: it also feeds the cross-file + * interface-inheritance pass, which has no repo context, so folding it in + * `scan` alone would make the two views disagree. Same rule `java.ts` applies + * (`typesWithUnfoldablePrefix`); folding class prefixes cross-file is a + * follow-up on both sides. Used by BOTH `scan` and the inheritance-view + * collector — with the prefix map each has already built — so the two cannot + * drift apart. + */ + const collectUnfoldablePrefixClassIds = ( + tree: Parser.Tree, + resolvedPrefixes: ReadonlyMap, + ): Set => { + const ids = new Set(); + for (const match of runCompiledPatterns(SPRING_CONST_CLASS_PREFIX_PATTERNS, tree)) { + const argNode = match.captures.arg; + const classNode = match.captures.class; + if (!argNode || !classNode) continue; + if ((resolvedPrefixes.get(classNode.id) ?? []).length > 0) continue; + const expr = kotlinRouteArgumentExpression(argNode); + if (!expr || classifyPathArgument(expr) !== 'unresolvable') continue; + ids.add(classNode.id); + } + return ids; + }; + + /** + * Ids of `@FeignClient` interfaces whose `path` argument is present but not + * resolvable to a literal. + * + * `collectUnfoldablePrefixClassIds` cannot see these: it matches + * `@RequestMapping` only, so `@FeignClient(path = ApiPaths.BASE)` fell through + * to the `['']` prefix fallback and published the consumer at its unprefixed + * path — a call the service never makes. Kept as its own set rather than + * merged into the `@RequestMapping` one because `path` OUTRANKS + * `@RequestMapping` on a Feign client: an unresolvable `path` is fatal + * whatever the `@RequestMapping` says, and a resolvable `path` rescues a route + * whose `@RequestMapping` is a constant. The consumer lanes therefore consult + * the two in that same "path wins" order. + */ + const collectFeignUnfoldablePathClassIds = (tree: Parser.Tree): Set => { + const ids = new Set(); + for (const match of runCompiledPatterns(SPRING_CONST_FEIGN_PATH_PATTERNS, tree)) { + const argNode = match.captures.arg; + const classNode = match.captures.class; + if (!argNode || !classNode) continue; + const expr = kotlinFeignPathArgumentExpression(argNode); + if (!expr || classifyPathArgument(expr) !== 'unresolvable') continue; + ids.add(classNode.id); + } + return ids; + }; + // ─── Consumer: Spring RestTemplate ──────────────────────────────────── // Kotlin call-site shape mirrors the Java plugin's // `REST_TEMPLATE_PATTERNS`, but goes through tree-sitter-kotlin's @@ -875,11 +1225,24 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { const prefixNode = match.captures.prefix; const classNode = match.captures.class; if (!prefixNode || !classNode) continue; + // An INTERPOLATED literal (`"${ApiPaths.BASE}"`) is not a path — unquoting + // its raw text would carry the source spelling into the shared type view + // as a served prefix. Refusing it here is also what lets the unfoldable + // analysis below mark such a class (it skips classes with a resolved + // prefix), so the two stay one decision rather than two. + if (!isPlainStringLiteral(prefixNode)) continue; const prefix = unquoteLiteral(prefixNode.text); if (prefix !== null) pushPrefix(prefixByClassId, classNode.id, prefix); } // Method @(Get|...)Mapping routes keyed by the function_declaration node id. + // + // Only LITERAL paths land here. A constant-valued path is folded in `scan` + // against the repo constant map, which this inheritance-view collector has + // no access to; publishing it as an empty path would put `POST /`-shaped + // noise into the shared type view, so it is left out — the same skip floor + // `java.ts`'s `collectSpringTypes` keeps. const routesByMethodId = new Map>(); + const unfoldablePrefixClassIds = collectUnfoldablePrefixClassIds(tree, prefixByClassId); for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) { const annNode = match.captures.ann; const pathNode = match.captures.path; @@ -889,6 +1252,10 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { if (!httpMethod) continue; const rawPath = unquoteLiteral(pathNode.text); if (rawPath === null) continue; + // A constant class prefix leaves no single prefix string for the + // inheritance view to carry, so this route would be published unprefixed. + const owner = findEnclosingClass(methodNode); + if (owner && unfoldablePrefixClassIds.has(owner.id)) continue; const arr = routesByMethodId.get(methodNode.id) ?? []; arr.push({ method: httpMethod, path: rawPath }); routesByMethodId.set(methodNode.id, arr); @@ -931,8 +1298,90 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { return { name: 'kotlin-http', language, - scan(tree) { + prepareRepo(args) { + // Build the repo-wide Kotlin string-constant map and import index once per + // extract() run. The orchestrator hands over a bare Parser with no language + // bound; bind Kotlin explicitly or `parseSource` spins to its whole time + // budget on every file. + try { + args.parser.setLanguage(language); + } catch { + // A parser that rejects binding cannot produce a constant map; the + // per-file try/catch below then skips everything harmlessly. + } + const constants = new Map(); + for (const rel of args.files) { + if (!rel.endsWith('.kt') && !rel.endsWith('.kts')) continue; + try { + const src = args.readFile(rel); + // Cheap content gate: only constant-DEFINITION candidates are parsed + // here. Import-only files (every controller) are deliberately NOT + // parsed in this pass — `scan` extracts the importing file's own + // import table from the tree it already holds, on demand, for the + // rare file that actually references a constant. A gate that also + // matched `import …` would parse the entire repository here. + if (!src || !isKotlinConstantFile(src)) continue; + const tree = args.parseSource(args.parser, src); + if (!tree) continue; + const mc = extractKotlinModuleConstants(tree); + if ( + mc.literals.size > 0 || + mc.exprs.size > 0 || + mc.imports.size > 0 || + unfoldableDeclarationsOf(mc).size > 0 + ) { + // POSIX key (see `normalizeRel`); `readFile` above got the raw `rel`. + constants.set(normalizeRel(rel), mc); + } + } catch { + // Per-file resilience: one unreadable/oversized/ill-formed file must + // not forfeit the whole repo's constant map. + continue; + } + } + return { constants, index: buildKotlinConstantIndex(constants) }; + }, + scan(tree, repoContext, fileRel) { const out: HttpDetection[] = []; + const kotlinCtx = repoContext as + | { constants: RepoConstants; index: KotlinConstantIndex } + | undefined; + + // Read side of the POSIX keying (see `normalizeRel`): the map `prepareRepo` + // built is keyed by normalized path, so every lookup and every fold entry + // point below uses `fileKey`, never the raw `fileRel`. + const fileKey = fileRel === undefined ? undefined : normalizeRel(fileRel); + + // Lazy per-file constants/index view. `prepareRepo` only indexes constant- + // DEFINING files, so an importing controller is absent from that map. When + // a route references a constant, extract THIS file's import table from the + // tree `scan` already holds and overlay it. Import-only overlays reuse the + // prepared package projections; files whose routes are all literal never + // pay this cost. + let foldIndex: KotlinConstantIndex | undefined; + const getFoldIndex = (): KotlinConstantIndex | undefined => { + if (foldIndex !== undefined) return foldIndex; + foldIndex = kotlinCtx?.index; + if (!kotlinCtx || !fileKey) return foldIndex; + if (kotlinCtx.constants.has(fileKey)) return foldIndex; + try { + const mc = extractKotlinModuleConstants(tree); + // Same admission test the pre-pass applies above. Keeping the complete + // test here also makes this overlay correct if a future gate safely + // excludes another declaration shape. + if ( + mc.literals.size > 0 || + mc.exprs.size > 0 || + mc.imports.size > 0 || + unfoldableDeclarationsOf(mc).size > 0 + ) { + foldIndex = overlayKotlinConstantIndex(kotlinCtx.index, fileKey, mc); + } + } catch { + // fold falls back to the repo-wide map (imports stay unresolved) + } + return foldIndex; + }; // ─── Class prefixes ───────────────────────────────────────────── const prefixByClassId = new Map(); @@ -940,10 +1389,17 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { const prefixNode = match.captures.prefix; const classNode = match.captures.class; if (!prefixNode || !classNode) continue; + // An INTERPOLATED literal (`"${ApiPaths.BASE}"`) is not a path — see + // `isPlainStringLiteral`. Refusing it here also lets the unfoldable + // analysis below mark such a class, since that skips classes whose + // prefix already resolved. + if (!isPlainStringLiteral(prefixNode)) continue; const prefix = unquoteLiteral(prefixNode.text); if (prefix !== null) pushPrefix(prefixByClassId, classNode.id, prefix); } + const classesWithUnfoldablePrefix = collectUnfoldablePrefixClassIds(tree, prefixByClassId); + // ─── OpenFeign client interfaces + HTTP Interface type prefixes ── // In tree-sitter-kotlin an `interface` is a `class_declaration`, so a // `@FeignClient` interface's @(Get|...)Mapping methods would otherwise be @@ -956,11 +1412,12 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { if (!classNode) continue; feignClassIds.add(classNode.id); const prefixNode = match.captures.prefix; - if (prefixNode) { + if (prefixNode && isPlainStringLiteral(prefixNode)) { const prefix = unquoteLiteral(prefixNode.text); if (prefix !== null) pushPrefix(feignPrefixByClassId, classNode.id, prefix); } } + const feignClassesWithUnfoldablePath = collectFeignUnfoldablePathClassIds(tree); const httpExchangePrefixByClassId = new Map(); for (const match of runCompiledPatterns(SPRING_HTTP_EXCHANGE_CLASS_PATTERNS, tree)) { const classNode = match.captures.class; @@ -971,24 +1428,91 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { } // ─── Method routes (Spring providers) + OpenFeign consumers ───── + // Literal and constant-valued paths are normalized into one candidate list + // so both reach the same Feign/interface/prefix classification below. + const methodRoutes: Array<{ + httpMethod: string; + rawPath: string; + nameNode: Parser.SyntaxNode | undefined; + methodNode: Parser.SyntaxNode; + }> = []; for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) { const annNode = match.captures.ann; const pathNode = match.captures.path; - const nameNode = match.captures.method_name; const methodNode = match.captures.method; if (!annNode || !pathNode || !methodNode) continue; const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text]; if (!httpMethod) continue; const rawPath = unquoteLiteral(pathNode.text); if (rawPath === null) continue; + methodRoutes.push({ + httpMethod, + rawPath, + nameNode: match.captures.method_name, + methodNode, + }); + } + for (const match of runCompiledPatterns(SPRING_CONST_METHOD_ROUTE_PATTERNS, tree)) { + const annNode = match.captures.ann; + const argNode = match.captures.arg; + const methodNode = match.captures.method; + if (!annNode || !argNode || !methodNode) continue; + const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text]; + if (!httpMethod) continue; + const expr = kotlinRouteArgumentExpression(argNode); + if (!expr || !FOLDABLE_PATH_EXPRESSIONS.has(expr.type)) continue; + // No repo context (context-less fallback scanning) means no constant map + // and therefore no honest answer — skip rather than guess a path. + if (!fileKey) continue; + const index = getFoldIndex(); + if (!index) continue; + const operands = parseKotlinConstOperands(expr); + if (operands === null) continue; + // A bare reference means whatever the ENCLOSING types bind it to before + // it means anything at file level — Kotlin's rule for a companion + // member, which is in scope unqualified only inside its own class body. + const rawPath = foldKotlinOperands( + fileKey, + operands, + index.repo, + kotlinEnclosingTypeNames(methodNode), + index, + ); + if (rawPath === null) continue; + methodRoutes.push({ + httpMethod, + rawPath, + nameNode: match.captures.method_name, + methodNode, + }); + } + + for (const { httpMethod, rawPath, nameNode, methodNode } of methodRoutes) { const enclosingClass = findEnclosingClass(methodNode); // A @(Get|...)Mapping inside a @FeignClient interface is an OpenFeign // consumer (a remote call), not a route this service serves. if (enclosingClass && feignClassIds.has(enclosingClass.id)) { + // Whichever prefix GOVERNS must be resolvable, or the remote URL is + // unknowable and an unprefixed consumer would be a call this service + // never makes. Checked in the same "path wins" order the fallback + // below resolves in, so an unresolvable `@RequestMapping` does not + // suppress a client whose literal `@FeignClient(path)` outranks it, + // and an unresolvable `path` is fatal even when `@RequestMapping` is + // a literal. + // + // This reaches a Feign INTERFACE at all because tree-sitter-kotlin + // models `interface` as a `class_declaration`, and it should: Spring + // Cloud prepends the governing prefix to every method of the client. + // Java diverges only by accident of its grammar — `findEnclosingClass` + // skips `interface_declaration`, so `java.ts` still emits such a + // consumer at its unprefixed path. Aligning Java is a change to Java's + // behavior and belongs in its own follow-up, not in the Kotlin binding. + if (feignClassesWithUnfoldablePath.has(enclosingClass.id)) continue; + const feignPrefixes = feignPrefixByClassId.get(enclosingClass.id); + if (!feignPrefixes && classesWithUnfoldablePrefix.has(enclosingClass.id)) continue; // @FeignClient(path) wins over @RequestMapping; a multi-element prefix // yields one consumer per (prefix × this route). - const prefixes = feignPrefixByClassId.get(enclosingClass.id) ?? - prefixByClassId.get(enclosingClass.id) ?? ['']; + const prefixes = feignPrefixes ?? prefixByClassId.get(enclosingClass.id) ?? ['']; for (const prefix of prefixes) { out.push({ role: 'consumer', @@ -1002,6 +1526,10 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { } continue; } + // An unresolvable class prefix leaves no path this service serves, so + // every route under such a class is dropped rather than emitted at a + // wrong (unprefixed) one — the rule `java.ts` applies for Java. + if (enclosingClass && classesWithUnfoldablePrefix.has(enclosingClass.id)) continue; // A @(Get|...)Mapping on a (non-Feign) interface declares a route // *contract*, not a route this service serves — the implementing // @RestController is the provider, emitted via scanProject's interface @@ -1171,13 +1699,21 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin { if (!parsed) continue; const enclosingClass = findEnclosingClass(methodNode); if (!enclosingClass || !isKotlinInterface(enclosingClass)) continue; + // The same governing-prefix resolvability guard the @(Get|...)Mapping-in-Feign + // lane applies, in the same "path wins" order — this loop resolves through + // the identical fallback chain, so an unresolvable governing prefix leaves + // the remote URL just as unknowable here. Without it a single interface + // could suppress its @(Get|...)Mapping routes and publish its @RequestLine + // routes under the very same unresolvable prefix. + if (feignClassesWithUnfoldablePath.has(enclosingClass.id)) continue; + const feignPrefixes = feignPrefixByClassId.get(enclosingClass.id); + if (!feignPrefixes && classesWithUnfoldablePrefix.has(enclosingClass.id)) continue; // Mirror java.ts (which pre-merges the @RequestMapping fallback into // feignPrefixByInterfaceId, "path wins"): @FeignClient(path) wins, else // the interface's class-level @RequestMapping prefix, else none. Without // the prefixByClassId fallback Kotlin dropped the class prefix that Java // applies — the same fallback chain the @GetMapping-in-Feign path uses above. - const prefixes = feignPrefixByClassId.get(enclosingClass.id) ?? - prefixByClassId.get(enclosingClass.id) ?? ['']; + const prefixes = feignPrefixes ?? prefixByClassId.get(enclosingClass.id) ?? ['']; for (const prefix of prefixes) { out.push({ role: 'consumer', diff --git a/gitnexus/src/core/ingestion/route-extractors/kotlin-const-resolver.ts b/gitnexus/src/core/ingestion/route-extractors/kotlin-const-resolver.ts new file mode 100644 index 000000000..ceb1f92e1 --- /dev/null +++ b/gitnexus/src/core/ingestion/route-extractors/kotlin-const-resolver.ts @@ -0,0 +1,1400 @@ +/** + * Kotlin binding for the language-agnostic constant resolver (#2391 core). + * + * Supplies the two Kotlin-specific pieces — {@link resolveKotlinImport} (import + * specifier → file, honoring JVM package rules) and + * {@link extractKotlinModuleConstants} (tree → {@link ModuleConstants}) — plus + * the folding entry points {@link resolveKotlinConstant} and + * {@link foldKotlinOperands}, so callers stay language-oblivious. + * + * WHAT IS ACTUALLY SHARED WITH THE AGNOSTIC CORE. One value — + * {@link MAX_FOLD_LENGTH} — and five types. The core's own `resolveConstant` / + * `resolveOperands` are NOT called: the fold state machine below (cycle guard, + * success memo, depth caps, operand concatenation — roughly 200 of this file's + * lines) is a local fork, close enough to `java-const-resolver.ts`'s already + * forked copy that the two read as the same code with the language name + * swapped. + * + * That fork is a consequence, not an oversight. The core keys its maps by + * SIMPLE name, and a Kotlin operand can be a QUALIFIED reference at any + * position (`X = ApiPaths.Y + "/tail"`); handed to the core, `ApiPaths.Y` misses + * every map and floors the whole chain to null — see {@link computeKotlinFold}, + * which resolves operands through the qualified-aware walk for exactly this + * reason. The import chase is Kotlin-specific too: a member import is spelled + * identically to a type import, so {@link resolveImportedName} has to try both + * readings, and the core exposes no hook for that. Java forked first on the same + * grounds. Teaching the core qualified names, and retiring both copies against + * it, is the standing follow-up; until then the honest description of this file + * is "a second fork", not "a binding over a shared fold". + * + * Kotlin shares the JVM package/import model with Java, so this binding mirrors + * `java-const-resolver.ts` in structure, naming and skip-floor discipline. The + * four places Kotlin genuinely differs are handled explicitly, not translated: + * + * 1. **Where a constant can live.** Java has one carrier (`static final` on a + * type). Kotlin has three: a top-level `const val`/`val`, a member of an + * `object`, and a member of a `companion object` — the last is referenced + * through its ENCLOSING class (`Holder.NAME`), not through `Companion`. + * 2. **No `String` type gate.** Kotlin infers property types, so + * `const val ORDERS = "/orders"` carries no type node to check. The + * initializer decides: anything {@link parseKotlinConstOperands} cannot fold + * to a string (a number, a call, a template) drops the constant. + * 3. **File names and directories are free.** `object ApiPaths` may live in + * `Constants.kt`, and a file's `package` need not match the directory it + * sits in, so a `/.kt` PATH lookup is a convention and not a + * rule. The authority is each file's DECLARED `package`, which + * {@link extractKotlinModuleConstants} records and {@link resolveKotlinImport} + * requires an exact match on; the path is only a tie-break among files that + * already declare the right package. + * 4. **Member imports are unmarked.** Java spells them `import static a.b.C.F`; + * Kotlin writes `import a.b.C.F`, which is byte-identical to a type import + * of a class `F` in package `a.b.C`. Nothing in the syntax says which, so + * the fold tries both readings (see `resolveImportedName`) instead of + * guessing from casing. + * 5. **Any identifier may be backtick-quoted.** `` package com.example.`api` `` + * and `package com.example.api` are the SAME package to the compiler, and a + * keyword segment (`` com.example.`fun` ``) can only be spelled the quoted + * way. The grammar keeps the backticks in the node text, so every identifier is + * read through {@link unquoteKotlinIdentifier} before it becomes a map key + * or a lookup name — see that function for what a verbatim comparison cost. + * + * THREE PLACES THIS BINDING NO LONGER MIRRORS JAVA, each because the mirrored + * behavior was wrong rather than merely different, and each open as a Java + * follow-up rather than fixed here: + * + * * `java-const-resolver.ts` flattens nested types into one file-level + * namespace and argues the collision away — "qualified refs carry the class + * name, so nesting only matters for same-name fields, which flatten + * last-wins". The argument does not hold: the collision is one level BELOW + * the qualification, in the initializer, so a fully qualified `A.ROUTE` whose + * initializer names a bare sibling `BASE` still resolves through whichever + * same-named sibling was walked last. {@link extractKotlinModuleConstants} + * keys by Kotlin's own visibility instead. + * * Java's import resolution can lean on the `/.java` layout the + * language enforces. Kotlin's cannot, and inferring the package from the path + * lets a path-suffix twin outrank the real declaration — so + * {@link resolveKotlinImport} reads the declared `package` instead. + * * Java's fold entry points take a file and a name, because a Java `static + * final` reachable by simple name is reachable that way from anywhere in the + * file. A Kotlin COMPANION member is not: it is bound unqualified only inside + * its enclosing class body. {@link foldKotlinOperands} therefore also takes + * the enclosing type chain of the reference site, which is what lets the + * binding answer a bare reference by Kotlin's scoping rather than by "whoever + * was walked last" — see {@link qualifyKotlinRefInEnclosingTypes}. + * + * Constant shapes this binding harvests: + * + * const val TOP_LEVEL = "/api/v1" // file top level + * object ApiPaths { // object member + * const val BASE = "/api/v1" + * val ORDERS = BASE + "/orders" + * } + * class Holder { companion object { const val H = "/h" } } // → Holder.H + * + * Reference shapes at annotation sites this binding resolves: + * @PostMapping(ApiPaths.ORDERS) // qualified + * @PostMapping(com.example.app.api.ApiPaths.ORDERS) // FQN-qualified + * @PostMapping(ORDERS) // single-name import + * @PostMapping(ApiPaths.BASE + "/orders") // inline concat + * + * Which ANNOTATIONS count as routes is a separate question this module has no + * say in — `spring-shared.ts` owns that map. Folding and annotation recognition + * compose; neither implies the other. + * + * Keying (parity with the Java and Python bindings): the repo map is keyed by + * unique POSIX file path, and an import that cannot be pinned to exactly one + * file returns null (skip floor), never a wrong path. A missing route is a + * missing fact; a wrongly folded one is a false edge in the graph. "Exactly one + * file" is decided from the DECLARED package, not from the path: a path is a + * repository-layout accident that any decoy directory can imitate, whereas the + * `package` header is the declaration the compiler itself resolves against. + * + * POSIX keys are a PRECONDITION this module cannot check cheaply, so it is + * enforced at the one boundary that produces them: `http-patterns/kotlin.ts` + * normalizes separators on both the write side (the `prepareRepo` map keys) and + * the read side (`scan`'s `fileRel`). It has to, because the orchestrator's file + * list comes from glob v13, which has no `posix: true` and joins with the + * platform separator — so on Windows the keys arrive backslashed and every + * `/.kt` test in {@link resolveKotlinImport} would miss, silently + * disabling cross-file folding on that platform alone. Normalizing INSIDE this + * module instead cannot work: the resolver returns the key it matched, and a + * normalized return value would then miss in a map that was never normalized. + * + * WHERE THIS IS WIRED. Java reaches its binding from BOTH layers: the group + * extractor (`group/extractors/http-patterns/java.ts`) and the ingestion + * provider (`languages/java.ts`, via `extractModuleConstants` + + * `foldRoutePathOperands`). Kotlin is wired into the GROUP layer only, because + * the ingestion fold in `pipeline-phases/parse-impl.ts` runs exclusively over + * `decoratorRoutes` — and `languages/kotlin.ts` declares no + * `extractDecoratorRoutes`, since the ingestion Spring extractor (`spring.ts`) + * is bound to `tree-sitter-java` and its node types. Declaring the constant + * hooks on the Kotlin provider today would harvest a map on every Kotlin file + * that nothing consumes. An ingestion-side Kotlin route extractor is the + * prerequisite; when it lands, this binding is what its provider hooks should + * point at, and no change here is needed. + */ + +import type Parser from 'tree-sitter'; +import { unquoteSpringLiteral } from './spring-shared.js'; +import { + MAX_FOLD_LENGTH, + type ImportBinding, + type ModuleConstants, + type Operand, + type RepoConstants, +} from './constant-resolver.js'; + +export type { + ImportBinding, + ModuleConstants, + Operand, + RepoConstants, +} from './constant-resolver.js'; + +/** + * What {@link extractKotlinModuleConstants} returns: the agnostic + * {@link ModuleConstants} plus the one piece of per-file metadata JVM import + * resolution cannot be honest without — the file's declared `package`. + * + * Deliberately a KOTLIN-LOCAL widening rather than a field on the shared type. + * `ModuleConstants` is consumed by the Java, JS and Python bindings too, and + * none of them needs this: Python resolves imports from the module path, and + * Java's `package` is already pinned by the `/.java` rule the + * language enforces. Adding a required field there would force three unrelated + * bindings to fill it in; adding an optional one would put a Kotlin-shaped hole + * in a type whose whole point is language neutrality. + * + * Read the metadata through {@link declaredPackageOf} and + * {@link unfoldableDeclarationsOf}, never by field access: a + * {@link RepoConstants} is typed over the agnostic shape, so an entry that some + * other producer put there carries no package and must be REJECTED as a + * candidate rather than silently treated as the default package. Missing + * unfoldable-declaration metadata instead means "none known", preserving the + * agnostic entry's existing behavior. + */ +export interface KotlinModuleConstants extends ModuleConstants { + /** The file's declared `package`, or `''` for the default package. */ + readonly packageName: string; + /** Declaration keys whose initializer cannot be folded. */ + readonly unfoldableDeclarations: ReadonlySet; +} + +/** + * The declared `package` of the file `mc` describes, or null when the entry did + * not come from {@link extractKotlinModuleConstants} and therefore cannot be + * matched against an import specifier. + */ +function declaredPackageOf(mc: ModuleConstants | undefined): string | null { + const declared = (mc as KotlinModuleConstants | undefined)?.packageName; + return typeof declared === 'string' ? declared : null; +} + +const NO_UNFOLDABLE_DECLARATIONS: ReadonlySet = new Set(); + +/** + * Kotlin declaration keys known to exist but not fold, or an empty set when + * `mc` came from another language binding. + */ +export function unfoldableDeclarationsOf(mc: ModuleConstants | undefined): ReadonlySet { + const declarations = (mc as KotlinModuleConstants | undefined)?.unfoldableDeclarations; + return declarations instanceof Set ? declarations : NO_UNFOLDABLE_DECLARATIONS; +} + +/** Source extensions a Kotlin declaration can live in. */ +const KOTLIN_EXTENSIONS = ['.kt', '.kts'] as const; + +/** + * The name a backtick-quoted Kotlin identifier denotes: `` `api` `` → `api`. + * + * Quotes are spelling, not part of the name. tree-sitter-kotlin keeps them in + * node text, so every identifier that becomes a map key or lookup is read + * through here. Applied per dot-separated segment — a quoted identifier cannot + * contain `.`. Both the declaration side ({@link declaredPackage}) and the + * import side ({@link resolveKotlinImport}) are normalized, because either may + * carry the quotes while the other spells the same name plainly. + */ +export function unquoteKotlinIdentifier(text: string): string { + return text.length >= 2 && text.startsWith('`') && text.endsWith('`') ? text.slice(1, -1) : text; +} + +/** {@link unquoteKotlinIdentifier} applied to every segment of a dotted name. */ +function unquoteKotlinDottedName(text: string): string { + return text.includes('`') ? text.split('.').map(unquoteKotlinIdentifier).join('.') : text; +} + +/** + * Recursion ceiling for {@link parseKotlinConstOperands}, counted in `+` links. + * + * This bounds SYNTAX depth, not resolution: `A + B + C` nests one + * `additive_expression` per link, so the cap is really "how long a concatenation + * may one initializer be". Deliberately loose — generated route tables do + * concatenate a dozen fragments, and overrunning costs a skipped route, so the + * cap is a guard against pathological input rather than a statement about + * reasonable code. + */ +const MAX_OPERAND_PARSE_DEPTH = 64; + +/** + * Recursion ceiling for the fold, counted in REFERENCE hops (`A = B`, `B = C`). + * + * Larger than the agnostic core's own `MAX_RESOLVE_DEPTH` (8), which is + * module-private in `constant-resolver.ts` and therefore cannot simply be + * reused, and equal to the value the Java binding spells inline. It backstops + * the cycle guard, which terminates loops but not a long acyclic chain; the + * memo makes reaching it cheap. Both caps floor to null, i.e. to a skipped + * route. + */ +const MAX_FOLD_DEPTH = 32; + +/** + * Cheap content gate: can this Kotlin file DEFINE a string constant that a route + * annotation might reference? + * + * Exported so every caller uses the same predicate and none can disagree with + * {@link extractKotlinModuleConstants} about which files carry constants — the + * defect class the Java binding's shared `isJavaConstantFile` exists to prevent. + * + * Arms, all intended to be WIDER than the extractor (a gate may over-admit — it + * only costs a parse — while rejecting a file the extractor would accept costs a + * fact): + * - `const val NAME [: T] =`. `const` is legal only at a file's top level or in + * an `object`/`companion object`, i.e. exactly the carriers the extractor + * harvests, so this arm needs no scope check. + * - an `object` (or `companion object`) declaration together with a `val NAME =` + * binding. A non-`const` `val` is the other half of the extractor's input and + * carries no keyword of its own; requiring an `object` nearby keeps a file + * whose only `val`s are function locals from costing a parse. It still admits + * a top-level `val` in a file that happens to declare an object elsewhere, + * which is the harmless direction. + * - a `val NAME =` binding at file scope. A small lexical walk tracks braces + * and parentheses while skipping comments and literals, admitting the + * top-level non-`const` property the extractor harvests without turning every + * function-local `val` or constructor property into an extra parse. + * + * Both name arms accept a BACKTICK-QUOTED identifier as well as a bare one, + * because the extractor does: `unquoteKotlinIdentifier` strips the quoting + * everywhere a name becomes a key, so `const val \`ORDERS\` = "/orders"` is a + * constant this module resolves. A gate that matched only `\w+` rejected the + * file outright and the reference floored to skip — a gate narrower than the + * extractor, which is the one direction the arms above are meant to exclude. + */ +const KOTLIN_NAME = String.raw`(?:\w+|\`[^\`\n]+\`)`; +const CONST_VAL_AT = new RegExp(String.raw`const\s+val\s+${KOTLIN_NAME}(?=\s|:|=|$)`, 'y'); +const VAL_DECLARATION_AT = new RegExp(String.raw`val\s+${KOTLIN_NAME}(?=\s|:|=|by\b|$)`, 'y'); + +/** Is `source[index...]` the keyword `word`, rather than part of an identifier? */ +function keywordAt(source: string, index: number, word: string): boolean { + if (!source.startsWith(word, index)) return false; + const before = index === 0 ? '' : source[index - 1]; + const after = source[index + word.length] ?? ''; + return !/[\w$]/.test(before) && !/[\w$]/.test(after); +} + +/** Test a sticky declaration pattern at one source offset without slicing. */ +function declarationAt(pattern: RegExp, source: string, index: number): boolean { + pattern.lastIndex = index; + return pattern.test(source); +} + +export function isKotlinConstantFile(source: string): boolean { + let braces = 0; + let parens = 0; + let blockCommentDepth = 0; + let sawObject = false; + + for (let i = 0; i < source.length; i++) { + if (blockCommentDepth > 0) { + if (source.startsWith('/*', i)) { + blockCommentDepth++; + i++; + } else if (source.startsWith('*/', i)) { + blockCommentDepth--; + i++; + } + continue; + } + + if (source.startsWith('//', i)) { + const newline = source.indexOf('\n', i + 2); + if (newline < 0) break; + i = newline; + continue; + } + if (source.startsWith('/*', i)) { + blockCommentDepth = 1; + i++; + continue; + } + + const quote = source[i]; + if (source.startsWith('"""', i)) { + const end = source.indexOf('"""', i + 3); + if (end < 0) break; + i = end + 2; + continue; + } + if (quote === '"' || quote === "'") { + for (i++; i < source.length; i++) { + if (source[i] === '\\') { + i++; + continue; + } + if (source[i] === quote) break; + } + continue; + } + if (quote === '`') { + const end = source.indexOf('`', i + 1); + if (end < 0) break; + i = end; + continue; + } + + if (quote === '{') { + braces++; + continue; + } + if (quote === '}') { + braces = Math.max(0, braces - 1); + continue; + } + if (quote === '(') { + parens++; + continue; + } + if (quote === ')') { + parens = Math.max(0, parens - 1); + continue; + } + + if (keywordAt(source, i, 'object')) { + sawObject = true; + i += 'object'.length - 1; + continue; + } + if (keywordAt(source, i, 'const') && declarationAt(CONST_VAL_AT, source, i)) return true; + if (keywordAt(source, i, 'val') && declarationAt(VAL_DECLARATION_AT, source, i)) { + if (sawObject || (braces === 0 && parens === 0)) return true; + i += 'val'.length - 1; + } + } + return false; +} + +/** Does `key` name the file `.kt` / `.kts`? */ +function isFileNamedAfterDeclaration(key: string, asPath: string): boolean { + for (const ext of KOTLIN_EXTENSIONS) { + const candidate = `${asPath}${ext}`; + if (key === candidate || key.endsWith(`/${candidate}`)) return true; + } + return false; +} + +/** + * Does the file `mc` describes declare a top-level entity called `name` — an + * `object`/companion carrier whose members are keyed `name.`, or a + * top-level constant keyed `name` outright? + * + * A true result is strong enough to select a unique declaring file before path + * fallbacks: the tested key set is a superset of every local key the fold may + * subsequently read for that imported name. Two matching files are therefore + * ambiguous; one is authoritative. A miss falls back to the conservative path + * heuristics below, whose result is still verified by the actual map lookup. + */ +function declaresTopLevelName(mc: ModuleConstants, name: string): boolean { + const prefix = `${name}.`; + for (const map of [mc.literals, mc.exprs]) { + if (map.has(name)) return true; + for (const key of map.keys()) if (key.startsWith(prefix)) return true; + } + for (const key of unfoldableDeclarationsOf(mc)) { + if (key === name || key.startsWith(prefix)) return true; + } + return false; +} + +/** Files and declaration ownership for one exact Kotlin package. */ +export interface KotlinPackageConstants { + readonly files: readonly string[]; + /** Unique declaring file, or null when the package declares the name twice. */ + readonly declarers: ReadonlyMap; +} + +/** One exact package-qualified declaration and its in-file lookup key. */ +interface KotlinImportTarget { + readonly fileKey: string; + readonly localName: string; +} + +/** + * Repo-wide projections reused by every fold in one extraction run. + * + * `repo` includes importing files overlaid by `scan`; `constantKeys` and + * `byPackage` include only files that define a foldable or explicitly + * unfoldable declaration, preserving import ambiguity semantics. + */ +export interface KotlinConstantIndex { + readonly repo: RepoConstants; + readonly constantKeys: ReadonlySet; + readonly byPackage: ReadonlyMap; + /** Exact FQN → unique file/local key, or null when the FQN is duplicated. */ + readonly byFqn: ReadonlyMap; +} + +/** Does this file contribute declarations to Kotlin import ambiguity? */ +function contributesKotlinConstants(mc: ModuleConstants): boolean { + return mc.literals.size > 0 || mc.exprs.size > 0 || unfoldableDeclarationsOf(mc).size > 0; +} + +/** Top-level names declared by one file (`Outer.X` contributes `Outer`). */ +function topLevelDeclarationNames(mc: ModuleConstants): Set { + const names = new Set(); + for (const map of [mc.literals, mc.exprs]) { + for (const key of map.keys()) { + const dot = key.indexOf('.'); + names.add(dot < 0 ? key : key.slice(0, dot)); + } + } + for (const key of unfoldableDeclarationsOf(mc)) { + const dot = key.indexOf('.'); + names.add(dot < 0 ? key : key.slice(0, dot)); + } + return names; +} + +/** Every declaration key recorded for a file, foldable or not. */ +function declarationKeys(mc: ModuleConstants): Set { + return new Set([...mc.literals.keys(), ...mc.exprs.keys(), ...unfoldableDeclarationsOf(mc)]); +} + +/** Build the immutable import projections once for a repo constant map. */ +export function buildKotlinConstantIndex(repo: RepoConstants): KotlinConstantIndex { + const constantKeys = new Set(); + const byFqn = new Map(); + const mutablePackages = new Map< + string, + { files: string[]; declarers: Map } + >(); + + for (const [key, mc] of repo) { + if (!contributesKotlinConstants(mc)) continue; + constantKeys.add(key); + const packageName = declaredPackageOf(mc); + if (packageName === null) continue; + let bucket = mutablePackages.get(packageName); + if (!bucket) { + bucket = { files: [], declarers: new Map() }; + mutablePackages.set(packageName, bucket); + } + bucket.files.push(key); + for (const name of topLevelDeclarationNames(mc)) { + if (!bucket.declarers.has(name)) bucket.declarers.set(name, key); + else if (bucket.declarers.get(name) !== key) bucket.declarers.set(name, null); + } + for (const declaration of declarationKeys(mc)) { + const parts = declaration.split('.'); + // A member key `Outer.Inner.Q` proves the file declares both owner paths + // as well as the member itself. This lets imports of nested objects and + // their members retain the complete in-file lookup path. + for (let length = 1; length <= parts.length; length++) { + const localName = parts.slice(0, length).join('.'); + const fqn = packageName === '' ? localName : `${packageName}.${localName}`; + const existing = byFqn.get(fqn); + if (existing === undefined) byFqn.set(fqn, { fileKey: key, localName }); + else if (existing !== null && existing.fileKey !== key) byFqn.set(fqn, null); + } + } + } + + return { repo, constantKeys, byPackage: mutablePackages, byFqn }; +} + +/** + * Add one scan-time file without rebuilding the base index when it only imports + * constants. A newly discovered declaration is rare and rebuilds once for that + * file's scan, never once per route. + */ +export function overlayKotlinConstantIndex( + index: KotlinConstantIndex, + fileKey: string, + mc: ModuleConstants, +): KotlinConstantIndex { + const repo = new Map(index.repo); + const replacing = repo.has(fileKey); + repo.set(fileKey, mc); + if (!replacing && !contributesKotlinConstants(mc)) return { ...index, repo }; + return buildKotlinConstantIndex(repo); +} + +/** + * 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. + * + * A specifier is split at its last dot into the package it names and the + * declaration inside it (`com.example.app.api` + `ApiPaths`). Resolution then + * runs in three steps, all of them "unique or nothing": + * + * 0. **Declared package** — only files whose `package` header is EXACTLY the + * sought package can carry the declaration (compared after + * {@link unquoteKotlinIdentifier}, since backtick quoting is spelling and + * not identity). This is the authority, and it + * is checked first. Kotlin does not require a file's directory to match its + * package, so the reverse test — "does this path end with the package?" — + * answers a different question, one any decoy directory can satisfy: a file + * at `src/x/com/example/api/ApiPaths.kt` declaring `package x.com.example.api` + * is not `com.example.api.ApiPaths` and must never be folded as it, and a + * path-suffix test also lets a root-level `package data` be impersonated by + * `…/com/example/data/`. An entry with no recorded package is rejected, not + * assumed to be the default package. + * 1. **Declared name** — when exactly one file declares the sought name, use + * it. When two do, the FQN itself is duplicated in the repository and names + * no single declaration, so return null. This is the general form of the + * same-FQN check step 2 could only make for files that happen to follow the + * file-name convention, and it is what stops a `src/test/…` copy of a + * production constant from being folded into a production route. + * 2. **File named after the declaration** — when declaration metadata found no + * owner, try the package-matching file ending + * `com/example/app/api/ApiPaths.kt`. Kotlin does not require this (`object + * ApiPaths` may live in `Constants.kt`), so it is only a fallback candidate; + * the subsequent map lookup must still prove that it carries the value. + * 3. **Sole file in the package** — when declaration metadata cannot identify + * the name, use the unique package-matching candidate. The set passed in + * contains files with foldable or explicitly unfoldable declarations, so + * unrelated files cannot create ambiguity once step 1 identifies a unique + * declarer. With 2+ unidentified candidates it returns null. + * + * Steps 2 and 3 can still hand back a file that does not declare the wanted name + * (its package is right and it is the only candidate, but the name lives + * elsewhere or nowhere). That remains safe by construction: the fold looks the + * name up in that file's map, misses, and returns null. + * + * A "nearest shared directory" tie-break is deliberately NOT applied when a step + * has several candidates, for the reason the Java binding records: the JVM + * resolves duplicate FQNs by classpath order, not directory proximity, so a test + * fixture copy sitting 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. + * + * This can no longer be typed as the agnostic {@link ModuleConstants} consumer's + * `ImportResolver`, whose signature carries only file KEYS: deciding a candidate + * on its declared package needs the map those keys index. Nothing is lost — the + * core's own fold is not used here either (see the module header), and the + * alternative is a resolver that must guess from a path. + */ +export function resolveKotlinImport( + _importingFileKey: string, + rawModuleSpec: string, + candidateKeys: ReadonlySet, + repo: RepoConstants, +): string | null { + // Normalized here as well as at extraction, so the function answers the same + // question however a caller spells the specifier. + const moduleSpec = unquoteKotlinDottedName(rawModuleSpec); + const lastDot = moduleSpec.lastIndexOf('.'); + const packageName = lastDot < 0 ? '' : moduleSpec.slice(0, lastDot); + const simpleName = lastDot < 0 ? moduleSpec : moduleSpec.slice(lastDot + 1); + + // Step 0 + step 1 in one pass over the candidates. + const inPackage: string[] = []; + let declaring: string | null = null; + for (const key of candidateKeys) { + const mc = repo.get(key); + if (!mc || declaredPackageOf(mc) !== packageName) continue; + inPackage.push(key); + if (declaresTopLevelName(mc, simpleName)) { + if (declaring !== null) return null; // 2+ files declare this FQN + declaring = key; + } + } + if (inPackage.length === 0) return null; + if (declaring !== null) return declaring; + if (inPackage.length === 1) return inPackage[0]; // steps 2 and 3 agree + + // Step 2: the file-name convention, as a tie-break among valid candidates. + const asPath = moduleSpec.replace(/\./g, '/'); + let named: string | null = null; + for (const key of inPackage) { + if (!isFileNamedAfterDeclaration(key, asPath)) continue; + if (named !== null) return null; // 2+ files spell the convention + named = key; + } + // Step 3 is "the sole candidate", already returned above. + return named; +} + +/** Indexed equivalent of {@link resolveKotlinImport}, with identical fallbacks. */ +export function resolveKotlinImportWithIndex( + rawModuleSpec: string, + index: KotlinConstantIndex, +): string | null { + return resolveKotlinImportTarget(rawModuleSpec, index)?.fileKey ?? null; +} + +/** Resolve an import to both its file and complete in-file declaration path. */ +function resolveKotlinImportTarget( + rawModuleSpec: string, + index: KotlinConstantIndex, +): KotlinImportTarget | null { + const moduleSpec = unquoteKotlinDottedName(rawModuleSpec); + const lastDot = moduleSpec.lastIndexOf('.'); + const packageName = lastDot < 0 ? '' : moduleSpec.slice(0, lastDot); + const simpleName = lastDot < 0 ? moduleSpec : moduleSpec.slice(lastDot + 1); + const bucket = index.byPackage.get(packageName); + // Preserve the top-level interpretation whenever the exact declared package + // exists. A parent package may legally contain nested objects whose joined + // path spells the same FQN; letting that projection win would make a nested + // decoy override the real top-level declaration. + if (!bucket) return index.byFqn.get(moduleSpec) ?? null; + + if (bucket.declarers.has(simpleName)) { + const fileKey = bucket.declarers.get(simpleName); + return fileKey === null || fileKey === undefined ? null : { fileKey, localName: simpleName }; + } + if (bucket.files.length === 1) return { fileKey: bucket.files[0], localName: simpleName }; + + const asPath = moduleSpec.replace(/\./g, '/'); + let named: string | null = null; + for (const key of bucket.files) { + if (!isFileNamedAfterDeclaration(key, asPath)) continue; + if (named !== null) return null; + named = key; + } + return named === null ? null : { fileKey: named, localName: simpleName }; +} + +/** + * Is `node` a Kotlin string literal, and if so what value does the route layer + * give it? + * + * Two rejections, both floors rather than guesses: + * - **String templates.** `"$base/orders"` parses as a `string_literal` whose + * children include an interpolation alongside the `string_content` runs. + * Joining the content runs would silently DELETE the interpolated part and + * publish `/orders` — a path the application does not serve. Any named child + * that is not `string_content` means the value is not statically knowable, so + * the literal is refused. (The same test makes the function safe against a + * grammar that splits escape sequences into their own nodes: it would floor + * to skip, never to a de-escaped path.) + * - **Multi-line raw strings.** A single-line `"""/api"""` is exact — unlike a + * Java text block, a Kotlin raw string performs no escape processing and no + * incidental-indentation stripping, so it folds to precisely its content. A + * multi-line one carries newlines (and usually a `.trimIndent()` call this + * layer cannot fold), so it is refused. + * + * Otherwise the quotes are sliced off the RAW TEXT via + * {@link unquoteSpringLiteral} — the same function the literal path uses — so + * `@GetMapping(ApiPaths.USER_REGEX)` and `@GetMapping("/user/{id:\\d+}")` emit + * the same path for the same Kotlin source. + */ +function stringLiteralValue(node: Parser.SyntaxNode): string | null { + if (node.type !== 'string_literal') return null; + for (const child of node.namedChildren) { + if (child.type !== 'string_content') return null; + } + const raw = node.text; + if (raw.startsWith('"""') && raw.includes('\n')) return null; + return unquoteSpringLiteral(raw); +} + +/** + * Flatten a navigation expression (`ApiPaths`, `com.example.app.ApiPaths`) to + * its dotted text, or null when any segment is not a plain identifier (calls, + * `this`, indexing, safe navigation — not a constant shape). + */ +function flattenNavigation(node: Parser.SyntaxNode): string | null { + if (node.type === 'simple_identifier') return unquoteKotlinIdentifier(node.text); + if (node.type === 'navigation_expression') { + const target = node.namedChild(0); + const suffix = node.namedChildren.find((c) => c.type === 'navigation_suffix'); + const field = suffix?.namedChildren.find((c) => c.type === 'simple_identifier'); + if (target && field) { + const head = flattenNavigation(target); + return head === null ? null : `${head}.${unquoteKotlinIdentifier(field.text)}`; + } + } + return null; +} + +/** + * Parse a Kotlin constant initializer (or an inline annotation argument) into an + * operand list, or null when it is not a foldable string expression. Handles a + * bare string literal, a bare identifier (`X = Y`), a qualified reference + * (`X = ApiPaths.Y` — recorded as ONE ref named `ApiPaths.Y`), and + * left-associative `+` chains of the three. Everything else — numbers, calls, + * `when`/`if` expressions, templates, `buildString` — returns null, which makes + * the constant unresolvable (→ skip floor), never a wrong value. + * + * A chain nests: tree-sitter-kotlin parses `A + B + C` as + * `additive_expression(additive_expression(A, B), C)`, so every node here has + * exactly two operands and arbitrary-length chains fold by recursion. The same + * node type also carries `-`, which is not a string operation, so a `+` token + * must be present. + * + * A PARENTHESIZED operand (`(A + B) + "/c"`) is deliberately NOT unwrapped, + * matching `parseJavaConstOperands`, which has no parenthesis arm either. The + * shape is vanishingly rare in a route annotation and the cost of omitting it is + * a skipped route, not a wrong one; adding it to both bindings at once is the + * only way to keep them in parity, so it is left to a follow-up. + */ +export function parseKotlinConstOperands( + node: Parser.SyntaxNode | null | undefined, + depth = 0, +): Operand[] | null { + if (!node) return null; + if (depth > MAX_OPERAND_PARSE_DEPTH) return null; + if (node.type === 'string_literal') { + const value = stringLiteralValue(node); + return value === null ? null : [{ kind: 'literal', value }]; + } + if (node.type === 'simple_identifier') { + return [{ kind: 'ref', name: unquoteKotlinIdentifier(node.text) }]; + } + if (node.type === 'navigation_expression') { + const name = flattenNavigation(node); + return name === null ? null : [{ kind: 'ref', name }]; + } + // `additive_expression` covers both `+` and `-` in tree-sitter-kotlin; only a + // `+` chain concatenates strings. + if (node.type === 'additive_expression') { + if (!(node.children ?? []).some((c) => c.type === '+')) return null; + const operandNodes = node.namedChildren; + if (operandNodes.length !== 2) return null; + const left = parseKotlinConstOperands(operandNodes[0], depth + 1); + const right = parseKotlinConstOperands(operandNodes[1], depth + 1); + if (left === null || right === null) return null; + return [...left, ...right]; + } + return null; +} + +/** The `val`/`var` keyword a property declaration binds with, or null. */ +function bindingKind(property: Parser.SyntaxNode): string | null { + return property.children.find((c) => c.type === 'binding_pattern_kind')?.text ?? null; +} + +/** + * The initializer expression of a property declaration, or null when it has + * none. + * + * Reads the `=` that is a DIRECT child of the `property_declaration`, so a + * custom getter (`val X: String get() = "/g"`, whose `=` lives under `getter`) + * and a delegate (`val X by lazy { … }`, which has no `=` at all) both yield + * null. Both are computed at access time and are not constants. + */ +function initializerOf(property: Parser.SyntaxNode): Parser.SyntaxNode | null { + let equalsIndex = -1; + for (let i = 0; i < property.childCount; i++) { + if (property.child(i)?.type === '=') { + equalsIndex = i; + break; + } + } + if (equalsIndex < 0) return null; + for (let i = equalsIndex + 1; i < property.childCount; i++) { + const child = property.child(i); + if (child?.isNamed) return child; + } + return null; +} + +/** + * One `val` declaration, captured before anything is written to the file's + * namespace so that the DECLARING SCOPE of every initializer is known regardless + * of the order the declarations appear in. + */ +interface KotlinConstDeclaration { + /** The declaration's simple name. */ + readonly name: string; + /** `.`, or null for a top-level declaration. */ + readonly qualified: string | null; + /** + * The qualified-key prefixes in LEXICAL scope for this declaration's + * initializer, innermost first (`['Outer.Inner', 'Outer']`). Empty at file + * level. + */ + readonly scopes: readonly string[]; + /** + * Is the simple name a FILE-LEVEL binding — one any reference in the file can + * use unqualified? True only for a top-level `val`. FALSE for a member of a + * named `object` (which every caller outside that object's body must qualify) + * and FALSE for a companion member, whose unqualified binding exists only + * inside its enclosing class body and is reached through + * {@link qualifyKotlinRefInEnclosingTypes} instead. + */ + readonly fileLevelName: boolean; + /** + * Does an unfoldable initializer here take a same-named IMPORT down with it? + * + * True wherever the declaration binds the simple name for at least some of the + * file — a top-level `val` (everywhere) or a companion member (inside its + * class). Deliberately wider than {@link fileLevelName}: a companion's shadow + * is scoped, but this map is not, and over-deleting an import can only cost a + * route, whereas under-deleting one publishes the imported value at a + * reference the compiler resolves to the unfoldable member. An `object` member + * shadows nothing and is false. + */ + readonly shadowsImport: boolean; + /** The parsed initializer, or null when it is not a foldable string. */ + readonly operands: readonly Operand[] | null; +} + +/** + * The file's declared `package`, or `''` when it declares none (default + * package). Shaped exactly like the import walk below: `package_header` holds + * one `identifier` whose `simple_identifier` children are the dotted segments. + * + * Each segment is unquoted (see {@link unquoteKotlinIdentifier}), so a package + * declared `` com.example.`api` `` is recorded — and therefore matched — as the + * same package an import spells `com.example.api`. + */ +function declaredPackage(root: Parser.SyntaxNode): string { + const header = root.children.find((c) => c.type === 'package_header'); + const identifier = header?.children.find((c) => c.type === 'identifier'); + if (!identifier) return ''; + return identifier.namedChildren + .filter((c) => c.type === 'simple_identifier') + .map((c) => unquoteKotlinIdentifier(c.text)) + .join('.'); +} + +/** + * Extract the declared package, file-level string constants and import bindings + * of one parsed Kotlin file into the {@link KotlinModuleConstants} shape the + * resolver consumes. + * + * Constants come from the three carriers Kotlin allows a caller to reach without + * an instance: file top level, `object` members, and `companion object` members. + * A `val` in a plain class or interface body is per-instance or abstract and is + * NOT collected — the Kotlin analogue of Java's `static final` requirement. `var` + * is rejected outright. + * + * KEYS FOLLOW KOTLIN'S OWN VISIBILITY, not a flattened namespace. Every constant + * is recorded under `.`, the spelling a qualified reference + * uses, with a companion member keyed under its ENCLOSING CLASS (`Holder.NAME`) + * because that is how Kotlin source refers to it — `Companion` never appears in + * a reference. The SIMPLE name is recorded only for a TOP-LEVEL `val`, the one + * carrier whose bare binding really does span the file. A member of a named + * `object` gets no bare key, because `BASE` alone does not name `A.BASE` from + * anywhere outside `object A`'s own body. Writing one anyway (as this binding + * and the Java one both used to) fabricates a binding the language does not + * have, and a fabricated key outranks the genuine `import com.example.api.Paths.ORDERS` + * that {@link computeKotlinFold} consults only after literals and expressions. + * + * A COMPANION member gets no bare key either: it is bound unqualified inside + * its enclosing class body and nowhere else. {@link qualifyKotlinRefInEnclosingTypes} + * rewrites a bare name to `.` when an enclosing type + * declares it, so the companion wins inside its own class and loses everywhere + * else. + * + * An initializer that names a SIBLING is resolved the same way, against its own + * scope chain, innermost first, before the file level: inside + * `object A { const val BASE = "/right"; const val ROUTE = BASE + "/m" }` the + * operand `BASE` is rewritten to `A.BASE`. Collecting every declaration before + * recording any keeps that independent of declaration order. + * + * A TOP-LEVEL initializer has an EMPTY scope chain, so its bare operands stay + * bare and resolve at file level — they must not pick up a companion key. + * + * A non-foldable rebind (`X = compute()`) DROPS X to unresolvable rather than + * leaving a stale literal — and drops a same-named import with it whenever the + * declaration shadows that import ANYWHERE (top level, or a companion inside its + * class). The import map has no scopes, so a companion's shadow is applied + * file-wide: the conservative direction, costing a route rather than publishing + * the imported value at a reference the compiler binds to the unfoldable member. + * An `object` member shadows nothing and must leave the import alone. + */ +export function extractKotlinModuleConstants(tree: Parser.Tree): KotlinModuleConstants { + const literals = new Map(); + const exprs = new Map(); + const imports = new Map(); + const unfoldableDeclarations = new Set(); + + // Pass 1: imports. + const walkImports = (node: Parser.SyntaxNode): void => { + if (node.type === 'import_header') { + // `import a.b.*` binds no single name — nothing to key the fold on, and + // guessing which package member a bare reference came from is exactly the + // wrong answer. Skipped, so such a reference floors to skip. + const isWildcard = node.children.some((c) => c.type === 'wildcard_import'); + const identifier = node.children.find((c) => c.type === 'identifier'); + if (!isWildcard && identifier) { + const segments = identifier.namedChildren + .filter((c) => c.type === 'simple_identifier') + .map((c) => unquoteKotlinIdentifier(c.text)); + if (segments.length >= 2) { + const spec = segments.join('.'); + const originalName = segments[segments.length - 1]; + const aliasNode = node.children + .find((c) => c.type === 'import_alias') + ?.namedChildren.find((c) => c.type === 'type_identifier'); + const alias = aliasNode ? unquoteKotlinIdentifier(aliasNode.text) : undefined; + // `module` is the specifier AS WRITTEN, complete. Kotlin does not mark + // member imports, so the fold — not the extractor — decides whether the + // trailing segment is a declaration or one of its members. + imports.set(alias ?? originalName, { module: spec, originalName }); + } + } + return; + } + for (const child of node.children ?? []) walkImports(child); + }; + walkImports(tree.rootNode); + + // Pass 2a: collect every declaration, writing nothing yet. Which member each + // unqualified operand means depends on the whole file, so no key can be + // written — and no operand rewritten — until the last declaration is in. + const declarations: KotlinConstDeclaration[] = []; + /** Declaring scope → the simple names it declares, foldable or not. */ + const membersByScope = new Map>(); + + const collectProperties = ( + body: Parser.SyntaxNode, + declaringType: string | null, + scopes: readonly string[], + fileLevelName: boolean, + shadowsImport: boolean, + ): void => { + for (const member of body.children ?? []) { + if (member.type !== 'property_declaration') continue; + if (bindingKind(member) !== 'val') continue; + const declaration = member.children.find((c) => c.type === 'variable_declaration'); + const nameNode = declaration?.namedChildren.find((c) => c.type === 'simple_identifier'); + if (!nameNode) continue; + const name = unquoteKotlinIdentifier(nameNode.text); + if (declaringType !== null) { + let members = membersByScope.get(declaringType); + if (!members) membersByScope.set(declaringType, (members = new Set())); + // Recorded even when the initializer does not fold: a sibling reference + // to an unfoldable member must resolve to that member and then MISS, + // not fall through to a same-named constant at file level. + members.add(name); + } + declarations.push({ + name, + qualified: declaringType === null ? null : `${declaringType}.${name}`, + scopes, + fileLevelName, + shadowsImport, + operands: parseKotlinConstOperands(initializerOf(member)), + }); + } + }; + + const bodyOf = (node: Parser.SyntaxNode): Parser.SyntaxNode | undefined => + node.children.find((c) => c.type === 'class_body'); + + /** The declared name of an `object_declaration` / `class_declaration`. */ + const typeNameOf = (node: Parser.SyntaxNode): string | null => { + const ident = node.children.find((c) => c.type === 'type_identifier'); + return ident ? unquoteKotlinIdentifier(ident.text) : null; + }; + + /** Append one simple type name to its enclosing qualified type path. */ + const nestedTypeName = (enclosingType: string | null, name: string | null): string | null => { + if (name === null) return enclosingType; + return enclosingType === null ? name : `${enclosingType}.${name}`; + }; + + /** Prepend a qualified scope unless it is already the innermost scope. */ + const withScope = (scope: string | null, scopes: readonly string[]): readonly string[] => + scope === null || scopes[0] === scope ? scopes : [scope, ...scopes]; + + const walkDeclarations = ( + node: Parser.SyntaxNode, + enclosingType: string | null, + scopes: readonly string[], + ): void => { + for (const child of node.children ?? []) { + if (child.type === 'object_declaration') { + const name = typeNameOf(child); + const body = bodyOf(child); + if (!body) continue; + // Carry the full path: a nested object member is `Outer.Inner.NAME`, not + // `Inner.NAME`. Inside the body a bare name searches that qualified + // scope first, then each enclosing type. + const declaredType = nestedTypeName(enclosingType, name); + const inner = withScope(declaredType, scopes); + collectProperties(body, declaredType, inner, false, false); + walkDeclarations(body, declaredType, inner); + continue; + } + if (child.type === 'companion_object') { + const body = bodyOf(child); + if (!body) continue; + // Referenced through the enclosing class (`Holder.NAME`), never through + // `Companion` — so the qualified alias is keyed on `enclosingType`. The + // simple name is bound inside that class body only, which is a SCOPE and + // not a file-level key: it is reached from the reference site by + // `qualifyKotlinRefInEnclosingTypes`, through this same `Holder.NAME`. + const inner = withScope(enclosingType, scopes); + collectProperties(body, enclosingType, inner, false, true); + walkDeclarations(body, enclosingType, inner); + continue; + } + if (child.type === 'class_declaration') { + // A class/interface body's own `val`s are per-instance or abstract, so + // only its nested objects and companion contribute constants. + const name = typeNameOf(child); + const body = bodyOf(child); + const declaredType = nestedTypeName(enclosingType, name); + if (body) walkDeclarations(body, declaredType, withScope(declaredType, scopes)); + continue; + } + walkDeclarations(child, enclosingType, scopes); + } + }; + + collectProperties(tree.rootNode, null, [], true, true); + walkDeclarations(tree.rootNode, null, []); + + // Pass 2b: rewrite each initializer's unqualified operands against the scope + // chain that encloses it, then record. Only a top-level declaration writes a + // bare key, so nothing here can collide across scopes; a companion's + // unqualified binding is applied at the reference site instead. + // A PARTIALLY qualified reference is resolved here too, not just a bare one: + // inside `object Outer`, the initializer `Inner.Q + "/m"` names `Outer.Inner.Q`, + // and taking a dotted name as already complete looked up a key nothing + // declares. Split at the last dot and prefix the scope onto the OWNER, so the + // bare case (`ownerSuffix === null`) stays exactly what it was. + const qualifyRef = (refName: string, scopes: readonly string[]): string => { + const lastDot = refName.lastIndexOf('.'); + const ownerSuffix = lastDot < 0 ? null : refName.slice(0, lastDot); + const member = lastDot < 0 ? refName : refName.slice(lastDot + 1); + for (const scope of scopes) { + const declaringType = ownerSuffix === null ? scope : `${scope}.${ownerSuffix}`; + if (membersByScope.get(declaringType)?.has(member)) return `${declaringType}.${member}`; + } + return refName; // file level, or unresolvable — the fold decides + }; + + for (const decl of declarations) { + const keys: string[] = []; + if (decl.fileLevelName) keys.push(decl.name); + if (decl.qualified !== null) keys.push(decl.qualified); + + if (decl.operands === null) { + for (const key of keys) { + literals.delete(key); + exprs.delete(key); + unfoldableDeclarations.add(key); + } + if (decl.shadowsImport) imports.delete(decl.name); + continue; + } + + const operands = decl.operands.map((op) => + op.kind === 'ref' ? { kind: 'ref' as const, name: qualifyRef(op.name, decl.scopes) } : op, + ); + const literalValue = + operands.length === 1 && operands[0].kind === 'literal' ? operands[0].value : null; + for (const key of keys) { + unfoldableDeclarations.delete(key); + if (literalValue !== null) { + literals.set(key, literalValue); + exprs.delete(key); + } else { + exprs.set(key, operands); + literals.delete(key); + } + } + } + + return { + literals, + exprs, + imports, + packageName: declaredPackage(tree.rootNode), + unfoldableDeclarations, + }; +} + +/** + * Per-fold state. Mirrors {@link resolveJavaConstant}'s, for the same reasons: + * + * - `memo` caches SUCCESSES only and is never popped, so a shared-descendant + * DAG (`X_k = X_{k+1} + X_{k+1}`) folds in O(nodes) instead of O(2^depth). + * 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. + * - `index` carries the constant-defining key set and exact-package declaration + * buckets built by `prepareRepo`. A public one-shot call can still build it + * lazily, while production folds reuse it across every route in the scan. + * Import-only files stay out of the candidate set so they cannot manufacture + * ambiguity. + */ +interface KotlinFoldState { + readonly index: KotlinConstantIndex; + readonly visited: Set; + readonly memo: Map; +} + +function newFoldState(repo: RepoConstants, index?: KotlinConstantIndex): KotlinFoldState { + return { + index: index ?? buildKotlinConstantIndex(repo), + visited: new Set(), + memo: new Map(), + }; +} + +/** + * Resolve a single Kotlin constant referenced in `fileKey` to its literal string + * value, folding `+` concatenation and following import chains via + * {@link resolveKotlinImport}, or null when it cannot be fully folded. + * + * `name` may be simple (`ORDERS`, resolved via a single-name import or a + * same-file constant) or qualified (`ApiPaths.ORDERS`, resolved via the type + * import plus the target file's qualified alias). + */ +export function resolveKotlinConstant( + fileKey: string, + name: string, + repo: RepoConstants, + depth = 0, + index?: KotlinConstantIndex, +): string | null { + return resolveWithState(fileKey, name, newFoldState(repo, index), depth); +} + +function resolveWithState( + fileKey: string, + name: string, + state: KotlinFoldState, + depth: number, +): string | null { + if (depth > MAX_FOLD_DEPTH) 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 = computeKotlinFold(fileKey, name, state, depth); + if (result !== null) state.memo.set(guard, result); + return result; + } finally { + state.visited.delete(guard); + } +} + +/** + * Resolve a name bound by an import, trying both readings of the specifier. + * + * Kotlin writes a member import exactly like a type import, so + * `import com.example.app.api.ApiPaths.ORDERS` is syntactically + * indistinguishable from a type import of `ORDERS` in package + * `com.example.app.api.ApiPaths`. Rather than guess from casing — a convention, + * not a rule, and one that quietly breaks on `object apiPaths` or `const val + * Orders` — both readings are attempted and the first that actually RESOLVES + * wins. A reading that resolves to no constant simply falls through. + */ +function resolveImportedName( + fileKey: string, + imp: ImportBinding, + state: KotlinFoldState, + depth: number, +): string | null { + // Reading A: the specifier names the declaration itself (a top-level + // `const val`, or a type whose file we then search). + const direct = resolveKotlinImportTarget(imp.module, state.index); + if (direct !== null) { + const value = resolveWithState(direct.fileKey, direct.localName, state, depth); + if (value !== null) return value; + } + // Reading B: the specifier names a MEMBER of the declaration one segment up + // (`…ApiPaths.ORDERS` → member `ORDERS` of `ApiPaths`). + const dot = imp.module.lastIndexOf('.'); + if (dot <= 0) return null; + const ownerSpec = imp.module.slice(0, dot); + const owner = resolveKotlinImportTarget(ownerSpec, state.index); + if (owner === null) return null; + return resolveWithState(owner.fileKey, `${owner.localName}.${imp.originalName}`, state, depth); +} + +function computeKotlinFold( + fileKey: string, + name: string, + state: KotlinFoldState, + depth: number, +): string | null { + const { repo } = state.index; + // Qualified reference (`ApiPaths.ORDERS`): 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 type import, then look the + // member up in the target file under its declaring name. + // + // Unlike the Java binding there is NO bare-`tail` fallback: in Kotlin + // `Head.TAIL` means TAIL is a member of the object or companion `Head`, so a + // top-level `TAIL` in the target file is a different declaration and matching + // it would fabricate a value. + 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 target = resolveKotlinImportTarget(imp.module, state.index); + if (target === null) return null; + // `originalName` un-aliases `import … .ApiPaths as Paths`, so the lookup + // uses the declaring type's real name. + return resolveWithState(target.fileKey, `${target.localName}.${tail}`, state, depth + 1); + } + // Un-imported qualified name (FQN form `com.example.app.api.ApiPaths.ORDERS`): + // try the longest dotted prefix that resolves to a file. + const parts = name.split('.'); + for (let cut = parts.length - 2; cut >= 1; cut--) { + const fqn = parts.slice(0, cut + 1).join('.'); + const target = resolveKotlinImportTarget(fqn, state.index); + if (target !== null) { + const member = parts.slice(cut + 1).join('.'); + return resolveWithState(target.fileKey, `${target.localName}.${member}`, 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, so a same-file + // qualified reference (`ApiPaths.ORDERS` inside the file declaring + // `object ApiPaths`) resolves below. + } + + // Name lookup: literals, then same-file expressions, then the import chase. + // Expressions are folded HERE rather than handed to the agnostic core because + // an operand may itself be a QUALIFIED reference (`X = ApiPaths.Y + "/tail"`) + // and the core only knows bare names: it would look `ApiPaths.Y` up in maps + // keyed by simple name, miss, and floor the whole chain to null. + 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) return resolveImportedName(fileKey, imp, state, depth + 1); + return null; +} + +/** + * Concatenate an operand list, resolving each `ref` through the qualified-aware + * walk so `ApiPaths.BASE` 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. + */ +function foldOperands( + fileKey: string, + operands: readonly Operand[], + state: KotlinFoldState, + 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; +} + +/** + * Rewrite one BARE reference to the enclosing type that binds it, or leave it + * bare when none does — the reference-site twin of the `qualifyRef` that + * {@link extractKotlinModuleConstants} applies to sibling initializers. + * + * `enclosingTypes` is the chain of qualified type paths the reference sits + * inside, INNERMOST FIRST (`['Outer.Inner', 'Outer']`). A companion member is + * keyed `.` and is bound unqualified exactly within that + * class body — including its nested types, which is why the whole chain is + * walked and not just the innermost link. An `object`'s own members are in scope + * inside its body under the same `.` key, so the same walk covers + * both. + * + * Innermost-first, and BEFORE the file-level maps the fold consults next, is + * Kotlin's own order: a companion member shadows a same-named top-level + * declaration and a same-named import throughout its class. Outside that class + * the bare name never means the companion at all, which is precisely what an + * empty chain expresses. + */ +function qualifyKotlinRefInEnclosingTypes( + fileKey: string, + name: string, + repo: RepoConstants, + enclosingTypes: readonly string[], +): string { + // A dotted reference carries AN owner, not necessarily its OWN full one, so it + // is resolved against the enclosing scopes exactly like a bare name. Kotlin + // binds `Inner.Q` inside `object Outer` to `Outer.Inner.Q`, and returning it + // unchanged looked for a key nothing declares. Worse, when the partial owner + // also names a top-level declaration the unchanged form MATCHES it: with a + // top-level `object ApiPaths` beside a nested one, `@GetMapping(ApiPaths.ORDERS)` + // inside the class holding the nested object resolved to the top-level value — + // a path the application does not serve, where the compiler binds the nested + // one. The scopes are already qualified (`kotlinEnclosingTypeNames`), so + // prefixing them onto whatever the reference spells is the whole rule. + const mc = repo.get(fileKey); + if (!mc) return name; + const unfoldableDeclarations = unfoldableDeclarationsOf(mc); + for (const type of enclosingTypes) { + const key = `${type}.${name}`; + if (mc.literals.has(key) || mc.exprs.has(key) || unfoldableDeclarations.has(key)) { + return key; + } + } + return name; +} + +/** + * Fold an inline operand list (e.g. `ApiPaths.BASE + "/orders"`) against + * `fileKey`, or null when any piece is unresolvable (skip floor). + * + * `enclosingTypes` is the chain of type declarations the REFERENCE sits inside + * (innermost first), and it is applied to the entry operands only — everything + * deeper is either already qualified by + * {@link extractKotlinModuleConstants} against its own declaring scope, or lives + * in another file where this chain means nothing. Passing it empty answers + * "what does this name mean at file level", which is the right question for a + * reference outside any type and the only one a caller without position + * information can honestly ask. + * + * An empty result is a SUCCESS, not a skip. `const val ROOT = ""` folds to `""`, + * which `joinPath` then resolves against the class-level prefix exactly as it + * resolves the literal `@GetMapping("")` — both mean "the prefix itself", the + * Spring idiom for a collection root. Collapsing it into `null` would make a + * resolved-empty path indistinguishable from an unresolvable one — the skip + * floor is reserved for "could not fold", and nothing else in the resolver + * conflates the two: {@link resolveKotlinConstant} returns `''` for an empty + * constant, and `resolveOperands` in the shared core returns its fold + * unfiltered. Matches `foldJavaOperands`, so the two JVM bindings do not + * diverge on the same input. + */ +export function foldKotlinOperands( + fileKey: string, + operands: readonly Operand[], + repo: RepoConstants, + enclosingTypes: readonly string[] = [], + index?: KotlinConstantIndex, +): string | null { + // Allocation gate only: skip the map when there is nothing to qualify against + // or no reference to qualify. It must not restate the rule — a dotted operand + // is qualified too, so testing for a bare one here decided the result instead + // of merely avoiding an allocation, and did so per-OPERAND-LIST: the same + // `Inner.Q` folded or not depending on whether a SIBLING operand happened to + // be bare. + const needsQualify = enclosingTypes.length > 0 && operands.some((op) => op.kind === 'ref'); + const scoped = needsQualify + ? operands.map((op) => + op.kind === 'ref' + ? { + kind: 'ref' as const, + name: qualifyKotlinRefInEnclosingTypes(fileKey, op.name, repo, enclosingTypes), + } + : op, + ) + : operands; + return foldOperands(fileKey, scoped, newFoldState(repo, index), 0); +} diff --git a/gitnexus/test/unit/group/kotlin-const-route-fold.test.ts b/gitnexus/test/unit/group/kotlin-const-route-fold.test.ts new file mode 100644 index 000000000..93c44d6cf --- /dev/null +++ b/gitnexus/test/unit/group/kotlin-const-route-fold.test.ts @@ -0,0 +1,1265 @@ +/** + * Constant-valued Spring route paths on the Kotlin group plugin. + * + * Drives `KOTLIN_HTTP_PLUGIN.prepareRepo` + `scan(tree, ctx, rel)` with all + * three arguments, which is the shape the http-route-extractor orchestrator + * uses. The existing Kotlin guards call `scan(tree)` with ONE argument and are + * therefore structurally blind here: without a repo context the plugin has no + * constant map and drops every constant-valued route. + * + * Asserted: + * • the four reference forms fold to the right provider contract — qualified + * access, fully-qualified name, single-name import, `+`-concatenation; + * • a class prefix that resolves to NO literal suppresses every method route + * under that class, literal ones included (the prefix is not knowable here, + * and emitting the methods unprefixed would publish paths the application + * does not serve) — the rule `java.ts` already applies. Pinned across every + * spelling that reaches the suppression, because the analysis inverts a + * literalness test rather than listing node types: a bare constant, both + * argument spellings, `[…]`, `arrayOf(…)`, a call, an `if`, and an + * interpolated string; + * • a prefix that resolves only PARTLY still publishes its resolvable arm — + * Kotlin's vararg `@RequestMapping("/lit", ApiPaths.BASE)` keeps `/lit`, + * because suppression exists to avoid wrong routes, not to discard right + * ones; + * • a `@RequestMapping` with no path argument at all is not a prefix and does + * not suppress anything; + * • an OpenFeign consumer folds a constant method path, and both consumer + * lanes (`@(Get|…)Mapping` and `@RequestLine`) are suppressed by an + * unresolvable governing prefix for the same reason a provider is — + * resolved in "path wins" order, so a literal `@FeignClient(path)` rescues + * an interface whose `@RequestMapping` is a constant, and an unresolvable + * `path` is fatal on its own; + * • an unresolvable constant emits nothing rather than a guessed path; + * • a cross-file fold survives BACKSLASHED repository keys — the shape glob + * v13 hands the orchestrator on Windows, and the one every other fixture + * here misses by writing POSIX string literals; + * • a constant that folds to `""` publishes the class prefix, exactly as the + * literal `@GetMapping("")` beside it does — an empty fold is a success, + * not the skip floor; + * • without a repo context the plugin emits nothing (the documented skip + * floor, and the branch the 1-argument guards cannot reach); + * • literal routes are untouched and are not emitted twice. + */ + +import { describe, expect, it } from 'vitest'; +import Parser from 'tree-sitter'; +import { requireVendoredGrammar } from '../../../src/core/tree-sitter/vendored-grammars.js'; +import { KOTLIN_HTTP_PLUGIN } from '../../../src/core/group/extractors/http-patterns/kotlin.js'; +import type { HttpLanguagePlugin } from '../../../src/core/group/extractors/http-patterns/types.js'; + +// Vendored grammar — loaded from vendor/ by absolute path, never node_modules (#2111). +let Kotlin: unknown; +try { + Kotlin = requireVendoredGrammar('tree-sitter-kotlin'); +} catch { + // Optional grammar; the suite skips when its native binding is unavailable. +} + +const describeKotlin = Kotlin && KOTLIN_HTTP_PLUGIN ? describe : describe.skip; +// Non-null only inside `describeKotlin`, which is skipped when the plugin is null. +const plugin = KOTLIN_HTTP_PLUGIN as HttpLanguagePlugin; + +const parseSource = (p: Parser, src: string): Parser.Tree => { + p.setLanguage(Kotlin as Parser.Language); + return p.parse(src); +}; + +/** prepareRepo + a 3-argument scan over every file; contracts of one role. */ +function contracts(files: Record, role: 'provider' | 'consumer'): string[] { + const ctx = plugin.prepareRepo?.({ + repoPath: '/virtual', + files: Object.keys(files), + parser: new Parser(), + readFile: (rel: string) => files[rel] ?? null, + parseSource, + }); + const out: string[] = []; + for (const rel of Object.keys(files)) { + for (const d of plugin.scan(parseSource(new Parser(), files[rel]), ctx, rel)) { + if (d.role === role) out.push(`${d.method} ${d.path}`); + } + } + return out.sort(); +} + +const providers = (files: Record): string[] => contracts(files, 'provider'); +const consumers = (files: Record): string[] => contracts(files, 'consumer'); + +const CONSTS = 'src/main/kotlin/com/example/app/api/ApiPaths.kt'; +const CONTROLLER = 'src/main/kotlin/com/example/app/web/OrderController.kt'; +const CLIENT = 'src/main/kotlin/com/example/app/client/OrderClient.kt'; + +const CONSTS_SRC = `package com.example.app.api + +object ApiPaths { + const val BASE = "/api/v1" + const val ORDERS = BASE + "/orders" +} +`; + +describeKotlin('Kotlin constant-valued Spring routes (group plugin)', () => { + it('folds a qualified reference in a positional argument', () => { + expect( + providers({ + [CONSTS]: CONSTS_SRC, + [CONTROLLER]: `package com.example.app.web + +import com.example.app.api.ApiPaths + +@RestController +class OrderController { + @GetMapping(ApiPaths.ORDERS) + fun list() {} +} +`, + }), + ).toEqual(['GET /api/v1/orders']); + }); + + it('folds a standalone top-level val from another file', () => { + expect( + providers({ + [CONSTS]: `package com.example.app.api + +val ORDERS = "/api/v1/orders" +`, + [CONTROLLER]: `package com.example.app.web + +import com.example.app.api.ORDERS + +@RestController +class OrderController { + @GetMapping(ORDERS) + fun list() {} +} +`, + }), + ).toEqual(['GET /api/v1/orders']); + }); + + it('folds a constant imported through a nested object', () => { + expect( + providers({ + [CONSTS]: `package com.example.app.api + +object Outer { + object Inner { + const val ORDERS = "/nested/orders" + } +} +`, + [CONTROLLER]: `package com.example.app.web + +import com.example.app.api.Outer.Inner + +@RestController +class OrderController { + @GetMapping(Inner.ORDERS) + fun list() {} +} +`, + }), + ).toEqual(['GET /nested/orders']); + }); + + it('folds a named `value =` / `path =` argument and an inline concatenation', () => { + expect( + providers({ + [CONSTS]: CONSTS_SRC, + [CONTROLLER]: `package com.example.app.web + +import com.example.app.api.ApiPaths + +@RestController +class OrderController { + @PostMapping(value = ApiPaths.BASE + "/orders/create") + fun create() {} + + @DeleteMapping(path = ApiPaths.ORDERS) + fun remove() {} +} +`, + }), + ).toEqual(['DELETE /api/v1/orders', 'POST /api/v1/orders/create']); + }); + + it('folds a fully-qualified reference and a single-name import', () => { + expect( + providers({ + [CONSTS]: CONSTS_SRC, + [CONTROLLER]: `package com.example.app.web + +import com.example.app.api.ApiPaths.ORDERS + +@RestController +class OrderController { + @GetMapping(ORDERS) + fun list() {} + + @PutMapping(com.example.app.api.ApiPaths.ORDERS) + fun replace() {} +} +`, + }), + ).toEqual(['GET /api/v1/orders', 'PUT /api/v1/orders']); + }); + + it('ignores a non-route named argument', () => { + expect( + providers({ + [CONSTS]: CONSTS_SRC, + [CONTROLLER]: `package com.example.app.web + +import com.example.app.api.ApiPaths + +@RestController +class OrderController { + @GetMapping(path = ApiPaths.ORDERS, produces = [MediaType.APPLICATION_JSON_VALUE]) + fun list() {} +} +`, + }), + ).toEqual(['GET /api/v1/orders']); + }); + + it('suppresses every method route under a CONSTANT class prefix', () => { + expect( + providers({ + [CONSTS]: CONSTS_SRC, + [CONTROLLER]: `package com.example.app.web + +import com.example.app.api.ApiPaths + +@RestController +@RequestMapping(ApiPaths.BASE) +class OrderController { + @GetMapping(ApiPaths.ORDERS) + fun list() {} + + @GetMapping("/literal") + fun literal() {} +} +`, + }), + ).toEqual([]); + }); + + it('suppresses them just the same when the class prefix is a NAMED argument', () => { + // `@RequestMapping(value = ApiPaths.BASE)` takes the other branch of + // `kotlinRouteArgumentExpression` (read the key, then take `namedChild(1)`) + // than the positional case above. Both must reach the same verdict: a + // regression in the named branch would let the class escape suppression and + // publish every method under it unprefixed. + expect( + providers({ + [CONSTS]: CONSTS_SRC, + [CONTROLLER]: `package com.example.app.web + +import com.example.app.api.ApiPaths + +@RestController +@RequestMapping(value = ApiPaths.BASE) +class OrderController { + @GetMapping(ApiPaths.ORDERS) + fun list() {} + + @GetMapping("/literal") + fun literal() {} +} +`, + }), + ).toEqual([]); + }); + + /** + * A controller carrying `prefix` as its class-level `@RequestMapping`, with + * one constant-valued and one literal route under it. `decls` holds any + * top-level declaration the prefix expression refers to. + */ + const controllerWithPrefix = (prefix: string, decls = ''): Record => ({ + [CONSTS]: CONSTS_SRC, + [CONTROLLER]: `package com.example.app.web + +import com.example.app.api.ApiPaths +${decls} +@RestController +@RequestMapping(${prefix}) +class OrderController { + @GetMapping(ApiPaths.ORDERS) + fun list() {} + + @GetMapping("/literal") + fun literal() {} +} +`, + }); + + // Every prefix spelling that resolves to no literal, and so must suppress. + // This is a table rather than one representative case on purpose: the two + // tests above pin a BARE constant, which any node-type allow-list would also + // catch. These are the shapes such a list forgets — and forgetting one does + // not degrade to "no route", it publishes every method of the class at its + // UNPREFIXED path, which the application does not serve. The `if` and the + // interpolated string are the two that need no constant map at all to go + // wrong, and the `[…]` / `arrayOf(…)` pair matters because the literal + // prefix patterns DO reach inside both — so a naive "is it a literal + // container?" test would pass them straight through. + it.each([ + ['a collection literal holding a constant', '[ApiPaths.BASE]', ''], + ['an arrayOf(…) holding a constant', 'arrayOf(ApiPaths.BASE)', ''], + ['a named collection literal holding a constant', 'value = [ApiPaths.BASE]', ''], + ['a function call', 'buildPath()', '\nfun buildPath(): String = ApiPaths.BASE\n'], + ['an interpolated string', '"${ApiPaths.BASE}"', ''], + ['an if expression', 'if (USE_V2) "/api/v2" else "/api/v1"', '\nconst val USE_V2 = false\n'], + ])('suppresses every method route under a class prefix that is %s', (_label, prefix, decls) => { + expect(providers(controllerWithPrefix(prefix, decls))).toEqual([]); + }); + + it('keeps both routes when that same class prefix is a plain literal', () => { + // The control for the table above: same two methods, same helper, a prefix + // the extractor can resolve. Without it an empty result there would be + // indistinguishable from the fixture failing to produce routes at all. + expect(providers(controllerWithPrefix('"/api"'))).toEqual([ + 'GET /api/api/v1/orders', + 'GET /api/literal', + ]); + }); + + it('keeps the resolvable arm of a PARTLY resolvable class prefix', () => { + // Kotlin's vararg spelling. `/lit` is a real prefix the application really + // serves, so the routes under it are derivable and must survive; only the + // `ApiPaths.BASE` arm is missing from the result, exactly as it was before + // constant folding existed. Marking the class unfoldable here would trade a + // wrong route for a missing one, which is not the bargain suppression makes. + expect(providers(controllerWithPrefix('"/lit", ApiPaths.BASE'))).toEqual([ + 'GET /lit/api/v1/orders', + 'GET /lit/literal', + ]); + // Same shape spelled as one collection argument. + expect(providers(controllerWithPrefix('["/lit", ApiPaths.BASE]'))).toEqual([ + 'GET /lit/api/v1/orders', + 'GET /lit/literal', + ]); + }); + + it('does not treat a @RequestMapping without a path argument as a prefix', () => { + // `produces` is not a path, so this class has no prefix — not an + // unresolvable one. Suppressing here would drop routes that are correct and + // complete as written. + expect( + providers({ + [CONSTS]: CONSTS_SRC, + [CONTROLLER]: `package com.example.app.web + +import com.example.app.api.ApiPaths + +@RestController +@RequestMapping(produces = [MediaType.APPLICATION_JSON_VALUE]) +class OrderController { + @GetMapping(ApiPaths.ORDERS) + fun list() {} +} +`, + }), + ).toEqual(['GET /api/v1/orders']); + }); + + it('does not treat an EMPTY class path array as an unresolvable prefix', () => { + // `@RequestMapping(arrayOf())` designates NO prefix — Spring maps the class + // at the application root — so `/literal` really is served at `/literal`. + // Treating it as an unresolvable prefix suppressed every route under the + // class, the literal one included, which no constant fold was ever involved + // in. The arithmetic behind that: an empty array has no elements, and "no + // element is a literal" is trivially true of an empty set, so the class read + // as unresolvable. "No prefix" is not "an unresolvable prefix". + expect(providers(controllerWithPrefix('arrayOf()'))).toEqual([ + 'GET /api/v1/orders', + 'GET /literal', + ]); + }); + + it('keeps an inherited route under an EMPTY interface path array', () => { + const files = { + 'src/OrderApi.kt': `package com.example.app + +@RequestMapping([]) +interface OrderApi { + @GetMapping("/orders") + fun list() +} +`, + 'src/OrderController.kt': `package com.example.app + +@RestController +class OrderController : OrderApi { + override fun list() {} +} +`, + }; + const detections = + plugin.scanProject?.( + Object.entries(files).map(([filePath, source]) => ({ + filePath, + tree: parseSource(new Parser(), source), + })), + ) ?? []; + expect( + detections.flatMap((file) => + file.detections + .filter((detection) => detection.role === 'provider') + .map((detection) => `${detection.method} ${detection.path}`), + ), + ).toEqual(['GET /orders']); + }); + + it('still suppresses a NON-empty array whose only element is a constant', () => { + // The control for the empty `arrayOf()` case: an array that DOES designate + // a prefix still suppresses when that prefix is unknowable. + expect(providers(controllerWithPrefix('arrayOf(ApiPaths.BASE)'))).toEqual([]); + }); + + it('does not treat an EMPTY @FeignClient(path) array as an unresolvable path', () => { + // Same distinction on the consumer side, where the governing-prefix guard is + // its own set: `path = arrayOf()` adds no prefix, so the remote URL is + // exactly the method's own path and the consumer is knowable. + expect( + consumers({ + [CLIENT]: `package com.example.app.client + +@FeignClient(name = "orders", path = arrayOf()) +interface OrderClient { + @GetMapping("/orders") + fun listOrders(): String +} +`, + }), + ).toEqual(['GET /orders']); + }); + + it('still applies a LITERAL class prefix to a folded method path', () => { + expect( + providers({ + [CONSTS]: CONSTS_SRC, + [CONTROLLER]: `package com.example.app.web + +import com.example.app.api.ApiPaths + +@RestController +@RequestMapping("/api/v1") +class OrderController { + @GetMapping(ApiPaths.ORDERS) + fun list() {} +} +`, + }), + ).toEqual(['GET /api/v1/api/v1/orders']); + }); + + it('emits nothing when the constant cannot be resolved', () => { + expect( + providers({ + [CONTROLLER]: `package com.example.app.web + +import com.example.app.api.ApiPaths + +@RestController +class OrderController { + @GetMapping(ApiPaths.ORDERS) + fun list() {} +} +`, + }), + ).toEqual([]); + }); + + it('emits nothing for a constant route scanned without a repo context', () => { + // This is the branch the 1-argument guards cannot reach; pin it so it is + // not silently dead in the suite. + const tree = parseSource( + new Parser(), + `package com.example.app.web + +import com.example.app.api.ApiPaths + +@RestController +class OrderController { + @GetMapping(ApiPaths.ORDERS) + fun list() {} +} +`, + ); + expect(plugin.scan(tree).filter((d) => d.role === 'provider')).toEqual([]); + }); + + it('folds a constant method path on a @FeignClient interface', () => { + expect( + consumers({ + [CONSTS]: CONSTS_SRC, + [CLIENT]: `package com.example.app.client + +import com.example.app.api.ApiPaths + +@FeignClient(name = "orders") +interface OrderClient { + @GetMapping(ApiPaths.ORDERS) + fun list() +} +`, + }), + ).toEqual(['GET /api/v1/orders']); + }); + + it('drops a @FeignClient consumer whose interface prefix is a CONSTANT', () => { + // In tree-sitter-kotlin an `interface` is a `class_declaration`, so the + // suppression rule reaches a Feign interface too — and it must, for the same + // reason it reaches a controller: the prefix is not knowable here, so the + // alternative is publishing the remote call at `/orders` when the service is + // really called at `/api/v1/orders`. A dropped consumer edge is a missing + // fact; a wrong URL is a false edge. + const files = { + [CONSTS]: CONSTS_SRC, + [CLIENT]: `package com.example.app.client + +import com.example.app.api.ApiPaths + +@FeignClient(name = "orders") +@RequestMapping(ApiPaths.BASE) +interface OrderClient { + @GetMapping("/orders") + fun list() +} +`, + }; + expect(consumers(files)).toEqual([]); + // Control: the identical interface with a LITERAL prefix is still detected, + // so the empty result above is the suppression rule and not a blind spot in + // Feign detection itself. + expect( + consumers({ + ...files, + [CLIENT]: files[CLIENT].replace( + '@RequestMapping(ApiPaths.BASE)', + '@RequestMapping("/api/v1")', + ), + }), + ).toEqual(['GET /api/v1/orders']); + }); + + it('drops a @FeignClient consumer whose `path` argument is a CONSTANT', () => { + // `path` is the Feign client's own prefix and is never a `@RequestMapping`, + // so the class-prefix analysis cannot see it. Left unchecked, this interface + // falls through to the no-prefix fallback and publishes a remote call to + // `/api/v1/orders` as a call to `/orders` — a consumer edge pointing at a + // route no service serves. + const files = { + [CONSTS]: CONSTS_SRC, + [CLIENT]: `package com.example.app.client + +import com.example.app.api.ApiPaths + +@FeignClient(name = "orders", path = ApiPaths.BASE) +interface OrderClient { + @GetMapping(ApiPaths.ORDERS) + fun list() +} +`, + }; + expect(consumers(files)).toEqual([]); + // Control: the same interface with a LITERAL `path` is still detected. + expect( + consumers({ + ...files, + [CLIENT]: files[CLIENT].replace('path = ApiPaths.BASE', 'path = "/svc"'), + }), + ).toEqual(['GET /svc/api/v1/orders']); + }); + + it('lets a literal @FeignClient(path) outrank a CONSTANT @RequestMapping', () => { + // `path` wins over `@RequestMapping` when the URL is assembled, so it has to + // win when resolvability is judged too — otherwise an interface whose real + // prefix is perfectly knowable loses its consumer to a `@RequestMapping` + // that never governed it. + expect( + consumers({ + [CONSTS]: CONSTS_SRC, + [CLIENT]: `package com.example.app.client + +import com.example.app.api.ApiPaths + +@FeignClient(name = "orders", path = "/svc") +@RequestMapping(ApiPaths.BASE) +interface OrderClient { + @GetMapping("/orders") + fun list() +} +`, + }), + ).toEqual(['GET /svc/orders']); + }); + + it('drops a @RequestLine consumer under an unresolvable interface prefix', () => { + // `@RequestLine` carries its own verb and path but is still prefixed by the + // interface, and it resolves through the same "path wins" fallback chain as + // the `@(Get|…)Mapping` lane — so an unresolvable governing prefix leaves + // the remote URL just as unknowable here. + const files = { + [CONSTS]: CONSTS_SRC, + [CLIENT]: `package com.example.app.client + +import com.example.app.api.ApiPaths + +@FeignClient(name = "orders") +@RequestMapping(ApiPaths.BASE) +interface OrderClient { + @RequestLine("GET /list") + fun list() +} +`, + }; + expect(consumers(files)).toEqual([]); + // Control: a literal interface prefix still yields the prefixed consumer. + expect( + consumers({ + ...files, + [CLIENT]: files[CLIENT].replace( + '@RequestMapping(ApiPaths.BASE)', + '@RequestMapping("/lit")', + ), + }), + ).toEqual(['GET /lit/list']); + }); + + it('judges @RequestLine and @(Get|…)Mapping alike on ONE interface', () => { + // Both lanes read the same prefix through the same fallback chain, so they + // must reach the same verdict on it. A guard on only one of them lets the + // interface suppress one route and publish the other under the very same + // unresolvable prefix — a self-inconsistency visible in a single scan. + expect( + consumers({ + [CONSTS]: CONSTS_SRC, + [CLIENT]: `package com.example.app.client + +import com.example.app.api.ApiPaths + +@FeignClient(name = "orders") +@RequestMapping(ApiPaths.BASE) +interface OrderClient { + @GetMapping(ApiPaths.ORDERS) + fun list() + + @RequestLine("GET /list") + fun listLegacy() +} +`, + }), + ).toEqual([]); + }); + + it('folds across files when repository keys use Windows separators', () => { + // The orchestrator's file list comes from glob v13, which has no + // `posix: true` and joins with the platform separator, so on Windows both + // `prepareRepo({files})` and `scan(tree, ctx, rel)` see + // `src\main\kotlin\…`. `resolveKotlinImport` asks whether a key ends with + // `com/example/app/api/ApiPaths.kt` — a test no backslashed key can pass — + // so EVERY cross-file fold returned null on Windows and on Windows only: + // the pre-pass still ran and the context was still built, the feature was + // just silently absent. Every other fixture in this file is a POSIX string + // literal, which is exactly why CI stayed green. + // + // The keys are backslashed HERE rather than derived from `path.sep`, so the + // regression is pinned on every runner instead of only on the Windows + // matrix — the plugin reads keys, not the host OS, so simulating the keys + // simulates the whole bug. + const winKey = (rel: string): string => rel.replace(/\//g, '\\'); + expect( + providers({ + [winKey(CONSTS)]: CONSTS_SRC, + [winKey(CONTROLLER)]: `package com.example.app.web + +import com.example.app.api.ApiPaths + +@RestController +class OrderController { + @GetMapping(ApiPaths.ORDERS) + fun list() {} +} +`, + }), + ).toEqual(['GET /api/v1/orders']); + }); + + it('treats a constant that folds to "" as the class prefix itself', () => { + // `const val ROOT = ""` is Spring's idiom for "the collection root", and + // `joinPath` resolves it against the class prefix exactly as it resolves the + // literal `@PostMapping("")` beside it. The fold used to collapse `''` into + // the skip floor, so the two annotations below — the same path, written two + // ways — disagreed: the literal published `POST /api`, the constant + // published nothing. Asserting BOTH in one class is the point; a test on the + // constant alone would pass against any chosen convention rather than + // pinning the two spellings together. + expect( + providers({ + [CONSTS]: `package com.example.app.api + +object ApiPaths { + const val ROOT = "" +} +`, + [CONTROLLER]: `package com.example.app.web + +import com.example.app.api.ApiPaths + +@RestController +@RequestMapping("/api") +class OrderController { + @GetMapping(ApiPaths.ROOT) + fun list() {} + + @PostMapping("") + fun create() {} +} +`, + }), + ).toEqual(['GET /api/', 'POST /api/']); + }); + + it('serves the same route whichever of two same-named objects is declared first', () => { + // `A.ROUTE = BASE + "/m"` means `A.BASE`. Recording every object member + // under its bare name too made that operand resolve through whichever + // same-named sibling was walked LAST, so reordering two objects — a change + // Kotlin does not even see — moved the published route from `/right/m` to + // `/wrong/m`. Both orders are asserted; either alone passes on a last-wins + // implementation. + const controllerWith = (objects: string): string => `package com.example.app.web + +${objects} + +@RestController +class OrderController { + @GetMapping(A.ROUTE) + fun get() {} +} +`; + const A = `object A { + const val BASE = "/right" + const val ROUTE = BASE + "/m" +}`; + const B = `object B { + const val BASE = "/wrong" +}`; + expect(providers({ [CONTROLLER]: controllerWith(`${A}\n\n${B}`) })).toEqual(['GET /right/m']); + expect(providers({ [CONTROLLER]: controllerWith(`${B}\n\n${A}`) })).toEqual(['GET /right/m']); + }); + + it('reads a bare route constant from the import, not from a local object member', () => { + // Bare `ORDERS` in this file is the IMPORT: `object Local` binds + // `Local.ORDERS` and nothing else. A bare key for the object member is a + // binding Kotlin does not have, and it outranked the import because the fold + // consults literals before imports — publishing a path the service does not + // serve. + expect( + providers({ + [CONSTS]: `package com.example.app.api + +object ApiPaths { + const val ORDERS = "/api/v1/orders" +} +`, + [CONTROLLER]: `package com.example.app.web + +import com.example.app.api.ApiPaths.ORDERS + +object Local { + const val ORDERS = "/local" +} + +@RestController +class OrderController { + @GetMapping(ORDERS) + fun list() {} +} +`, + }), + ).toEqual(['GET /api/v1/orders']); + }); + + it('keeps a companion constant readable under its bare name', () => { + // The control for the test above, and the reason object members and + // companion members are keyed differently: a companion's members ARE in + // scope unqualified throughout the enclosing class, which is precisely where + // route annotations sit. + expect( + providers({ + [CONTROLLER]: `package com.example.app.web + +@RestController +class OrderController { + companion object { + const val ORDERS = "/api/v1/orders" + } + + @GetMapping(ORDERS) + fun list() {} +} +`, + }), + ).toEqual(['GET /api/v1/orders']); + }); + + it('resolves a bare companion constant inside a nested class', () => { + // Declaration extraction and reference-site qualification must agree on + // the full owner path. `ORDERS` here means `Outer.Inner.ORDERS`, not the + // nonexistent top-level `Inner.ORDERS`. + expect( + providers({ + [CONTROLLER]: `package com.example.app.web + +@RestController +class Outer { + class Inner { + companion object { + const val ORDERS = "/nested/orders" + } + + @GetMapping(ORDERS) + fun list() {} + } +} +`, + }), + ).toEqual(['GET /nested/orders']); + }); + + it('folds through the file that declares the package, not one whose path imitates it', () => { + // The decoy's PATH ends with the imported FQN, but it declares + // `package x.com.example.app.api` — a different declaration. Choosing the + // candidate by path let it win, and because it declares the same member the + // fold did not skip: it published `/wrong`. The declared `package` is the + // authority; the path is only a tie-break among files that already declare + // the right one. + expect( + providers({ + 'src/generated/Constants.kt': `package com.example.app.api + +object ApiPaths { + const val ORDERS = "/api/v1/orders" +} +`, + 'src/x/com/example/app/api/ApiPaths.kt': `package x.com.example.app.api + +object ApiPaths { + const val ORDERS = "/wrong" +} +`, + [CONTROLLER]: `package com.example.app.web + +import com.example.app.api.ApiPaths + +@RestController +class OrderController { + @GetMapping(ApiPaths.ORDERS) + fun list() {} +} +`, + }), + ).toEqual(['GET /api/v1/orders']); + }); + + it('emits nothing when a test-source copy duplicates a production constant', () => { + // Same package, same object, different value, and only the copy follows the + // `/.kt` convention — so a file-name tie-break folded a + // test-only path into a production route. Two declarations of one + // fully-qualified name identify no single declaration, so the honest answer + // is no route: preferring the production source set would be a guess about + // build configuration this layer cannot see. + expect( + providers({ + 'src/main/kotlin/generated/RoutePaths.kt': `package com.example.app.api + +object ApiPaths { + const val ORDERS = "/api/v1/orders" +} +`, + 'src/test/kotlin/com/example/app/api/ApiPaths.kt': `package com.example.app.api + +object ApiPaths { + const val ORDERS = "/test-only" +} +`, + [CONTROLLER]: `package com.example.app.web + +import com.example.app.api.ApiPaths + +@RestController +class OrderController { + @GetMapping(ApiPaths.ORDERS) + fun list() {} +} +`, + }), + ).toEqual([]); + }); + + it('keeps an unfoldable production twin in duplicate-FQN detection', () => { + expect( + providers({ + 'src/main/kotlin/generated/RoutePaths.kt': `package com.example.app.api + +object ApiPaths { + const val ORDERS = ("/production") +} +`, + 'src/test/kotlin/com/example/app/api/ApiPaths.kt': `package com.example.app.api + +object ApiPaths { + const val ORDERS = "/test-only" +} +`, + [CONTROLLER]: `package com.example.app.web + +import com.example.app.api.ApiPaths + +@RestController +class OrderController { + @GetMapping(ApiPaths.ORDERS) + fun list() {} +} +`, + }), + ).toEqual([]); + }); + + it('resolves a bare reference by the class it sits in, not by declaration order', () => { + // A companion member is bound unqualified inside its enclosing class BODY + // and nowhere else. Recorded under a file-level bare key it landed in the + // same namespace as top-level declarations and, because companions are + // walked last, won every unqualified reference in the file — so the + // reference in `OrderController` below, which is not `Holder`'s body, + // published `/companion` where the application serves `/top`. + // + // Both classes are asserted from ONE file: a fixture with only the wrong + // one would also pass on an implementation that simply dropped companions, + // and a fixture with only the right one would pass on the old file-wide key. + expect( + providers({ + [CONTROLLER]: `package com.example.app.web + +const val ORDERS = "/top" + +class Holder { + companion object { + const val ORDERS = "/companion" + } + + @GetMapping(ORDERS) + fun inside() {} +} + +@RestController +class OrderController { + @GetMapping(ORDERS) + fun outside() {} +} +`, + }), + ).toEqual(['GET /companion', 'GET /top']); + }); + + it('does not publish a top-level route for an unfoldable companion binding', () => { + expect( + providers({ + [CONTROLLER]: `package com.example.app.web + +const val ORDERS = "/top" + +@RestController +class Holder { + companion object { + const val ORDERS = ("/companion") + } + + @GetMapping(ORDERS) + fun inside() {} +} + +@RestController +class OtherController { + @GetMapping(ORDERS) + fun outside() {} +} +`, + }), + ).toEqual(['GET /top']); + }); + + it('gives two colliding companions each their own class', () => { + // Kotlin scopes each companion's members to its own class, so the two + // references below mean different constants even though they are spelled + // identically. One file-level namespace could only answer last-wins: both + // read `/h2`, and swapping the two classes flipped both to `/h1`. + expect( + providers({ + [CONTROLLER]: `package com.example.app.web + +@RestController +class FirstController { + companion object { + const val ORDERS = "/h1" + } + + @GetMapping(ORDERS) + fun list() {} +} + +@RestController +class SecondController { + companion object { + const val ORDERS = "/h2" + } + + @GetMapping(ORDERS) + fun list() {} +} +`, + }), + ).toEqual(['GET /h1', 'GET /h2']); + }); + + it('folds a top-level initializer at file level even when a companion shares the name', () => { + // `ROUTE`'s initializer is TOP-LEVEL, so its scope chain is empty and its + // operand `BASE` means the top-level `BASE`. With a file-wide companion key + // the empty chain left the operand bare and the companion answered it, + // publishing `/comp/m` where the application serves `/top/m`. + expect( + providers({ + [CONTROLLER]: `package com.example.app.web + +const val BASE = "/top" +const val ROUTE = BASE + "/m" + +class Holder { + companion object { + const val BASE = "/comp" + } +} + +@RestController +class OrderController { + @GetMapping(ROUTE) + fun list() {} +} +`, + }), + ).toEqual(['GET /top/m']); + }); + + it('folds through a package whose segment is backtick-quoted on one side only', () => { + // `` package com.example.app.`api` `` and `package com.example.app.api` are + // the same package to the compiler — the quotes are lexical syntax, not part + // of the name. Comparing the two verbatim rejected the one real candidate + // and dropped the route. Both directions are asserted because either side + // can carry the quotes. + const controller = (spec: string): string => `package com.example.app.web + +import ${spec} + +@RestController +class OrderController { + @GetMapping(ApiPaths.ORDERS) + fun list() {} +} +`; + const quotedDeclaration = `package com.example.app.\`api\` + +object ApiPaths { + const val ORDERS = "/api/v1/orders" +} +`; + // Declaration quoted, import plain. + expect( + providers({ + [CONSTS]: quotedDeclaration, + [CONTROLLER]: controller('com.example.app.api.ApiPaths'), + }), + ).toEqual(['GET /api/v1/orders']); + // Import quoted, declaration plain. + expect( + providers({ + [CONSTS]: CONSTS_SRC, + [CONTROLLER]: controller('com.example.app.`api`.ApiPaths'), + }), + ).toEqual(['GET /api/v1/orders']); + }); + + it('folds a same-file top-level `val` even when the file imports nothing', () => { + // Three conditions have to line up for this to break, which is why a + // realistic controller never hit it: the constant is a top-level non-`const` + // `val` (so `isKotlinConstantFile` rejects the file and the pre-pass never + // indexes it), the file declares no `object` (the gate's other arm), and it + // imports nothing. The on-demand overlay in `scan` is the file's only + // remaining chance, and it used to admit the extraction only when the file + // had imports — throwing away the very constants the route needs. + expect( + providers({ + [CONTROLLER]: `package com.example.app.web + +val PATH = "/orders" + +@RestController +@RequestMapping("/api/v1") +class OrderController { + @GetMapping(PATH) + fun list() {} +} +`, + }), + ).toEqual(['GET /api/v1/orders']); + }); + + it('folds a backtick-quoted constant declared in its own file', () => { + // The gate decides whether a file is parsed into the repo map at all, so a + // gate that rejects backticks silently drops a constant the resolver can + // fold — a cross-file reference to it then floors to skip. + expect( + providers({ + [CONSTS]: `package com.example.app.api + +object ApiPaths { + const val \`ORDERS\` = "/api/v1/orders" +} +`, + [CONTROLLER]: `package com.example.app.web + +import com.example.app.api.ApiPaths + +@RestController +class OrderController { + @GetMapping(ApiPaths.ORDERS) + fun list() {} +} +`, + }), + ).toEqual(['GET /api/v1/orders']); + }); + + it('resolves a PARTIALLY qualified reference against the enclosing scopes', () => { + // `Inner.Q` carries an owner, but not its whole one: the key is + // `Outer.Inner.Q`. Treating any dotted reference as already complete looked + // for a key nothing declares and dropped the route. + expect( + providers({ + [CONTROLLER]: `package com.example.app.web + +@RestController +object Outer { + object Inner { + const val Q = "/orders" + } + + @GetMapping(Inner.Q) + fun list() {} +} +`, + }), + ).toEqual(['GET /orders']); + }); + + it('binds a partially qualified reference to the NESTED owner, not a top-level twin', () => { + // The severe half of the same defect. Kotlin binds `ApiPaths` to the nested + // object, so the route is `/inner`. Left unqualified, `ApiPaths.ORDERS` + // matched the TOP-LEVEL object instead and published `/orders` — a path the + // application does not serve, which is worse than the dropped route above. + expect( + providers({ + [CONTROLLER]: `package com.example.app.web + +object ApiPaths { + const val ORDERS = "/orders" +} + +@RestController +class OrderController { + object ApiPaths { + const val ORDERS = "/inner" + } + + @GetMapping(ApiPaths.ORDERS) + fun list() {} +} +`, + }), + ).toEqual(['GET /inner']); + }); + + it('resolves a partially qualified reference inside an INITIALIZER too', () => { + // The same rule on the other side. `ROUTE = Inner.Q + "/m"` sits in + // `Outer`'s body, so `Inner.Q` means `Outer.Inner.Q` there. Fixing only the + // reference site would leave the two halves disagreeing about what a dotted + // name means — the asymmetry that produced the earlier defects here. + expect( + providers({ + [CONTROLLER]: `package com.example.app.web + +object Outer { + object Inner { + const val Q = "/orders" + } + + const val ROUTE = Inner.Q + "/m" +} + +@RestController +class OrderController { + @GetMapping(Outer.ROUTE) + fun list() {} +} +`, + }), + ).toEqual(['GET /orders/m']); + }); + + it('folds a partially qualified reference regardless of its sibling operands', () => { + // Control for the allocation gate: it must not decide the result. Gating on + // "some operand is bare" made this same `Inner.Q` fold only because `SUFFIX` + // sits beside it, while `Inner.Q` alone did not — the same reference + // resolving differently by the company it keeps. + expect( + providers({ + [CONTROLLER]: `package com.example.app.web + +@RestController +object Outer { + object Inner { + const val Q = "/orders" + } + + const val SUFFIX = "/list" + + @GetMapping(Inner.Q + SUFFIX) + fun list() {} +} +`, + }), + ).toEqual(['GET /orders/list']); + }); + + it('leaves literal routes unchanged and emits each exactly once', () => { + expect( + providers({ + [CONTROLLER]: `package com.example.app.web + +@RestController +@RequestMapping("/api/v1") +class OrderController { + @GetMapping("/orders") + fun list() {} + + @PostMapping(value = "/orders") + fun create() {} +} +`, + }), + ).toEqual(['GET /api/v1/orders', 'POST /api/v1/orders']); + }); +}); diff --git a/gitnexus/test/unit/kotlin-route-const-resolver.test.ts b/gitnexus/test/unit/kotlin-route-const-resolver.test.ts new file mode 100644 index 000000000..5f9a193f8 --- /dev/null +++ b/gitnexus/test/unit/kotlin-route-const-resolver.test.ts @@ -0,0 +1,1448 @@ +/** + * Kotlin route-path constant resolution (the Kotlin binding of the #2391 core). + * + * Covers the four reference forms the Java binding already handles — qualified + * access, fully-qualified name, single-name import, `+`-concatenation — plus the + * places Kotlin genuinely differs from Java and therefore needs its own + * behavior rather than a translation: + * + * • `object` / `companion object` / top-level carriers, where Java has only + * `static final` on a type (a companion member is referenced through its + * ENCLOSING class, never through `Companion`); + * • no `String` type gate — Kotlin infers property types, so the initializer + * decides whether a constant is foldable; + * • a file name need not match the declaration it holds, so import resolution + * falls back to the package directory; + * • member imports are unmarked (`import a.b.C.F` is spelled exactly like a + * type import), so both readings are tried; + * • string templates (`"$base/orders"`) are refused rather than silently + * folded with the interpolation deleted. + * + * Every unresolvable case asserts `null` — an ambiguous import must never + * produce a guessed path, because a wrong route is a false edge in the graph + * while a missing one is only a missing fact. + */ + +import { describe, expect, it } from 'vitest'; +import Parser from 'tree-sitter'; +import { requireVendoredGrammar } from '../../src/core/tree-sitter/vendored-grammars.js'; +import { MAX_FOLD_LENGTH } from '../../src/core/ingestion/route-extractors/constant-resolver.js'; +import { + buildKotlinConstantIndex, + extractKotlinModuleConstants, + foldKotlinOperands, + isKotlinConstantFile, + overlayKotlinConstantIndex, + parseKotlinConstOperands, + resolveKotlinConstant, + resolveKotlinImport, + resolveKotlinImportWithIndex, + type ModuleConstants, + type RepoConstants, +} from '../../src/core/ingestion/route-extractors/kotlin-const-resolver.js'; +import { unquoteSpringLiteral } from '../../src/core/ingestion/route-extractors/spring-shared.js'; + +// Vendored grammar — loaded from vendor/ by absolute path, never node_modules (#2111). +let Kotlin: unknown; +try { + Kotlin = requireVendoredGrammar('tree-sitter-kotlin'); +} catch { + // Optional grammar; the suite skips when its native binding is unavailable. +} + +const parser = new Parser(); +if (Kotlin) parser.setLanguage(Kotlin as Parser.Language); + +const parse = (src: string): Parser.Tree => parser.parse(src); + +/** Build a RepoConstants map from virtual files: { 'a/b/C.kt': source }. */ +function repoOf(files: Record): RepoConstants { + const map = new Map(); + for (const [key, src] of Object.entries(files)) { + map.set(key, extractKotlinModuleConstants(parse(src))); + } + return map; +} + +/** The initializer expression of the first `property_declaration` in `src`. */ +function firstInitializer(src: string): Parser.SyntaxNode { + const property = parse(src).rootNode.descendantsOfType('property_declaration')[0]; + expect(property, 'expected a property_declaration').toBeDefined(); + const eq = property.children.findIndex((c) => c.type === '='); + expect(eq, 'expected an initializer').toBeGreaterThan(-1); + const init = property.children.slice(eq + 1).find((c) => c.isNamed); + expect(init, 'expected an initializer expression').toBeDefined(); + return init as Parser.SyntaxNode; +} + +const CONSTS_KEY = 'src/main/kotlin/com/example/app/api/ApiPaths.kt'; +const CONTROLLER_KEY = 'src/main/kotlin/com/example/app/web/OrderController.kt'; + +const CONSTS_SRC = `package com.example.app.api + +object ApiPaths { + const val BASE = "/api/v1" + const val ORDERS = BASE + "/orders" + val LEGACY: String = "/legacy/orders" +} +`; + +const describeKotlin = Kotlin ? describe : describe.skip; + +describeKotlin('Kotlin route-path constant resolution', () => { + describe('reference forms shared with the Java binding', () => { + it('resolves a qualified reference through a type import', () => { + const repo = repoOf({ + [CONSTS_KEY]: CONSTS_SRC, + [CONTROLLER_KEY]: `package com.example.app.web + +import com.example.app.api.ApiPaths + +@RestController +class OrderController { + @GetMapping(ApiPaths.ORDERS) + fun list() {} +} +`, + }); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'ApiPaths.ORDERS', repo)).toBe('/api/v1/orders'); + }); + + it('resolves a fully-qualified reference with no import at all', () => { + const repo = repoOf({ + [CONSTS_KEY]: CONSTS_SRC, + [CONTROLLER_KEY]: `package com.example.app.web + +@RestController +class OrderController { + @GetMapping(com.example.app.api.ApiPaths.ORDERS) + fun list() {} +} +`, + }); + expect( + resolveKotlinConstant(CONTROLLER_KEY, 'com.example.app.api.ApiPaths.ORDERS', repo), + ).toBe('/api/v1/orders'); + }); + + it('resolves a single-name import of an object member', () => { + const repo = repoOf({ + [CONSTS_KEY]: CONSTS_SRC, + [CONTROLLER_KEY]: `package com.example.app.web + +import com.example.app.api.ApiPaths.ORDERS + +@RestController +class OrderController { + @GetMapping(ORDERS) + fun list() {} +} +`, + }); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'ORDERS', repo)).toBe('/api/v1/orders'); + }); + + it('folds an inline `+`-concatenation at the annotation site', () => { + const repo = repoOf({ + [CONSTS_KEY]: CONSTS_SRC, + [CONTROLLER_KEY]: `package com.example.app.web + +import com.example.app.api.ApiPaths + +@RestController +class OrderController { + @PostMapping(value = ApiPaths.BASE + "/orders/create") + fun create() {} +} +`, + }); + const operands = parseKotlinConstOperands( + firstInitializer('val X = ApiPaths.BASE + "/orders/create"'), + ); + if (operands === null) throw new Error('expected a foldable operand list'); + expect(operands).toEqual([ + { kind: 'ref', name: 'ApiPaths.BASE' }, + { kind: 'literal', value: '/orders/create' }, + ]); + expect(foldKotlinOperands(CONTROLLER_KEY, operands, repo)).toBe('/api/v1/orders/create'); + }); + + it('folds a constant defined by concatenating another constant', () => { + // `ORDERS = BASE + "/orders"` inside the same object. + const repo = repoOf({ [CONSTS_KEY]: CONSTS_SRC }); + expect(resolveKotlinConstant(CONSTS_KEY, 'ApiPaths.ORDERS', repo)).toBe('/api/v1/orders'); + }); + + it('folds a chain of three or more `+` operands', () => { + // tree-sitter-kotlin nests `A + B + C` left-associatively, so every + // `additive_expression` has exactly two operands and the chain folds by + // recursion. Pinned because the two-operand case cannot detect a + // regression to a flat-node reading. + const key = 'src/main/kotlin/com/example/app/api/Chained.kt'; + const repo = repoOf({ + [key]: `package com.example.app.api + +object Chained { + const val BASE = "/api" + const val VERSION = "/v1" + const val ORDERS = BASE + VERSION + "/orders" + const val ORDER_ITEMS = BASE + VERSION + "/orders" + "/items" +} +`, + }); + expect(resolveKotlinConstant(key, 'Chained.ORDERS', repo)).toBe('/api/v1/orders'); + expect(resolveKotlinConstant(key, 'Chained.ORDER_ITEMS', repo)).toBe('/api/v1/orders/items'); + }); + + it('rejects a `-` expression, which shares one node type with `+`', () => { + // tree-sitter-kotlin gives `A + B` and `A - B` the same + // `additive_expression` type, so only the presence of a `+` token + // distinguishes a concatenation. Subtraction is not a string operation; + // folding it as one would fabricate a path. + const key = 'src/main/kotlin/com/example/app/api/Minus.kt'; + const repo = repoOf({ + [key]: `package com.example.app.api + +object Minus { + const val BASE = "/api" + const val VERSION = "/v1" + val BROKEN = BASE - VERSION +} +`, + }); + expect(resolveKotlinConstant(key, 'Minus.BROKEN', repo)).toBeNull(); + }); + + it('folds escapes to exactly what the literal path would produce', () => { + const key = 'src/main/kotlin/com/example/app/api/Regexes.kt'; + const repo = repoOf({ + [key]: `package com.example.app.api + +object Regexes { + const val USER = "/user/{id:\\\\d+}" +} +`, + }); + expect(resolveKotlinConstant(key, 'Regexes.USER', repo)).toBe( + unquoteSpringLiteral('"/user/{id:\\\\d+}"'), + ); + }); + }); + + describe('prepared constant index', () => { + it('preserves fold results while narrowing lookup to the declared package', () => { + const files: Record = { + [CONSTS_KEY]: CONSTS_SRC, + [CONTROLLER_KEY]: `package com.example.app.web + +import com.example.app.api.ApiPaths +`, + }; + for (let i = 0; i < 256; i++) { + files[`src/main/kotlin/com/noise/p${i}/Noise.kt`] = `package com.noise.p${i} + +object Noise${i} { + const val PATH = "/noise/${i}" +} +`; + } + const repo = repoOf(files); + const index = buildKotlinConstantIndex(repo); + + expect(index.constantKeys.size).toBe(257); + expect(index.byPackage.get('com.example.app.api')?.files).toEqual([CONSTS_KEY]); + expect(resolveKotlinImportWithIndex('com.example.app.api.ApiPaths', index)).toBe(CONSTS_KEY); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'ApiPaths.ORDERS', repo, 0, index)).toBe( + resolveKotlinConstant(CONTROLLER_KEY, 'ApiPaths.ORDERS', repo), + ); + }); + + it('reuses package projections for an import-only scan overlay', () => { + const repo = repoOf({ [CONSTS_KEY]: CONSTS_SRC }); + const index = buildKotlinConstantIndex(repo); + const controller = extractKotlinModuleConstants( + parse(`package com.example.app.web + +import com.example.app.api.ApiPaths +`), + ); + const overlaid = overlayKotlinConstantIndex(index, CONTROLLER_KEY, controller); + + expect(overlaid.repo.get(CONTROLLER_KEY)).toBe(controller); + expect(overlaid.constantKeys).toBe(index.constantKeys); + expect(overlaid.byPackage).toBe(index.byPackage); + expect(resolveKotlinImportWithIndex('com.example.app.api.ApiPaths', overlaid)).toBe( + CONSTS_KEY, + ); + expect( + resolveKotlinConstant(CONTROLLER_KEY, 'ApiPaths.ORDERS', overlaid.repo, 0, overlaid), + ).toBe('/api/v1/orders'); + }); + + it('keeps duplicate declarations ambiguous after an overlay', () => { + const repo = repoOf({ [CONSTS_KEY]: CONSTS_SRC }); + const index = buildKotlinConstantIndex(repo); + const duplicateKey = 'src/test/kotlin/com/example/app/api/ApiPaths.kt'; + const duplicate = extractKotlinModuleConstants( + parse(`package com.example.app.api + +object ApiPaths { + const val ORDERS = "/test-only" +} +`), + ); + const overlaid = overlayKotlinConstantIndex(index, duplicateKey, duplicate); + + expect(overlaid.constantKeys.size).toBe(2); + expect(resolveKotlinImportWithIndex('com.example.app.api.ApiPaths', overlaid)).toBeNull(); + }); + + it('prefers an exact declared package over a nested path with the same FQN', () => { + const parentKey = 'src/main/kotlin/com/example/app/Parent.kt'; + const childKey = 'src/main/kotlin/com/example/app/api/ApiPaths.kt'; + const repo = repoOf({ + [parentKey]: `package com.example.app + +object api { + object ApiPaths { + const val ORDERS = "/wrong" + } +} +`, + [childKey]: `package com.example.app.api + +object ApiPaths { + const val ORDERS = "/right" +} +`, + [CONTROLLER_KEY]: `package com.example.app.web + +import com.example.app.api.ApiPaths +`, + }); + const index = buildKotlinConstantIndex(repo); + + expect(resolveKotlinImportWithIndex('com.example.app.api.ApiPaths', index)).toBe(childKey); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'ApiPaths.ORDERS', repo, 0, index)).toBe( + '/right', + ); + expect( + resolveKotlinConstant( + CONTROLLER_KEY, + 'com.example.app.api.ApiPaths.ORDERS', + repo, + 0, + index, + ), + ).toBe('/right'); + }); + }); + + describe('ambiguity floors to skip, never to a guess', () => { + it('returns null when two modules carry the same fully-qualified name', () => { + const files = { + 'service-a/src/main/kotlin/com/example/app/api/ApiPaths.kt': CONSTS_SRC, + 'service-b/src/main/kotlin/com/example/app/api/ApiPaths.kt': `package com.example.app.api + +object ApiPaths { + const val ORDERS = "/legacy/orders" +} +`, + [CONTROLLER_KEY]: `package com.example.app.web + +import com.example.app.api.ApiPaths +`, + }; + const repo = repoOf(files); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'ApiPaths.ORDERS', repo)).toBeNull(); + // Same verdict at the resolver layer the fold delegates to. It reads the + // candidates' DECLARED packages, so it takes the repo map as well as the + // key set. + expect( + resolveKotlinImport( + CONTROLLER_KEY, + 'com.example.app.api.ApiPaths', + new Set(Object.keys(files)), + repo, + ), + ).toBeNull(); + }); + + it('keeps an unfoldable duplicate in the fully-qualified-name candidate set', () => { + const repo = repoOf({ + 'src/main/kotlin/generated/RoutePaths.kt': `package com.example.app.api + +object ApiPaths { + const val ORDERS = ("/production") +} +`, + 'src/test/kotlin/com/example/app/api/ApiPaths.kt': `package com.example.app.api + +object ApiPaths { + const val ORDERS = "/test-only" +} +`, + [CONTROLLER_KEY]: `package com.example.app.web + +import com.example.app.api.ApiPaths +`, + }); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'ApiPaths.ORDERS', repo)).toBeNull(); + }); + + it('uses the unique declaring file before package filename fallbacks', () => { + // An unrelated constant file in the same package is not ambiguity when + // exactly one candidate declares the imported type. + const repo = repoOf({ + 'src/main/kotlin/com/example/app/api/Paths.kt': `package com.example.app.api + +object ApiPaths { + const val ORDERS = "/api/v1/orders" +} +`, + 'src/main/kotlin/com/example/app/api/More.kt': `package com.example.app.api + +object MorePaths { + const val ITEMS = ("/api/v1/items") +} +`, + [CONTROLLER_KEY]: `package com.example.app.web + +import com.example.app.api.ApiPaths +`, + }); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'ApiPaths.ORDERS', repo)).toBe('/api/v1/orders'); + }); + + it('returns null for a wildcard import', () => { + // `import com.example.app.api.*` binds no single name, so there is nothing + // to key the fold on and no honest way to pick a package member. + const repo = repoOf({ + [CONSTS_KEY]: CONSTS_SRC, + [CONTROLLER_KEY]: `package com.example.app.web + +import com.example.app.api.* +`, + }); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'ORDERS', repo)).toBeNull(); + }); + + it('returns null for an unknown reference rather than an empty path', () => { + const repo = repoOf({ [CONSTS_KEY]: CONSTS_SRC }); + expect(resolveKotlinConstant(CONSTS_KEY, 'ApiPaths.MISSING', repo)).toBeNull(); + expect(foldKotlinOperands(CONSTS_KEY, [{ kind: 'ref', name: 'MISSING' }], repo)).toBeNull(); + }); + + it('folds to the empty string as a SUCCESS, not a skip', () => { + // The counterpart of the test above, and the distinction it depends on: + // `null` means "could not fold", `''` means "folded, and the answer is + // empty". `const val ROOT = ""` is Spring's spelling for "the class prefix + // itself", so collapsing it into null loses a route the literal + // `@GetMapping("")` publishes from the same class. `resolveKotlinConstant` + // already returned `''` here; `foldKotlinOperands` did not, which made the + // two entry points disagree about the same constant. + const key = 'src/main/kotlin/com/example/app/api/Root.kt'; + const repo = repoOf({ + [key]: `package com.example.app.api + +object ApiPaths { + const val ROOT = "" +} +`, + }); + expect(resolveKotlinConstant(key, 'ApiPaths.ROOT', repo)).toBe(''); + expect(foldKotlinOperands(key, [{ kind: 'ref', name: 'ApiPaths.ROOT' }], repo)).toBe(''); + expect(foldKotlinOperands(key, [{ kind: 'literal', value: '' }], repo)).toBe(''); + }); + + it('terminates on a self-referential constant', () => { + const key = 'src/main/kotlin/com/example/app/api/Cycle.kt'; + const repo = repoOf({ + [key]: `package com.example.app.api + +object Cycle { + val A = B + "/a" + val B = A + "/b" +} +`, + }); + expect(resolveKotlinConstant(key, 'Cycle.A', repo)).toBeNull(); + }); + }); + + describe('Kotlin-specific declaration forms', () => { + it('reads a companion object member through its enclosing class', () => { + const key = 'src/main/kotlin/com/example/app/api/OrderApi.kt'; + const repo = repoOf({ + [key]: `package com.example.app.api + +class OrderApi { + companion object { + const val ORDERS = "/api/v1/orders" + } +} +`, + }); + // Kotlin source says `OrderApi.ORDERS`; `Companion` never appears. + expect(resolveKotlinConstant(key, 'OrderApi.ORDERS', repo)).toBe('/api/v1/orders'); + expect(resolveKotlinConstant(key, 'Companion.ORDERS', repo)).toBeNull(); + }); + + it('reads a top-level `const val` through a single-name import', () => { + const repo = repoOf({ + 'src/main/kotlin/com/example/app/api/TopLevel.kt': `package com.example.app.api + +const val ORDERS = "/api/v1/orders" +`, + [CONTROLLER_KEY]: `package com.example.app.web + +import com.example.app.api.ORDERS +`, + }); + // The declaration's file is named `TopLevel.kt`, so this only resolves via + // the package-directory fallback tier. + expect(resolveKotlinConstant(CONTROLLER_KEY, 'ORDERS', repo)).toBe('/api/v1/orders'); + }); + + it('reads an object whose file is not named after it', () => { + const repo = repoOf({ + 'src/main/kotlin/com/example/app/api/Constants.kt': `package com.example.app.api + +object ApiPaths { + const val ORDERS = "/api/v1/orders" +} +`, + [CONTROLLER_KEY]: `package com.example.app.web + +import com.example.app.api.ApiPaths +`, + }); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'ApiPaths.ORDERS', repo)).toBe('/api/v1/orders'); + }); + + it('un-aliases an aliased import', () => { + const repo = repoOf({ + [CONSTS_KEY]: CONSTS_SRC, + [CONTROLLER_KEY]: `package com.example.app.web + +import com.example.app.api.ApiPaths as Paths +`, + }); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'Paths.ORDERS', repo)).toBe('/api/v1/orders'); + }); + + it('accepts a non-`const` `val` in an object but rejects `var`', () => { + const key = 'src/main/kotlin/com/example/app/api/Mixed.kt'; + const repo = repoOf({ + [key]: `package com.example.app.api + +object Mixed { + val STABLE = "/api/v1/stable" + var MUTABLE = "/api/v1/mutable" +} +`, + }); + expect(resolveKotlinConstant(key, 'Mixed.STABLE', repo)).toBe('/api/v1/stable'); + expect(resolveKotlinConstant(key, 'Mixed.MUTABLE', repo)).toBeNull(); + }); + + it('rejects a computed property (custom getter or delegate)', () => { + const key = 'src/main/kotlin/com/example/app/api/Computed.kt'; + const repo = repoOf({ + [key]: `package com.example.app.api + +object Computed { + val VIA_GETTER: String get() = "/api/v1/getter" + val VIA_DELEGATE: String by lazy { "/api/v1/delegate" } +} +`, + }); + expect(resolveKotlinConstant(key, 'Computed.VIA_GETTER', repo)).toBeNull(); + expect(resolveKotlinConstant(key, 'Computed.VIA_DELEGATE', repo)).toBeNull(); + }); + + it('does not harvest an instance property of a plain class', () => { + // `val` in a class body is per-instance; `Holder.ORDERS` does not compile. + const key = 'src/main/kotlin/com/example/app/api/Holder.kt'; + const repo = repoOf({ + [key]: `package com.example.app.api + +class Holder { + val ORDERS = "/api/v1/orders" +} +`, + }); + expect(resolveKotlinConstant(key, 'Holder.ORDERS', repo)).toBeNull(); + expect(resolveKotlinConstant(key, 'ORDERS', repo)).toBeNull(); + }); + + it('refuses a string template instead of dropping the interpolation', () => { + // Joining the literal runs of `"$BASE/orders"` would publish `/orders` — + // a path the application does not serve. + const key = 'src/main/kotlin/com/example/app/api/Templated.kt'; + const repo = repoOf({ + [key]: `package com.example.app.api + +object Templated { + const val BASE = "/api/v1" + val ORDERS = "\${BASE}/orders" + val ITEMS = "$BASE/items" +} +`, + }); + expect(resolveKotlinConstant(key, 'Templated.BASE', repo)).toBe('/api/v1'); + expect(resolveKotlinConstant(key, 'Templated.ORDERS', repo)).toBeNull(); + expect(resolveKotlinConstant(key, 'Templated.ITEMS', repo)).toBeNull(); + }); + + it('folds a single-line raw string, which Kotlin leaves byte-exact', () => { + const key = 'src/main/kotlin/com/example/app/api/Raw.kt'; + const repo = repoOf({ + [key]: `package com.example.app.api + +object Raw { + const val ORDERS = """/api/v1/orders""" +} +`, + }); + expect(resolveKotlinConstant(key, 'Raw.ORDERS', repo)).toBe('/api/v1/orders'); + }); + + it('drops a constant whose initializer is not a string expression', () => { + // Kotlin infers property types, so there is no `String` type node to gate + // on — the initializer is what decides. + const key = 'src/main/kotlin/com/example/app/api/NonString.kt'; + const repo = repoOf({ + [key]: `package com.example.app.api + +object NonString { + const val PORT = 8080 + val COMPUTED = buildPath() +} +`, + }); + expect(resolveKotlinConstant(key, 'NonString.PORT', repo)).toBeNull(); + expect(resolveKotlinConstant(key, 'NonString.COMPUTED', repo)).toBeNull(); + }); + + it('does not answer `Owner.NAME` with a same-named top-level constant', () => { + // In Kotlin `Owner.NAME` means NAME is a member of the object/companion + // `Owner`; a top-level `NAME` in the same file is a different declaration, + // so matching it would fabricate a value. (The Java binding's bare-name + // fallback is sound there only because the file name pins the class.) + const repo = repoOf({ + 'src/main/kotlin/com/example/app/api/ApiPaths.kt': `package com.example.app.api + +const val ORDERS = "/top-level/orders" + +object Unrelated { + const val ITEMS = "/api/v1/items" +} +`, + [CONTROLLER_KEY]: `package com.example.app.web + +import com.example.app.api.ApiPaths +`, + }); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'ApiPaths.ORDERS', repo)).toBeNull(); + }); + }); + + describe('member names resolve in their declaring scope, not a flat namespace', () => { + /** Two objects declaring `BASE`; only `A` is referenced. Order is the axis. */ + const siblingShadow = (first: 'A' | 'B'): string => { + const a = `object A { + const val BASE = "/right" + const val ROUTE = BASE + "/m" +}`; + const b = `object B { + const val BASE = "/wrong" +}`; + return `package com.example.app.api\n\n${first === 'A' ? `${a}\n\n${b}` : `${b}\n\n${a}`}\n`; + }; + const SIBLING_KEY = 'src/main/kotlin/com/example/app/api/Siblings.kt'; + + it('answers a sibling initializer identically whichever object is declared first', () => { + // `A.ROUTE = BASE + "/m"` means `A.BASE`, so the answer is `/right/m` in + // both spellings. Recording every member under its BARE name too made the + // operand resolve through whichever object was walked last, so moving + // `object B` above `object A` changed the emitted route for source that + // had not changed — the same file, merely reordered, served a different + // path. Both orders are asserted because either one alone passes on a + // last-wins implementation. + for (const first of ['A', 'B'] as const) { + const repo = repoOf({ [SIBLING_KEY]: siblingShadow(first) }); + expect(resolveKotlinConstant(SIBLING_KEY, 'A.ROUTE', repo), `${first} first`).toBe( + '/right/m', + ); + expect(resolveKotlinConstant(SIBLING_KEY, 'B.BASE', repo), `${first} first`).toBe('/wrong'); + } + }); + + it('does not bind an `object` member to its bare name, so an import still wins', () => { + // `object Local { const val ORDERS }` binds `Local.ORDERS` and nothing + // else — bare `ORDERS` in this file is the IMPORT. A bare key for the + // object member is a binding Kotlin does not have, and it outranks the + // import because the fold consults literals before imports. + const repo = repoOf({ + 'src/main/kotlin/com/example/app/api/Paths.kt': `package com.example.app.api + +object Paths { + const val ORDERS = "/imported" +} +`, + [CONTROLLER_KEY]: `package com.example.app.web + +import com.example.app.api.Paths.ORDERS + +object Local { + const val ORDERS = "/local-member" +} +`, + }); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'ORDERS', repo)).toBe('/imported'); + // The qualified spelling still reaches the object member. + expect(resolveKotlinConstant(CONTROLLER_KEY, 'Local.ORDERS', repo)).toBe('/local-member'); + }); + + it('keeps a top-level `const val` shadowing a same-named import', () => { + // The control for the test above: a top-level declaration IS the bare + // binding, so it must keep winning over the import. + const repo = repoOf({ + 'src/main/kotlin/com/example/app/api/Paths.kt': `package com.example.app.api + +object Paths { + const val ORDERS = "/imported" +} +`, + [CONTROLLER_KEY]: `package com.example.app.web + +import com.example.app.api.Paths.ORDERS + +const val ORDERS = "/local" +`, + }); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'ORDERS', repo)).toBe('/local'); + }); + + it('keeps a companion member visible under its bare name INSIDE its class', () => { + // The other control: a companion's members ARE in scope unqualified + // throughout the enclosing class, which is where route annotations sit — + // but ONLY there. The binding is reached from the reference site through + // the enclosing type chain, not from a file-level key, so all three + // spellings below are asserted together: the same name answers one way + // inside `OrderApi` and does not answer at all outside it. + const key = 'src/main/kotlin/com/example/app/web/OrderApi.kt'; + const repo = repoOf({ + [key]: `package com.example.app.web + +class OrderApi { + companion object { + const val ORDERS = "/companion/orders" + } +} +`, + }); + const bare = [{ kind: 'ref', name: 'ORDERS' } as const]; + expect(foldKotlinOperands(key, bare, repo, ['OrderApi'])).toBe('/companion/orders'); + expect(foldKotlinOperands(key, bare, repo)).toBeNull(); + expect(resolveKotlinConstant(key, 'OrderApi.ORDERS', repo)).toBe('/companion/orders'); + }); + + it('does not let a companion outrank a top-level constant outside its class', () => { + // Recording the companion's simple name at FILE level put it in the same + // namespace as the top-level declaration, and companions are recorded + // last, so the companion won every unqualified reference in the file — + // including from a class that is not its own. Kotlin binds the top-level + // `ORDERS` there. Both scopes are asserted from one file; either alone + // passes on an implementation that gets the other wrong. + const key = 'src/main/kotlin/com/example/app/web/Routes.kt'; + const repo = repoOf({ + [key]: `package com.example.app.web + +const val ORDERS = "/top" + +class Holder { + companion object { + const val ORDERS = "/companion" + } +} + +class OrderController +`, + }); + const bare = [{ kind: 'ref', name: 'ORDERS' } as const]; + expect(foldKotlinOperands(key, bare, repo, ['OrderController'])).toBe('/top'); + expect(foldKotlinOperands(key, bare, repo, ['Holder'])).toBe('/companion'); + expect(foldKotlinOperands(key, bare, repo)).toBe('/top'); + }); + + it('does not fall through an unfoldable companion to a top-level constant', () => { + const key = 'src/main/kotlin/com/example/app/web/Routes.kt'; + const repo = repoOf({ + [key]: `package com.example.app.web + +const val ORDERS = "/top" + +class Holder { + companion object { + const val ORDERS = ("/companion") + } +} + +class Other +`, + }); + const bare = [{ kind: 'ref', name: 'ORDERS' } as const]; + expect(foldKotlinOperands(key, bare, repo, ['Holder'])).toBeNull(); + expect(foldKotlinOperands(key, bare, repo, ['Other'])).toBe('/top'); + expect(foldKotlinOperands(key, bare, repo)).toBe('/top'); + }); + + it('scopes each of two colliding companions to its own class', () => { + // Kotlin scopes the member name to its enclosing class, so the same + // spelling means a different constant in each body. One file-level + // namespace could only answer last-wins — both reads returned `/h2`, and + // reordering the two classes flipped both to `/h1`. Both orders are + // asserted, because either alone passes on a last-wins implementation. + const holders = (first: 'A' | 'B'): string => { + const a = `class HolderA { + companion object { + const val ORDERS = "/h1" + } +}`; + const b = `class HolderB { + companion object { + const val ORDERS = "/h2" + } +}`; + return `package com.example.app.web\n\n${first === 'A' ? `${a}\n\n${b}` : `${b}\n\n${a}`}\n`; + }; + const key = 'src/main/kotlin/com/example/app/web/Holders.kt'; + const bare = [{ kind: 'ref', name: 'ORDERS' } as const]; + for (const first of ['A', 'B'] as const) { + const repo = repoOf({ [key]: holders(first) }); + expect(foldKotlinOperands(key, bare, repo, ['HolderA']), `${first} first`).toBe('/h1'); + expect(foldKotlinOperands(key, bare, repo, ['HolderB']), `${first} first`).toBe('/h2'); + } + }); + + it('folds a TOP-LEVEL initializer at file level despite a same-named companion', () => { + // A top-level initializer has an EMPTY scope chain, so `qualifyRef` leaves + // its operand bare and the file-level maps answer it. A file-wide + // companion key WAS one of those maps, so `ROUTE` folded to `/comp/m` + // where Kotlin serves `/top/m` — the case an earlier note in the resolver + // claimed could not arise because sibling initializers "go through the + // scope chain". An empty chain is exactly what that missed. + const key = 'src/main/kotlin/com/example/app/web/Routes.kt'; + const repo = repoOf({ + [key]: `package com.example.app.web + +const val BASE = "/top" +const val ROUTE = BASE + "/m" + +class Holder { + companion object { + const val BASE = "/comp" + } +} +`, + }); + expect(resolveKotlinConstant(key, 'ROUTE', repo)).toBe('/top/m'); + // The companion's own binding is intact, reached the way Kotlin reaches it. + expect(resolveKotlinConstant(key, 'Holder.BASE', repo)).toBe('/comp'); + }); + + it('lets a single-name import win over a companion outside that companion class', () => { + // The fold consults literals before imports, so a file-level companion key + // outranked a genuine `import …Paths.ORDERS` everywhere in the file. The + // import is what Kotlin binds outside `Holder`; inside `Holder`, the + // companion shadows it. + const repo = repoOf({ + 'src/main/kotlin/com/example/app/api/Paths.kt': `package com.example.app.api + +object Paths { + const val ORDERS = "/imported" +} +`, + [CONTROLLER_KEY]: `package com.example.app.web + +import com.example.app.api.Paths.ORDERS + +class Holder { + companion object { + const val ORDERS = "/companion" + } +} +`, + }); + const bare = [{ kind: 'ref', name: 'ORDERS' } as const]; + expect(foldKotlinOperands(CONTROLLER_KEY, bare, repo)).toBe('/imported'); + expect(foldKotlinOperands(CONTROLLER_KEY, bare, repo, ['Other'])).toBe('/imported'); + expect(foldKotlinOperands(CONTROLLER_KEY, bare, repo, ['Holder'])).toBe('/companion'); + }); + + it('reaches an enclosing class companion from a NESTED type', () => { + // Kotlin keeps the companion's members in scope through the nested types + // of its class, so the whole enclosing chain is walked, innermost first — + // and the inner link still wins where both declare the name. + const key = 'src/main/kotlin/com/example/app/web/Nested.kt'; + const repo = repoOf({ + [key]: `package com.example.app.web + +class Outer { + companion object { + const val ORDERS = "/outer" + const val ONLY_OUTER = "/only-outer" + } + + class Inner { + companion object { + const val ORDERS = "/inner" + } + } +} +`, + }); + expect( + foldKotlinOperands(key, [{ kind: 'ref', name: 'ORDERS' }], repo, ['Outer.Inner', 'Outer']), + ).toBe('/inner'); + expect( + foldKotlinOperands(key, [{ kind: 'ref', name: 'ONLY_OUTER' }], repo, [ + 'Outer.Inner', + 'Outer', + ]), + ).toBe('/only-outer'); + }); + + it('keys a nested object by its full enclosing type path', () => { + // `Inner`'s initializer names `P`, which `Inner` does not declare and + // `Outer` does; the scope chain is walked innermost-first, so it means + // `Outer.P` — not the same-named member of the unrelated `Other`. The + // declaration itself is reachable as `Outer.Inner.Q`, never `Inner.Q`. + const key = 'src/main/kotlin/com/example/app/api/Nested.kt'; + const controllerKey = 'src/main/kotlin/com/example/app/web/Controller.kt'; + const nestedImportKey = 'src/main/kotlin/com/example/app/web/NestedImport.kt'; + const memberImportKey = 'src/main/kotlin/com/example/app/web/MemberImport.kt'; + const repo = repoOf({ + [key]: `package com.example.app.api + +object Other { + const val P = "/wrong" +} + +object Outer { + const val P = "/right" + object Inner { + const val Q = P + "/q" + } +} +`, + [controllerKey]: `package com.example.app.web + +import com.example.app.api.Outer +`, + [nestedImportKey]: `package com.example.app.web + +import com.example.app.api.Outer.Inner +`, + [memberImportKey]: `package com.example.app.web + +import com.example.app.api.Outer.Inner.Q +`, + }); + expect(resolveKotlinConstant(key, 'Outer.Inner.Q', repo)).toBe('/right/q'); + expect(resolveKotlinConstant(controllerKey, 'Outer.Inner.Q', repo)).toBe('/right/q'); + expect(resolveKotlinConstant(controllerKey, 'com.example.app.api.Outer.Inner.Q', repo)).toBe( + '/right/q', + ); + expect(resolveKotlinConstant(nestedImportKey, 'Inner.Q', repo)).toBe('/right/q'); + expect(resolveKotlinConstant(memberImportKey, 'Q', repo)).toBe('/right/q'); + expect(resolveKotlinConstant(key, 'Inner.Q', repo)).toBeNull(); + }); + + it('does not fall through to a file-level constant for an unfoldable sibling', () => { + // `A.R` names `A.BASE`, which does not fold. The answer is the skip floor, + // not the top-level `BASE` that happens to share the simple name. + const key = 'src/main/kotlin/com/example/app/api/Unfoldable.kt'; + const repo = repoOf({ + [key]: `package com.example.app.api + +const val BASE = "/top-level" + +object A { + val BASE = buildBase() + val R = BASE + "/r" +} +`, + }); + expect(resolveKotlinConstant(key, 'A.R', repo)).toBeNull(); + expect(resolveKotlinConstant(key, 'BASE', repo)).toBe('/top-level'); + }); + + it('lets an unfoldable object member leave a same-named import alone', () => { + // A local declaration drops a same-named import only when it SHADOWS it. + // An object member shadows nothing, so dropping the import here would + // floor a reference the language resolves perfectly well. + const repo = repoOf({ + 'src/main/kotlin/com/example/app/api/Paths.kt': `package com.example.app.api + +object Paths { + const val ORDERS = "/imported" +} +`, + [CONTROLLER_KEY]: `package com.example.app.web + +import com.example.app.api.Paths.ORDERS + +object Local { + val ORDERS = buildOrders() +} +`, + }); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'ORDERS', repo)).toBe('/imported'); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'Local.ORDERS', repo)).toBeNull(); + }); + }); + + describe('imports resolve on the declared package, not on the path', () => { + it('folds through the file that DECLARES the package, not one whose path imitates it', () => { + // `src/x/com/example/api/ApiPaths.kt` ends with the imported FQN but + // declares `package x.com.example.api`, so it is a different declaration + // entirely. Selecting candidates by path made it beat the real file — and + // because the decoy declares the same member, the fold did not skip, it + // published `/wrong`. + const repo = repoOf({ + 'src/generated/Constants.kt': `package com.example.api + +object ApiPaths { + const val ORDERS = "/right" +} +`, + 'src/x/com/example/api/ApiPaths.kt': `package x.com.example.api + +object ApiPaths { + const val ORDERS = "/wrong" +} +`, + [CONTROLLER_KEY]: `package com.example.app.web + +import com.example.api.ApiPaths +`, + }); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'ApiPaths.ORDERS', repo)).toBe('/right'); + }); + + it('does not let a deep directory impersonate a root-level package', () => { + // `package data` lives at the repository root, which the old + // package-DIRECTORY fallback could not see at all, while + // `src/main/kotlin/com/example/data/` matched `data` by path suffix. Both + // halves are gone: the declared package is the whole test. + const repo = repoOf({ + 'Constants.kt': `package data + +object Constants { + const val ORDERS = "/right" +} +`, + 'src/main/kotlin/com/example/data/AppPaths.kt': `package com.example.data + +object Constants { + const val ORDERS = "/wrong" +} +`, + [CONTROLLER_KEY]: `package com.example.app.web + +import data.Constants +`, + }); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'Constants.ORDERS', repo)).toBe('/right'); + }); + + it('skips rather than guesses when no file declares the imported package', () => { + // The same import with the real declaration absent. A path-suffix match + // answered `/wrong` here; the honest answer is that the constant is not + // in this repository. + const repo = repoOf({ + 'src/main/kotlin/com/example/data/AppPaths.kt': `package com.example.data + +object Constants { + const val ORDERS = "/wrong" +} +`, + [CONTROLLER_KEY]: `package com.example.app.web + +import data.Constants +`, + }); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'Constants.ORDERS', repo)).toBeNull(); + }); + + it('skips when two files declare the same fully-qualified name', () => { + // A test-source copy of a production constant: same package, same object, + // different value. Only the copy follows the `/.kt` + // convention, so a file-name tie-break picked it and folded a test-only + // path into a production route. Two declarations of one FQN name no single + // declaration, whichever paths they sit at. + const repo = repoOf({ + 'src/main/kotlin/generated/RoutePaths.kt': `package com.example.api + +object ApiPaths { + const val ORDERS = "/right" +} +`, + 'src/test/kotlin/com/example/api/ApiPaths.kt': `package com.example.api + +object ApiPaths { + const val ORDERS = "/test-only" +} +`, + [CONTROLLER_KEY]: `package com.example.app.web + +import com.example.api.ApiPaths +`, + }); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'ApiPaths.ORDERS', repo)).toBeNull(); + }); + + it('prefers a unique declarer among same-package candidates', () => { + // The declaration itself is stronger evidence than either filename or + // package-only fallback, even when its file also follows the convention. + const repo = repoOf({ + 'src/main/kotlin/com/example/api/ApiPaths.kt': `package com.example.api + +object ApiPaths { + const val ORDERS = "/right" +} +`, + 'src/main/kotlin/com/example/api/Other.kt': `package com.example.api + +object OtherPaths { + const val ITEMS = "/items" +} +`, + [CONTROLLER_KEY]: `package com.example.app.web + +import com.example.api.ApiPaths +`, + }); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'ApiPaths.ORDERS', repo)).toBe('/right'); + }); + + it('reaches the sole file of a package whose name matches nothing', () => { + // The decoy declares a DIFFERENT package, so it is not a candidate at all + // and the unconventionally named `Constants.kt` is the only one left. + // This used to be the one shape the old resolver's safety argument + // covered, and it covered it by emitting nothing. + const repo = repoOf({ + 'src/generated/Constants.kt': `package com.example.api + +object ApiPaths { + const val ORDERS = "/right" +} +`, + 'src/x/com/example/api/ApiPaths.kt': `package x.com.example.api + +object ApiPaths { + const val OTHER = "/other" +} +`, + [CONTROLLER_KEY]: `package com.example.app.web + +import com.example.api.ApiPaths +`, + }); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'ApiPaths.ORDERS', repo)).toBe('/right'); + }); + + it('rejects a candidate carrying no recorded package', () => { + // `RepoConstants` is typed over the agnostic shape, so an entry some other + // producer put there has no `packageName`. Unknown is not "the default + // package": the candidate is rejected, and the fold floors to skip. + const key = 'src/main/kotlin/com/example/api/ApiPaths.kt'; + const foreign = extractKotlinModuleConstants( + parse(`package com.example.api + +object ApiPaths { + const val ORDERS = "/right" +} +`), + ); + const repo = new Map(); + // Stripped to the agnostic shape: same maps, no `packageName`. + repo.set(key, { + literals: foreign.literals, + exprs: foreign.exprs, + imports: foreign.imports, + }); + repo.set( + CONTROLLER_KEY, + extractKotlinModuleConstants( + parse(`package com.example.app.web + +import com.example.api.ApiPaths +`), + ), + ); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'ApiPaths.ORDERS', repo)).toBeNull(); + }); + + it('matches a package whose segment is backtick-quoted on one side only', () => { + // `` package com.example.`api` `` and `package com.example.api` name the + // SAME package: the quotes are lexical syntax, not part of the name. The + // declared package was recorded with the backticks and the import required + // an exact string match, so the one real candidate was rejected and the + // route lost. All three spellings are asserted, because either side can + // carry the quotes — a declaration may quote a segment an import spells + // plainly, and an import may quote one the declaration does not. + const declaration = (pkg: string): string => `package ${pkg} + +object ApiPaths { + const val ORDERS = "/right" +} +`; + const importing = (spec: string): string => `package com.example.app.web + +import ${spec} +`; + const CONSTS = 'src/main/kotlin/com/example/api/ApiPaths.kt'; + for (const [declared, spec] of [ + ['com.example.`api`', 'com.example.api.ApiPaths'], + ['com.example.api', 'com.example.`api`.ApiPaths'], + ['com.example.`api`', 'com.example.`api`.ApiPaths'], + ] as const) { + const repo = repoOf({ + [CONSTS]: declaration(declared), + [CONTROLLER_KEY]: importing(spec), + }); + expect( + resolveKotlinConstant(CONTROLLER_KEY, 'ApiPaths.ORDERS', repo), + `${declared} <- ${spec}`, + ).toBe('/right'); + } + }); + + it('still refuses a package that merely resembles the quoted one', () => { + // The control for the test above: unquoting compares NAMES, it does not + // widen the match. `com.example.other` is a different package however + // either side spells it, so the fold floors to skip rather than reaching + // for the only file it can see. + const repo = repoOf({ + 'src/main/kotlin/com/example/other/ApiPaths.kt': `package com.example.\`other\` + +object ApiPaths { + const val ORDERS = "/wrong" +} +`, + [CONTROLLER_KEY]: `package com.example.app.web + +import com.example.api.ApiPaths +`, + }); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'ApiPaths.ORDERS', repo)).toBeNull(); + }); + + it('folds through a KEYWORD package segment, which Kotlin can only spell quoted', () => { + // `package com.example.fun` does not compile — the segment must be + // `` `fun` `` on both sides. Unquoting must not break the case that only + // works BECAUSE it is quoted, so this is the control for the pair above. + const repo = repoOf({ + 'src/main/kotlin/com/example/fun/ApiPaths.kt': `package com.example.\`fun\` + +object ApiPaths { + const val ORDERS = "/right" +} +`, + [CONTROLLER_KEY]: `package com.example.app.web + +import com.example.\`fun\`.ApiPaths +`, + }); + expect(resolveKotlinConstant(CONTROLLER_KEY, 'ApiPaths.ORDERS', repo)).toBe('/right'); + }); + + it('matches a backtick-quoted declaration name and reference', () => { + // Quoting reaches declarations too, and the two sides need not agree: + // `` object `ApiPaths` `` is keyed `ApiPaths.ORDERS`, and a reference + // written `` `ApiPaths`.`ORDERS` `` parses to that same name. + const key = 'src/main/kotlin/com/example/api/ApiPaths.kt'; + const repo = repoOf({ + [key]: `package com.example.api + +object \`ApiPaths\` { + const val \`ORDERS\` = "/right" +} +`, + }); + expect(resolveKotlinConstant(key, 'ApiPaths.ORDERS', repo)).toBe('/right'); + expect(parseKotlinConstOperands(firstInitializer('val X = `ApiPaths`.`ORDERS`\n'))).toEqual([ + { kind: 'ref', name: 'ApiPaths.ORDERS' }, + ]); + }); + }); + + describe('the fold is bounded in output, depth and time', () => { + /** `object Doubling { const val X = ; val X = X + X … }`. */ + const doublingChain = (levels: number, leaf: string): string => { + const lines = [` const val X${levels} = "${leaf}"`]; + for (let i = levels - 1; i >= 0; i--) lines.push(` val X${i} = X${i + 1} + X${i + 1}`); + return `package com.example.app.api\n\nobject Doubling {\n${lines.join('\n')}\n}\n`; + }; + const DOUBLING_KEY = 'src/main/kotlin/com/example/app/api/Doubling.kt'; + + it('folds a 30-level shared-descendant DAG instead of exploring 2^30 paths', () => { + // Every intermediate value here is the EMPTY string, so MAX_FOLD_LENGTH + // never fires and only the success memo keeps this from re-folding each + // child once per reference — O(2^depth). The assertion is the explicit + // timeout: a regression does not fail this test slowly, it fails it. + const repo = repoOf({ [DOUBLING_KEY]: doublingChain(30, '') }); + expect(resolveKotlinConstant(DOUBLING_KEY, 'Doubling.X0', repo)).toBe(''); + }, 5_000); + + it('caps output at MAX_FOLD_LENGTH, which the depth cap cannot bound', () => { + // Same shape with a one-character leaf: output doubles per level while + // depth only increments, so 13 levels land exactly on MAX_FOLD_LENGTH and + // 14 overrun it. Pinned from both sides — a chain deep enough to matter in + // practice (30 levels, a gigabyte of string) is the same code path. + const foldOf = (levels: number): string | null => + resolveKotlinConstant( + DOUBLING_KEY, + 'Doubling.X0', + repoOf({ [DOUBLING_KEY]: doublingChain(levels, 'a') }), + ); + expect(foldOf(13)).toHaveLength(MAX_FOLD_LENGTH); + expect(foldOf(14)).toBeNull(); + }); + + /** `object Link { const val X = "/end"; val X = X … }`. */ + const referenceChain = (links: number): string => { + const lines = [` const val X${links} = "/end"`]; + for (let i = links - 1; i >= 0; i--) lines.push(` val X${i} = X${i + 1}`); + return `package com.example.app.api\n\nobject Link {\n${lines.join('\n')}\n}\n`; + }; + const LINK_KEY = 'src/main/kotlin/com/example/app/api/Link.kt'; + + it('resolves a chain inside the cross-file depth cap but stops past it', () => { + // Each link costs one level of `resolveWithState`, so a 30-link chain + // resolves and a 40-link one runs into the cap. Asserted from both sides: + // a bare `toBeNull()` would also pass if the fold had stopped working. + expect( + resolveKotlinConstant(LINK_KEY, 'Link.X0', repoOf({ [LINK_KEY]: referenceChain(30) })), + ).toBe('/end'); + expect( + resolveKotlinConstant(LINK_KEY, 'Link.X0', repoOf({ [LINK_KEY]: referenceChain(40) })), + ).toBeNull(); + }); + + it('caps operand parsing on a pathologically long `+` chain', () => { + // `A + B + C` nests left-associatively, so an n-term concatenation is n-1 + // levels deep and a long enough one would recurse without the parse cap. + const chainOf = (terms: number): string => + `val X = ${Array.from({ length: terms }, (_, i) => `"/${i}"`).join(' + ')}`; + expect(parseKotlinConstOperands(firstInitializer(chainOf(60)))).toHaveLength(60); + expect(parseKotlinConstOperands(firstInitializer(chainOf(80)))).toBeNull(); + }); + }); + + describe('isKotlinConstantFile gate', () => { + it('admits every shape the extractor harvests', () => { + expect(isKotlinConstantFile(CONSTS_SRC)).toBe(true); + expect(isKotlinConstantFile('const val ORDERS = "/api/v1/orders"')).toBe(true); + expect(isKotlinConstantFile('val ORDERS = "/api/v1/orders"')).toBe(true); + expect(isKotlinConstantFile('object O { val ORDERS: String = "/api/v1/orders" }')).toBe(true); + expect( + isKotlinConstantFile('class C { companion object { const val O = "/api/v1/orders" } }'), + ).toBe(true); + }); + + it('rejects a file with no constant carrier at all', () => { + expect( + isKotlinConstantFile(`package com.example.app.web + +class OrderService { + fun list(): List = emptyList() +} +`), + ).toBe(false); + }); + + it('admits top-level vals without admitting locals or constructor properties', () => { + expect( + isKotlinConstantFile(`package com.example.app.api + +@JvmField +val ORDERS: String = "/api/v1/orders" +`), + ).toBe(true); + expect( + isKotlinConstantFile(`val ORDERS: + String + = "/api/v1/orders" +`), + ).toBe(true); + expect(isKotlinConstantFile('val ORDERS: String get() = "/computed"')).toBe(true); + expect(isKotlinConstantFile('val ORDERS by lazy { "/computed" }')).toBe(true); + expect(isKotlinConstantFile('val `ORDER PATH` = "/api/v1/orders"')).toBe(true); + expect( + isKotlinConstantFile(`fun route(): String { + val ORDERS = "/local" + return ORDERS +} +`), + ).toBe(false); + expect(isKotlinConstantFile('class Holder { val ORDERS = "/instance" }')).toBe(false); + expect(isKotlinConstantFile('data class Route(val path: String = "/constructor")')).toBe( + false, + ); + }); + + it('ignores declaration-shaped text in comments and literals', () => { + expect(isKotlinConstantFile('// const val ORDERS = "/comment"')).toBe(false); + expect( + isKotlinConstantFile('/* outer /* const val ORDERS = "/nested-comment" */ end */'), + ).toBe(false); + expect(isKotlinConstantFile('val text() = "const val ORDERS = \\"/string\\""')).toBe(false); + expect(isKotlinConstantFile('val text() = """const val ORDERS = "/raw-string\""""')).toBe( + false, + ); + expect( + isKotlinConstantFile(`fun route(): String { + val open = '{' + val close = '}' + return "$open$close" +} +`), + ).toBe(false); + }); + + it('admits a backtick-quoted name, because the extractor resolves one', () => { + // A gate NARROWER than the extractor costs a fact: the file is never + // parsed into the repo map, so a cross-file reference to the constant + // floors to skip. `unquoteKotlinIdentifier` strips the quoting everywhere + // a name becomes a key, so these declarations are ones this module folds. + expect(isKotlinConstantFile('const val `ORDERS` = "/api/v1/orders"')).toBe(true); + expect(isKotlinConstantFile('object O { val `ORDERS`: String = "/api/v1/orders" }')).toBe( + true, + ); + expect( + isKotlinConstantFile('class C { companion object { const val `O` = "/orders" } }'), + ).toBe(true); + }); + + it('does not let the backtick arm widen into a file with no carrier', () => { + // The control for the arm above: accepting backticks must not turn the + // gate into "any file mentioning val", which is the whole repository. + expect( + isKotlinConstantFile(`package com.example.app.web + +class OrderService { + fun list(): List { + val \`local name\` = "not a constant" + return listOf(\`local name\`) + } +} +`), + ).toBe(false); + }); + }); +});