From 66421ef42f1669ee3bd32a721e582144009e9a86 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 06:08:57 +0100 Subject: [PATCH 1/3] chore(deps)(deps-dev): bump @types/node in /gitnexus (#3297) --- gitnexus/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 6dbb880c6..5a4af535b 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -1276,9 +1276,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.0.tgz", - "integrity": "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A==", + "version": "26.5.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.1.tgz", + "integrity": "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==", "dev": true, "license": "MIT", "dependencies": { From ebbcd5b0b489743f87a5db0f15e067d4a3fbd914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Wed, 16 Sep 2026 08:29:04 +0100 Subject: [PATCH 2/3] fix(mcp): name the indexed ref on hot read tool staleness (#3291) (#3293) --- .claude/skills/gitnexus-debugging/SKILL.md | 1 + .claude/skills/gitnexus-exploring/SKILL.md | 1 + .claude/skills/gitnexus-guide/SKILL.md | 37 ++- .../skills/gitnexus-impact-analysis/SKILL.md | 1 + .claude/skills/gitnexus-refactoring/SKILL.md | 1 + AGENTS.md | 1 + CLAUDE.md | 1 + .../skills/gitnexus-debugging/SKILL.md | 1 + .../skills/gitnexus-exploring/SKILL.md | 1 + .../skills/gitnexus-guide/SKILL.md | 37 ++- .../skills/gitnexus-impact-analysis/SKILL.md | 1 + .../skills/gitnexus-refactoring/SKILL.md | 1 + .../skills/gitnexus-debugging/SKILL.md | 1 + .../skills/gitnexus-exploring/SKILL.md | 1 + .../skills/gitnexus-impact-analysis/SKILL.md | 1 + .../skills/gitnexus-refactoring/SKILL.md | 1 + gitnexus/CHANGELOG.md | 4 + gitnexus/skills/gitnexus-debugging.md | 1 + gitnexus/skills/gitnexus-exploring.md | 1 + gitnexus/skills/gitnexus-guide.md | 49 ++++ gitnexus/skills/gitnexus-impact-analysis.md | 1 + gitnexus/skills/gitnexus-refactoring.md | 1 + gitnexus/src/cli/ai-context.ts | 1 + gitnexus/src/core/staleness-status.ts | 75 ++++- gitnexus/src/mcp/local/local-backend.ts | 56 +++- gitnexus/src/mcp/tools.ts | 20 +- gitnexus/src/server/repo-projection.ts | 19 +- .../integration/objective-c-provider.test.ts | 39 ++- gitnexus/test/unit/ai-context.test.ts | 26 +- gitnexus/test/unit/calltool-dispatch.test.ts | 52 +++- .../repro-3291-staleness-indexed-ref.test.ts | 263 ++++++++++++++++++ .../test/unit/shipped-skills-sync.test.ts | 29 +- gitnexus/test/unit/tool-staleness.test.ts | 113 ++++++++ gitnexus/test/unit/tools.test.ts | 8 + 34 files changed, 767 insertions(+), 79 deletions(-) create mode 100644 gitnexus/test/unit/repro-3291-staleness-indexed-ref.test.ts diff --git a/.claude/skills/gitnexus-debugging/SKILL.md b/.claude/skills/gitnexus-debugging/SKILL.md index 41fb568f8..6051c33c4 100644 --- a/.claude/skills/gitnexus-debugging/SKILL.md +++ b/.claude/skills/gitnexus-debugging/SKILL.md @@ -42,6 +42,7 @@ diagnosis. ``` > If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. +> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable. ## Checklist diff --git a/.claude/skills/gitnexus-exploring/SKILL.md b/.claude/skills/gitnexus-exploring/SKILL.md index 46fc187ce..89f9a3884 100644 --- a/.claude/skills/gitnexus-exploring/SKILL.md +++ b/.claude/skills/gitnexus-exploring/SKILL.md @@ -36,6 +36,7 @@ the bound repository and index freshness alongside your explanation. ``` > If step 2 says "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. +> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable. ## Checklist diff --git a/.claude/skills/gitnexus-guide/SKILL.md b/.claude/skills/gitnexus-guide/SKILL.md index bf3c73948..126886fd7 100644 --- a/.claude/skills/gitnexus-guide/SKILL.md +++ b/.claude/skills/gitnexus-guide/SKILL.md @@ -16,6 +16,7 @@ For any task involving code understanding, debugging, impact analysis, or refact 3. **Follow the skill's workflow and checklist** > If step 1 warns the index is stale, run `node .gitnexus/run.cjs analyze` in the terminal first. +> On `query` / `context` / `impact` / `cypher`, read `staleness.status` and `staleness.branch`/`lastCommit` before using the answer. Re-analyze only for `behind` or `diverged`. ## Skills @@ -83,11 +84,32 @@ Notes: `offset` ≥ `total` returns an empty page (with `total` still reported). ### Inline staleness signal (`query` / `context` / `impact` / `cypher`) -These four hot read tools attach a non-blocking `staleness` field to their response when the index is not at the checkout's current HEAD — the same `{ status, commitsBehind?, hint? }` shape `list_repos` already reports — so a direct tool call surfaces a stale index without a separate `list_repos` call: +These four hot read tools attach a non-blocking `staleness` field to every response, in the shape `{ status, branch?, lastCommit, indexedAt, measuredAgainst, commitsBehind?, hint? }`. It answers two different questions at once: **which index answered** and **how fresh it is**. The identity half is why the field is present even when nothing is wrong — an answer computed from a branch-pinned index is otherwise indistinguishable from one computed from the default branch (#3291): ```jsonc { /* …the tool's normal result… */ - "staleness": { "status": "behind", "commitsBehind": 3, "hint": "⚠️ Index is 3 commits behind HEAD. Run analyze tool to update." } + "staleness": { + "status": "current", + "branch": "feature/checkout-v2", + "lastCommit": "4f2a1c9e8b7d6a5c4e3f2a1b0c9d8e7f6a5b4c3d", + "indexedAt": "2026-09-15T07:12:00.000Z", + "measuredAgainst": "HEAD" + } +} +``` + +`status: "current"` here means *this index is at the HEAD of the clone it was built from* — not that it is current with the default branch. `measuredAgainst` names what `commitsBehind` is counted against: the checked-out HEAD of that clone, never the remote. `branch` is the branch the index represents; it is absent for a detached HEAD, a non-git folder, or a legacy index that never recorded one, so read `lastCommit` when you need an identifier that is always present. + +When the index is behind that HEAD, the count and hint ride along: + +```jsonc +{ /* …the tool's normal result… */ + "staleness": { + "status": "behind", "commitsBehind": 3, "branch": "main", + "lastCommit": "a0c945022d06b8815f93ffd8838df9ed5c08cbc0", + "indexedAt": "2026-09-04T20:45:47.481Z", "measuredAgainst": "HEAD", + "hint": "⚠️ Index is 3 commits behind HEAD. Run analyze tool to update." + } } ``` @@ -95,11 +117,18 @@ These four hot read tools attach a non-blocking `staleness` field to their respo ```jsonc { /* …the tool's normal result… */ - "staleness": { "status": "diverged", "hint": "⚠️ Index is not at HEAD and the commit gap could not be counted — the recorded commit may no longer be in this clone's history. Run analyze tool to update." } + "staleness": { + "status": "diverged", "branch": "main", + "lastCommit": "a0c945022d06b8815f93ffd8838df9ed5c08cbc0", + "indexedAt": "2026-09-04T20:45:47.481Z", "measuredAgainst": "HEAD", + "hint": "⚠️ Index is not at HEAD and the commit gap could not be counted — the recorded commit may no longer be in this clone's history. Run analyze tool to update." + } } ``` -The field is **absent when the index is current**, and these four tools also omit it when the freshness check could not run at all — that case is `status: "unknown"`, which only the `list_repos` listing reports. So its presence means the status is not `current`: read `status` before using `commitsBehind`. It is only ever added to object results — raw-array `cypher` output and error envelopes are returned unchanged. `@group`-targeted calls do not carry it (multi-repo staleness is ill-defined). When you see it, the graph may be behind the working tree — re-run `analyze` before trusting blast-radius or dependence answers. +So: **read `status` before using `commitsBehind`**, and read `branch`/`lastCommit` before assuming which ref the answer describes. `status: "unknown"` means the freshness check could not run at all (a `--skip-git` folder has no history to measure) — the ref is still reported, because which index answered is knowable even when its freshness is not. The field is only ever added to object results — raw-array `cypher` output and error envelopes are returned unchanged. `@group`-targeted calls do not carry it (multi-repo staleness is ill-defined). Re-run `analyze` only for `behind` or `diverged` — those mean the index is not at this clone's HEAD. `unknown` is unmeasurable, not stale; analyze cannot make it `current` unless git history exists. + +`list_repos` and the HTTP repo routes are unchanged: they omit `staleness` entirely for a current index and report the ref through their own top-level `branch` / `lastCommit` / `indexedAt` fields. ### Taint findings (`explain`) diff --git a/.claude/skills/gitnexus-impact-analysis/SKILL.md b/.claude/skills/gitnexus-impact-analysis/SKILL.md index 85d90c90d..05d0ef26e 100644 --- a/.claude/skills/gitnexus-impact-analysis/SKILL.md +++ b/.claude/skills/gitnexus-impact-analysis/SKILL.md @@ -53,6 +53,7 @@ Repository: () Worktree: Index: , behind HEA ``` > If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. +> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable. > If `.gitnexus/run.cjs` is missing, replace `node .gitnexus/run.cjs` with `npx gitnexus` in the fallback commands. ## Checklist diff --git a/.claude/skills/gitnexus-refactoring/SKILL.md b/.claude/skills/gitnexus-refactoring/SKILL.md index 9d63eb6e3..7bcc12711 100644 --- a/.claude/skills/gitnexus-refactoring/SKILL.md +++ b/.claude/skills/gitnexus-refactoring/SKILL.md @@ -46,6 +46,7 @@ checkout and reports nothing changed, which reads as a verified refactor. ``` > If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. +> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable. ## Checklists diff --git a/AGENTS.md b/AGENTS.md index d15b02274..e6ce88e8a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,6 +116,7 @@ mirror. `gitnexus/test/unit/shipped-skills-sync.test.ts` guards the copies. Toke This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 relationships, 918 execution flows). > Index stale? Run `node .gitnexus/run.cjs analyze --index-only` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? Bootstrap with `npx`, `bunx`, or `pnpm dlx` — e.g. `bunx gitnexus@latest analyze` (npm 11 npx crash; #1939). +> On query/context/impact/cypher object results, read staleness.status and branch/lastCommit. Re-analyze only for behind or diverged — current is clone HEAD, not main. ## Always Do diff --git a/CLAUDE.md b/CLAUDE.md index 069163232..0d418c9b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,6 +65,7 @@ See the ` … ` block in **[AGENTS.m This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 relationships, 918 execution flows). > Index stale? Run `node .gitnexus/run.cjs analyze --index-only` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? Bootstrap with `npx`, `bunx`, or `pnpm dlx` — e.g. `bunx gitnexus@latest analyze` (npm 11 npx crash; #1939). +> On query/context/impact/cypher object results, read staleness.status and branch/lastCommit. Re-analyze only for behind or diverged — current is clone HEAD, not main. ## Always Do diff --git a/gitnexus-claude-plugin/skills/gitnexus-debugging/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-debugging/SKILL.md index 41fb568f8..6051c33c4 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-debugging/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-debugging/SKILL.md @@ -42,6 +42,7 @@ diagnosis. ``` > If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. +> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable. ## Checklist diff --git a/gitnexus-claude-plugin/skills/gitnexus-exploring/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-exploring/SKILL.md index 46fc187ce..89f9a3884 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-exploring/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-exploring/SKILL.md @@ -36,6 +36,7 @@ the bound repository and index freshness alongside your explanation. ``` > If step 2 says "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. +> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable. ## Checklist diff --git a/gitnexus-claude-plugin/skills/gitnexus-guide/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-guide/SKILL.md index bf3c73948..126886fd7 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-guide/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-guide/SKILL.md @@ -16,6 +16,7 @@ For any task involving code understanding, debugging, impact analysis, or refact 3. **Follow the skill's workflow and checklist** > If step 1 warns the index is stale, run `node .gitnexus/run.cjs analyze` in the terminal first. +> On `query` / `context` / `impact` / `cypher`, read `staleness.status` and `staleness.branch`/`lastCommit` before using the answer. Re-analyze only for `behind` or `diverged`. ## Skills @@ -83,11 +84,32 @@ Notes: `offset` ≥ `total` returns an empty page (with `total` still reported). ### Inline staleness signal (`query` / `context` / `impact` / `cypher`) -These four hot read tools attach a non-blocking `staleness` field to their response when the index is not at the checkout's current HEAD — the same `{ status, commitsBehind?, hint? }` shape `list_repos` already reports — so a direct tool call surfaces a stale index without a separate `list_repos` call: +These four hot read tools attach a non-blocking `staleness` field to every response, in the shape `{ status, branch?, lastCommit, indexedAt, measuredAgainst, commitsBehind?, hint? }`. It answers two different questions at once: **which index answered** and **how fresh it is**. The identity half is why the field is present even when nothing is wrong — an answer computed from a branch-pinned index is otherwise indistinguishable from one computed from the default branch (#3291): ```jsonc { /* …the tool's normal result… */ - "staleness": { "status": "behind", "commitsBehind": 3, "hint": "⚠️ Index is 3 commits behind HEAD. Run analyze tool to update." } + "staleness": { + "status": "current", + "branch": "feature/checkout-v2", + "lastCommit": "4f2a1c9e8b7d6a5c4e3f2a1b0c9d8e7f6a5b4c3d", + "indexedAt": "2026-09-15T07:12:00.000Z", + "measuredAgainst": "HEAD" + } +} +``` + +`status: "current"` here means *this index is at the HEAD of the clone it was built from* — not that it is current with the default branch. `measuredAgainst` names what `commitsBehind` is counted against: the checked-out HEAD of that clone, never the remote. `branch` is the branch the index represents; it is absent for a detached HEAD, a non-git folder, or a legacy index that never recorded one, so read `lastCommit` when you need an identifier that is always present. + +When the index is behind that HEAD, the count and hint ride along: + +```jsonc +{ /* …the tool's normal result… */ + "staleness": { + "status": "behind", "commitsBehind": 3, "branch": "main", + "lastCommit": "a0c945022d06b8815f93ffd8838df9ed5c08cbc0", + "indexedAt": "2026-09-04T20:45:47.481Z", "measuredAgainst": "HEAD", + "hint": "⚠️ Index is 3 commits behind HEAD. Run analyze tool to update." + } } ``` @@ -95,11 +117,18 @@ These four hot read tools attach a non-blocking `staleness` field to their respo ```jsonc { /* …the tool's normal result… */ - "staleness": { "status": "diverged", "hint": "⚠️ Index is not at HEAD and the commit gap could not be counted — the recorded commit may no longer be in this clone's history. Run analyze tool to update." } + "staleness": { + "status": "diverged", "branch": "main", + "lastCommit": "a0c945022d06b8815f93ffd8838df9ed5c08cbc0", + "indexedAt": "2026-09-04T20:45:47.481Z", "measuredAgainst": "HEAD", + "hint": "⚠️ Index is not at HEAD and the commit gap could not be counted — the recorded commit may no longer be in this clone's history. Run analyze tool to update." + } } ``` -The field is **absent when the index is current**, and these four tools also omit it when the freshness check could not run at all — that case is `status: "unknown"`, which only the `list_repos` listing reports. So its presence means the status is not `current`: read `status` before using `commitsBehind`. It is only ever added to object results — raw-array `cypher` output and error envelopes are returned unchanged. `@group`-targeted calls do not carry it (multi-repo staleness is ill-defined). When you see it, the graph may be behind the working tree — re-run `analyze` before trusting blast-radius or dependence answers. +So: **read `status` before using `commitsBehind`**, and read `branch`/`lastCommit` before assuming which ref the answer describes. `status: "unknown"` means the freshness check could not run at all (a `--skip-git` folder has no history to measure) — the ref is still reported, because which index answered is knowable even when its freshness is not. The field is only ever added to object results — raw-array `cypher` output and error envelopes are returned unchanged. `@group`-targeted calls do not carry it (multi-repo staleness is ill-defined). Re-run `analyze` only for `behind` or `diverged` — those mean the index is not at this clone's HEAD. `unknown` is unmeasurable, not stale; analyze cannot make it `current` unless git history exists. + +`list_repos` and the HTTP repo routes are unchanged: they omit `staleness` entirely for a current index and report the ref through their own top-level `branch` / `lastCommit` / `indexedAt` fields. ### Taint findings (`explain`) diff --git a/gitnexus-claude-plugin/skills/gitnexus-impact-analysis/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-impact-analysis/SKILL.md index 85d90c90d..05d0ef26e 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-impact-analysis/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-impact-analysis/SKILL.md @@ -53,6 +53,7 @@ Repository: () Worktree: Index: , behind HEA ``` > If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. +> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable. > If `.gitnexus/run.cjs` is missing, replace `node .gitnexus/run.cjs` with `npx gitnexus` in the fallback commands. ## Checklist diff --git a/gitnexus-claude-plugin/skills/gitnexus-refactoring/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-refactoring/SKILL.md index 9d63eb6e3..7bcc12711 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-refactoring/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-refactoring/SKILL.md @@ -46,6 +46,7 @@ checkout and reports nothing changed, which reads as a verified refactor. ``` > If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. +> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable. ## Checklists diff --git a/gitnexus-cursor-integration/skills/gitnexus-debugging/SKILL.md b/gitnexus-cursor-integration/skills/gitnexus-debugging/SKILL.md index 41fb568f8..6051c33c4 100644 --- a/gitnexus-cursor-integration/skills/gitnexus-debugging/SKILL.md +++ b/gitnexus-cursor-integration/skills/gitnexus-debugging/SKILL.md @@ -42,6 +42,7 @@ diagnosis. ``` > If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. +> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable. ## Checklist diff --git a/gitnexus-cursor-integration/skills/gitnexus-exploring/SKILL.md b/gitnexus-cursor-integration/skills/gitnexus-exploring/SKILL.md index 46fc187ce..89f9a3884 100644 --- a/gitnexus-cursor-integration/skills/gitnexus-exploring/SKILL.md +++ b/gitnexus-cursor-integration/skills/gitnexus-exploring/SKILL.md @@ -36,6 +36,7 @@ the bound repository and index freshness alongside your explanation. ``` > If step 2 says "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. +> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable. ## Checklist diff --git a/gitnexus-cursor-integration/skills/gitnexus-impact-analysis/SKILL.md b/gitnexus-cursor-integration/skills/gitnexus-impact-analysis/SKILL.md index 85d90c90d..05d0ef26e 100644 --- a/gitnexus-cursor-integration/skills/gitnexus-impact-analysis/SKILL.md +++ b/gitnexus-cursor-integration/skills/gitnexus-impact-analysis/SKILL.md @@ -53,6 +53,7 @@ Repository: () Worktree: Index: , behind HEA ``` > If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. +> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable. > If `.gitnexus/run.cjs` is missing, replace `node .gitnexus/run.cjs` with `npx gitnexus` in the fallback commands. ## Checklist diff --git a/gitnexus-cursor-integration/skills/gitnexus-refactoring/SKILL.md b/gitnexus-cursor-integration/skills/gitnexus-refactoring/SKILL.md index 9d63eb6e3..7bcc12711 100644 --- a/gitnexus-cursor-integration/skills/gitnexus-refactoring/SKILL.md +++ b/gitnexus-cursor-integration/skills/gitnexus-refactoring/SKILL.md @@ -46,6 +46,7 @@ checkout and reports nothing changed, which reads as a verified refactor. ``` > If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. +> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable. ## Checklists diff --git a/gitnexus/CHANGELOG.md b/gitnexus/CHANGELOG.md index 082debfe7..30303b213 100644 --- a/gitnexus/CHANGELOG.md +++ b/gitnexus/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to GitNexus will be documented in this file. ## [Unreleased] +### Changed + +- **MCP `query` / `context` / `impact` / `cypher` always attach a ref-carrying `staleness` field** — object results include it even when `status` is `current`. Absence is no longer the freshness signal: read `staleness.status` (`behind`/`diverged` vs `current`/`unknown`) and `branch`/`lastCommit` for which index answered. `list_repos` and the HTTP repo routes are unchanged (still omit `staleness` when current; the ref is top-level) (#3291, #3293) + ## [1.6.12] - 2026-09-12 ### Added diff --git a/gitnexus/skills/gitnexus-debugging.md b/gitnexus/skills/gitnexus-debugging.md index 41fb568f8..6051c33c4 100644 --- a/gitnexus/skills/gitnexus-debugging.md +++ b/gitnexus/skills/gitnexus-debugging.md @@ -42,6 +42,7 @@ diagnosis. ``` > If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. +> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable. ## Checklist diff --git a/gitnexus/skills/gitnexus-exploring.md b/gitnexus/skills/gitnexus-exploring.md index 46fc187ce..89f9a3884 100644 --- a/gitnexus/skills/gitnexus-exploring.md +++ b/gitnexus/skills/gitnexus-exploring.md @@ -36,6 +36,7 @@ the bound repository and index freshness alongside your explanation. ``` > If step 2 says "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. +> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable. ## Checklist diff --git a/gitnexus/skills/gitnexus-guide.md b/gitnexus/skills/gitnexus-guide.md index c96616130..126886fd7 100644 --- a/gitnexus/skills/gitnexus-guide.md +++ b/gitnexus/skills/gitnexus-guide.md @@ -16,6 +16,7 @@ For any task involving code understanding, debugging, impact analysis, or refact 3. **Follow the skill's workflow and checklist** > If step 1 warns the index is stale, run `node .gitnexus/run.cjs analyze` in the terminal first. +> On `query` / `context` / `impact` / `cypher`, read `staleness.status` and `staleness.branch`/`lastCommit` before using the answer. Re-analyze only for `behind` or `diverged`. ## Skills @@ -81,6 +82,54 @@ list_repos { offset: 400 } → repos 401–437, hasMore false Notes: `offset` ≥ `total` returns an empty page (with `total` still reported). Out-of-range or malformed `limit`/`offset` (non-integer, `limit` outside `[1, 200]`, `offset < 0`) are rejected with a clear error — `limit` above the max is rejected, not silently capped. The order is deterministic (lower-cased name, then path), so paging never skips or duplicates an entry while the registry is unchanged. +### Inline staleness signal (`query` / `context` / `impact` / `cypher`) + +These four hot read tools attach a non-blocking `staleness` field to every response, in the shape `{ status, branch?, lastCommit, indexedAt, measuredAgainst, commitsBehind?, hint? }`. It answers two different questions at once: **which index answered** and **how fresh it is**. The identity half is why the field is present even when nothing is wrong — an answer computed from a branch-pinned index is otherwise indistinguishable from one computed from the default branch (#3291): + +```jsonc +{ /* …the tool's normal result… */ + "staleness": { + "status": "current", + "branch": "feature/checkout-v2", + "lastCommit": "4f2a1c9e8b7d6a5c4e3f2a1b0c9d8e7f6a5b4c3d", + "indexedAt": "2026-09-15T07:12:00.000Z", + "measuredAgainst": "HEAD" + } +} +``` + +`status: "current"` here means *this index is at the HEAD of the clone it was built from* — not that it is current with the default branch. `measuredAgainst` names what `commitsBehind` is counted against: the checked-out HEAD of that clone, never the remote. `branch` is the branch the index represents; it is absent for a detached HEAD, a non-git folder, or a legacy index that never recorded one, so read `lastCommit` when you need an identifier that is always present. + +When the index is behind that HEAD, the count and hint ride along: + +```jsonc +{ /* …the tool's normal result… */ + "staleness": { + "status": "behind", "commitsBehind": 3, "branch": "main", + "lastCommit": "a0c945022d06b8815f93ffd8838df9ed5c08cbc0", + "indexedAt": "2026-09-04T20:45:47.481Z", "measuredAgainst": "HEAD", + "hint": "⚠️ Index is 3 commits behind HEAD. Run analyze tool to update." + } +} +``` + +`commitsBehind` is present only when git counted the gap. When git could not count it but HEAD still resolves to a commit other than the indexed one — usually because the indexed commit is no longer in the clone's history — the index is provably not at HEAD with no countable gap, so no number is reported: + +```jsonc +{ /* …the tool's normal result… */ + "staleness": { + "status": "diverged", "branch": "main", + "lastCommit": "a0c945022d06b8815f93ffd8838df9ed5c08cbc0", + "indexedAt": "2026-09-04T20:45:47.481Z", "measuredAgainst": "HEAD", + "hint": "⚠️ Index is not at HEAD and the commit gap could not be counted — the recorded commit may no longer be in this clone's history. Run analyze tool to update." + } +} +``` + +So: **read `status` before using `commitsBehind`**, and read `branch`/`lastCommit` before assuming which ref the answer describes. `status: "unknown"` means the freshness check could not run at all (a `--skip-git` folder has no history to measure) — the ref is still reported, because which index answered is knowable even when its freshness is not. The field is only ever added to object results — raw-array `cypher` output and error envelopes are returned unchanged. `@group`-targeted calls do not carry it (multi-repo staleness is ill-defined). Re-run `analyze` only for `behind` or `diverged` — those mean the index is not at this clone's HEAD. `unknown` is unmeasurable, not stale; analyze cannot make it `current` unless git history exists. + +`list_repos` and the HTTP repo routes are unchanged: they omit `staleness` entirely for a current index and report the ref through their own top-level `branch` / `lastCommit` / `indexedAt` fields. + ### Taint findings (`explain`) `explain` returns taint findings recorded by `gitnexus analyze --pdg` — intra-procedural `TAINTED` edges plus cross-function `TAINT_PATH` hops where the interprocedural taint phase found a function-level source→sink chain. Each finding includes a sink category (command-injection, code-injection, path-traversal, sql-injection, xss), source/sink lines, and the ordered hop path with the variable carried on each hop. diff --git a/gitnexus/skills/gitnexus-impact-analysis.md b/gitnexus/skills/gitnexus-impact-analysis.md index 85d90c90d..05d0ef26e 100644 --- a/gitnexus/skills/gitnexus-impact-analysis.md +++ b/gitnexus/skills/gitnexus-impact-analysis.md @@ -53,6 +53,7 @@ Repository: () Worktree: Index: , behind HEA ``` > If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. +> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable. > If `.gitnexus/run.cjs` is missing, replace `node .gitnexus/run.cjs` with `npx gitnexus` in the fallback commands. ## Checklist diff --git a/gitnexus/skills/gitnexus-refactoring.md b/gitnexus/skills/gitnexus-refactoring.md index 9d63eb6e3..7bcc12711 100644 --- a/gitnexus/skills/gitnexus-refactoring.md +++ b/gitnexus/skills/gitnexus-refactoring.md @@ -46,6 +46,7 @@ checkout and reports nothing changed, which reads as a verified refactor. ``` > If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. +> Hot-tool `staleness` names which index answered (`branch`/`lastCommit`) and how fresh it is (`status`). Re-analyze only for `behind` or `diverged` — `current` is identity, `unknown` is unmeasurable. ## Checklists diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index c7b3a934f..69d9b70d1 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -221,6 +221,7 @@ ${tableBody}` This project is indexed by GitNexus as **${projectName}**${noStats ? '' : ` (${stats.nodes || 0} symbols, ${stats.edges || 0} relationships, ${stats.processes || 0} execution flows)`}. > Index stale? Run \`${runner} analyze --index-only\` from the project root — it auto-selects an available runner. ${bootstrapNote} +> On query/context/impact/cypher object results, read staleness.status and branch/lastCommit. Re-analyze only for behind or diverged — current is clone HEAD, not main. ## Always Do diff --git a/gitnexus/src/core/staleness-status.ts b/gitnexus/src/core/staleness-status.ts index f4823c66c..97a8cce94 100644 --- a/gitnexus/src/core/staleness-status.ts +++ b/gitnexus/src/core/staleness-status.ts @@ -52,17 +52,52 @@ export interface StalenessInfo { export const stalenessStatus = (info: StalenessInfo): StalenessStatus => info.status ?? (info.isStale ? 'behind' : 'current'); +/** + * The ref an index represents, as the resolved repo handle already knows it. + * `lastCommit` and `indexedAt` are always recorded on a handle; `branch` is + * best-effort — a plain analyze stamps the checked-out branch, but a detached + * HEAD, a non-git folder, or a legacy index that never recorded one leaves it + * absent (`run-analyze.ts`: `branchLabel ?? existingMeta?.branch`). + */ +export interface IndexedRef { + branch?: string; + lastCommit: string; + indexedAt: string; +} + /** * The wire shape for staleness on every surface: MCP `list_repos`, the hot read * tools, and the `serve` repo routes. One builder so one fact has one shape * (#3232 review: "same sentinel as MCP"). * - * Absent for `current`, as before. `commitsBehind` is present only when git - * actually counted it, so `diverged` carries `status` and `hint` but no number — - * inventing one would be the silent wrong answer this exists to remove. + * Two forms, chosen by whether the caller supplies a {@link IndexedRef}: + * + * - **Without a ref** — absent for `current`, as before. That is what + * `list_repos` and the `serve` routes emit; they already report the ref + * through their own top-level `branch` / `lastCommit` / `indexedAt` fields + * (#3226), so repeating it inside the payload would duplicate it. + * - **With a ref** — emitted for EVERY status, naming the index it describes. + * The hot read tools have nowhere else to put it: `attachToolStaleness` may + * add exactly one key to an arbitrary tool result. Without it `current` is + * indistinguishable between an index of the default branch and one of some + * feature branch, because `current` is a statement about a *ref*, not about + * the repository (#3291). + * + * `commitsBehind` is present only when git actually counted it, so `diverged` + * carries `status` and `hint` but no number — inventing one would be the silent + * wrong answer this exists to remove. */ export interface StalenessPayload { - status: Exclude; + status: StalenessStatus; + /** Ref identity — present only on the ref-carrying (hot read tool) form. */ + branch?: string; + lastCommit?: string; + indexedAt?: string; + /** + * What `commitsBehind` is counted against: the checked-out HEAD of the clone + * this index was built from, never the remote or the default branch. + */ + measuredAgainst?: 'HEAD'; commitsBehind?: number; hint?: string; } @@ -72,18 +107,36 @@ export interface StalenessPayload { * nothing to report. * * `unknown` is emitted only when `includeUnknown` is set. A listing a monitor - * reads wants it; the hot read tools do not, because a `--skip-git` folder has - * no history to measure and would otherwise repeat that on every response. + * reads wants it; the no-ref hot-tool form does not, because a `--skip-git` + * folder has no history to measure and would otherwise repeat that on every + * response. The ref-carrying form reports it regardless — which index answered + * is knowable even when its freshness is not. */ export const stalenessPayload = ( info: StalenessInfo | undefined, - opts: { includeUnknown?: boolean } = {}, + opts: { includeUnknown?: boolean; ref?: IndexedRef } = {}, ): StalenessPayload | undefined => { if (!info) return undefined; const status = stalenessStatus(info); const hint = info.hint ? { hint: info.hint } : {}; - if (status === 'current') return undefined; - if (status === 'unknown') return opts.includeUnknown ? { status } : undefined; - if (status === 'diverged') return { status, ...hint }; - return { status, commitsBehind: info.commitsBehind, ...hint }; + + // No ref: bit-identical to the pre-#3291 output for every status. This is + // what keeps `list_repos` and both `serve` routes byte-stable, and their + // exact-match tests passing unmodified. + if (!opts.ref) { + if (status === 'current') return undefined; + if (status === 'unknown') return opts.includeUnknown ? { status } : undefined; + if (status === 'diverged') return { status, ...hint }; + return { status, commitsBehind: info.commitsBehind, ...hint }; + } + + const ref = { + ...(opts.ref.branch ? { branch: opts.ref.branch } : {}), + lastCommit: opts.ref.lastCommit, + indexedAt: opts.ref.indexedAt, + measuredAgainst: 'HEAD' as const, + }; + if (status === 'current' || status === 'unknown') return { status, ...ref }; + if (status === 'diverged') return { status, ...ref, ...hint }; + return { status, ...ref, commitsBehind: info.commitsBehind, ...hint }; }; diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 5a3f9748d..35f6866eb 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -106,6 +106,7 @@ import { import { checkStalenessAsync, checkCwdMatch } from '../../core/git-staleness.js'; import { stalenessPayload, + type IndexedRef, type StalenessInfo, type StalenessPayload, } from '../../core/staleness-status.js'; @@ -1079,7 +1080,11 @@ interface RepoHandle { lastCommit: string; remoteUrl?: string; stats?: RegistryEntry['stats']; - /** Primary/flat branch name, when known (#2106). */ + /** + * Branch this handle's index describes (#2106/#3291). The flat/workspace + * slot keeps the primary checkout name; `applyBranchScope` overwrites it + * with the pin when serving a `branches[]` sub-index. + */ branch?: string; /** Pinned `--branch` sub-indexes available for this repo, distinct from the flat workspace slot (#2106/#2354). */ branches?: BranchSummary[]; @@ -1463,18 +1468,29 @@ function canCarryStaleness(result: unknown): result is Record { } /** - * #2655: attach a non-blocking `staleness` signal to a tool result when the - * index is not at HEAD, in the same {@link stalenessPayload} shape `list_repos` - * returns. Only ever ADDS a field to a carryable object result (see - * {@link canCarryStaleness}) — it never changes an existing result's shape. + * #2655: attach a non-blocking `staleness` signal to a tool result. Only ever + * ADDS a field to a carryable object result (see {@link canCarryStaleness}) — + * it never changes an existing result's shape. * - * `diverged` is attached: it is a positive finding that the index is not at - * HEAD, only uncountable. `unknown` is not — these are the hot read tools, and a - * `--skip-git` folder has no history to measure, so it would ride on every - * response as noise rather than signal (#3256). + * #3291: `ref` names the index the answer came from. Supplying it switches the + * payload to the ref-carrying form, which reports every status — including + * `current` and `unknown` — because that one added key is the only place a tool + * result can say WHICH index answered. Absence used to be the freshness signal + * here; it could not distinguish a current index of the default branch from a + * current index of some feature branch, since `current` is a statement about a + * ref rather than about the repository. + * + * With no `ref` the pre-#3291 behaviour is unchanged: absent for `current`, + * `diverged` attached as a positive finding that the index is not at HEAD, and + * `unknown` withheld as noise (#3256). A missing `info` still attaches nothing + * either way, which is what keeps a failed freshness probe non-fatal. */ -export function attachToolStaleness(result: unknown, info: StalenessInfo | undefined): unknown { - const staleness = stalenessPayload(info); +export function attachToolStaleness( + result: unknown, + info: StalenessInfo | undefined, + ref?: IndexedRef, +): unknown { + const staleness = stalenessPayload(info, ref ? { ref } : {}); if (!staleness || !canCarryStaleness(result)) { return result; } @@ -2137,6 +2153,8 @@ export class LocalBackend { indexedAt: summary.indexedAt, lastCommit: summary.lastCommit, stats: summary.stats, + // The handle now represents the pin, not the flat slot (#3291). + branch: summary.branch, }; } // Stale summary (sub-index adopted/deleted): refresh so later calls see @@ -2669,15 +2687,27 @@ export class LocalBackend { * skipping the `git` spawn entirely for results that can't carry it (error * envelopes, arrays, non-objects — see {@link canCarryStaleness}) so an * error-returning call pays nothing. + * + * #3291: the ref comes straight off the already-resolved handle, so naming + * the index costs no extra I/O — no git spawn, no metadata read, and the + * `stalenessForTool` TTL cache is untouched. `branch` is passed through as-is + * and is legitimately absent for a detached HEAD or a legacy index; the + * always-present `lastCommit` is what identifies the index in that case. */ private async withToolStaleness(repo: RepoHandle, result: unknown): Promise { if (!canCarryStaleness(result)) return result; // Defensive: `checkStalenessAsync` self-catches today, but a rejection here // must never fail the tool — degrade to no-staleness. Paired with the // evict-on-reject in `stalenessForTool`, a transient failure also can't - // poison the TTL cache entry (#2655 review F1). + // poison the TTL cache entry (#2655 review F1). A rejection leaves `info` + // undefined, and the builder returns nothing for that even with a ref, so + // the degraded path still attaches no field. const staleness = await this.stalenessForTool(repo).catch(() => undefined); - return attachToolStaleness(result, staleness); + return attachToolStaleness(result, staleness, { + branch: repo.branch, + lastCommit: repo.lastCommit, + indexedAt: repo.indexedAt, + }); } /** diff --git a/gitnexus/src/mcp/tools.ts b/gitnexus/src/mcp/tools.ts index 146936f7a..4e6455132 100644 --- a/gitnexus/src/mcp/tools.ts +++ b/gitnexus/src/mcp/tools.ts @@ -88,6 +88,10 @@ const CWD_AWARE_REPO_OMISSION = const MUTATING_REPO_OMISSION = 'Omit only when one repo is indexed or an MCP default is configured; otherwise mutating tools require an explicit repo.'; +/** Always-on identity+freshness field on query/context/impact/cypher object results (#3291). */ +const HOT_READ_STALENESS_NOTE = + "Object results attach `staleness` even when current. Read `staleness.branch`/`lastCommit` for which index answered and `status` for freshness. Re-analyze only for `behind` or `diverged` — `current` is this clone's HEAD, not necessarily the default branch; `unknown` is unmeasurable, not stale. Field is only on object results (not raw-array cypher, error envelopes, or `@group` calls)."; + export const GITNEXUS_TOOLS: ToolDefinition[] = [ { name: 'list_repos', @@ -143,7 +147,9 @@ Hybrid ranking: BM25 keyword + semantic vector search, ranked by Reciprocal Rank GROUP MODE: set "repo" to "@" to search all member repos in that group (merged via RRF), or "@/" to run against a single member (same path keys as in group.yaml). If you use "@" only, the member repo defaults to the lexicographically first key in group.yaml "repos". Prefer resources for contracts/status (see migration from legacy group_* tools). -SERVICE: optional monorepo path prefix (POSIX-style, case-sensitive segments). When "repo" starts with "@", only processes whose symbols fall under that prefix are included. For a normal indexed repo name (no leading @), this field is currently ignored by the server.`, +SERVICE: optional monorepo path prefix (POSIX-style, case-sensitive segments). When "repo" starts with "@", only processes whose symbols fall under that prefix are included. For a normal indexed repo name (no leading @), this field is currently ignored by the server. + +${HOT_READ_STALENESS_NOTE}`, annotations: QUERY_TOOL_ANNOTATIONS, inputSchema: { type: 'object', @@ -254,7 +260,9 @@ TIPS: - Community = auto-detected functional area (Leiden algorithm). Properties: heuristicLabel, cohesion, symbolCount, keywords, description, enrichedBy - Process = execution flow trace from entry point to terminal. Properties: heuristicLabel, processType, stepCount, communities, entryPointId, terminalId - Use heuristicLabel (not label) for human-readable community/process names -- PDG layers (only when indexed with \`--pdg\`): BasicBlock nodes + CFG / CDG (control dependence, branch sense 'T'|'F' in reason) / REACHING_DEF (def→use, variable in reason) edges, all BasicBlock→BasicBlock. Prefer the \`pdg_query\` tool — it anchors + bounds these for you (raw \`[:CDG*]\`/\`[:REACHING_DEF*]\` path scans are unindexed and unbounded).`, +- PDG layers (only when indexed with \`--pdg\`): BasicBlock nodes + CFG / CDG (control dependence, branch sense 'T'|'F' in reason) / REACHING_DEF (def→use, variable in reason) edges, all BasicBlock→BasicBlock. Prefer the \`pdg_query\` tool — it anchors + bounds these for you (raw \`[:CDG*]\`/\`[:REACHING_DEF*]\` path scans are unindexed and unbounded). + +${HOT_READ_STALENESS_NOTE}`, annotations: READ_ONLY_TOOL_ANNOTATIONS, inputSchema: { type: 'object', @@ -307,7 +315,9 @@ REQUIRES RE-INDEX: causes.scopeExtractionFiles, causes.receiverTyping, causes.ex 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". -SERVICE: optional monorepo path prefix (case-sensitive path segments). When "repo" starts with "@", prefix-matches resolved symbol file paths; when a hit is outside the prefix, that member returns an empty payload for the symbol. Ignored for a normal indexed repo name.`, +SERVICE: optional monorepo path prefix (case-sensitive path segments). When "repo" starts with "@", prefix-matches resolved symbol file paths; when a hit is outside the prefix, that member returns an empty payload for the symbol. Ignored for a normal indexed repo name. + +${HOT_READ_STALENESS_NOTE}`, annotations: READ_ONLY_TOOL_ANNOTATIONS, inputSchema: { type: 'object', @@ -521,7 +531,9 @@ 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. '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.`, +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. + +${HOT_READ_STALENESS_NOTE}`, annotations: READ_ONLY_TOOL_ANNOTATIONS, inputSchema: { type: 'object', diff --git a/gitnexus/src/server/repo-projection.ts b/gitnexus/src/server/repo-projection.ts index 5370c608f..b73954482 100644 --- a/gitnexus/src/server/repo-projection.ts +++ b/gitnexus/src/server/repo-projection.ts @@ -27,13 +27,18 @@ export interface RepoProjectionSource { * Staleness through the shared {@link stalenessPayload} builder, so this route * and MCP `list_repos` emit one shape for one fact (#3232 review, #3256). * - * Absent when the index is current. Otherwise `staleness.status` says what git - * could establish: `behind` with the counted `commitsBehind`; `diverged` when - * HEAD has provably moved off the indexed commit but the history needed to - * count the gap is gone — the state a branch-pinned `url` clone reaches once - * git prunes the commit a failed re-index left behind; or `unknown` when the - * repository could not be measured at all. This is a listing a monitor reads, - * so `unknown` is included here, unlike on the hot read tools. + * This listing/HTTP helper uses the no-ref form: absent when the index is + * current; `unknown` is included via `includeUnknown`. Otherwise + * `staleness.status` says what git could establish: `behind` with the counted + * `commitsBehind`; `diverged` when HEAD has provably moved off the indexed + * commit but the history needed to count the gap is gone — the state a + * branch-pinned `url` clone reaches once git prunes the commit a failed + * re-index left behind; or `unknown` when the repository could not be + * measured at all. + * + * Hot read tools (`query`/`context`/`impact`/`cypher`) use the ref-carrying + * form: they emit `unknown` (and `current`) with `branch?`/`lastCommit`/ + * `indexedAt`/`measuredAgainst`. * * All of it measures the index against the local working tree — the same thing * `gitnexus status` and MCP `list_repos` measure — not against the remote. diff --git a/gitnexus/test/integration/objective-c-provider.test.ts b/gitnexus/test/integration/objective-c-provider.test.ts index 949a2f33e..b060636fb 100644 --- a/gitnexus/test/integration/objective-c-provider.test.ts +++ b/gitnexus/test/integration/objective-c-provider.test.ts @@ -104,6 +104,18 @@ function normalizeContext(value: unknown): Record { }; } +/** Drop analyze-time `indexedAt` so incremental vs force surfaces can compare graph fields. */ +function withoutVolatileStaleness(value: unknown): unknown { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return value; + const record = value as Record; + const staleness = record.staleness; + if (staleness === undefined || typeof staleness !== 'object' || staleness === null) { + return record; + } + const { indexedAt: _indexedAt, ...stableStaleness } = staleness as Record; + return { ...record, staleness: stableStaleness }; +} + async function readPersistedObjectiveCSurface(repoRoot: string): Promise> { const backend = new LocalBackend(); try { @@ -186,10 +198,10 @@ async function readPersistedObjectiveCSurface(repoRoot: string): Promise { }); describe('Objective-C provider persisted index behavior', () => { + it('drops analyze-time indexedAt from hot-tool staleness so increment vs force can compare', () => { + expect( + withoutVolatileStaleness({ + markdown: 'ok', + row_count: 1, + staleness: { + status: 'current', + lastCommit: 'abc', + indexedAt: '2026-09-15T00:00:00Z', + measuredAgainst: 'HEAD', + }, + }), + ).toEqual({ + markdown: 'ok', + row_count: 1, + staleness: { status: 'current', lastCommit: 'abc', measuredAgainst: 'HEAD' }, + }); + }); + it('surfaces query/context semantics and keeps incremental results aligned with force rebuild', async () => { const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-objc-provider-index-')); try { diff --git a/gitnexus/test/unit/ai-context.test.ts b/gitnexus/test/unit/ai-context.test.ts index df610f8e7..a7174e674 100644 --- a/gitnexus/test/unit/ai-context.test.ts +++ b/gitnexus/test/unit/ai-context.test.ts @@ -297,6 +297,9 @@ describe('generateAIContextFiles', () => { const content = await fs.readFile(path.join(tmpDir, 'CLAUDE.md'), 'utf-8'); expect(content).toContain('Index stale? Run `node .gitnexus/run.cjs analyze --index-only`'); + expect(content).toContain( + 'On query/context/impact/cypher object results, read staleness.status and branch/lastCommit', + ); expect(content).toContain('## Always Do'); expect(content).toContain('## Never Do'); expect(content).toContain('## Resources'); @@ -337,15 +340,18 @@ describe('generateAIContextFiles', () => { // clause (previously hand-added inside the committed docs instead of this // template, so a real `gitnexus analyze` silently deleted them on every // regeneration — moving them into the template is the fix, and they are - // unconditional text load-bearing enough to warrant the budget) — each time - // with the same argument, that the added line is load-bearing and the block - // is still meaningfully smaller than the original. That is a ratchet with no - // ratchet: an absolute cap can only ever fail on the PR that adds the - // character, and the fix is always to nudge the number. Assert the invariant - // the justifications actually appeal to — the RATIO to the pre-trim size — - // so a legitimate clause fits without ceremony while a genuine re-pad fails. - // (The structural guard is the sibling test asserting the six #856 section - // headers stay deleted; this one bounds bulk.) + // unconditional text load-bearing enough to warrant the budget), then + // 0.65 → 0.70 for the #3291 always-on `staleness` blockquote line (a second + // `>` line, not a new Always-Do bullet — the cheaper durable slot; the + // 0.65 band had ~3 chars of headroom so any useful line required a bump) — + // each time with the same argument, that the added line is load-bearing and + // the block is still meaningfully smaller than the original. That is a + // ratchet with no ratchet: an absolute cap can only ever fail on the PR + // that adds the character, and the fix is always to nudge the number. + // Assert the invariant the justifications actually appeal to — the RATIO + // to the pre-trim size — so a legitimate clause fits without ceremony + // while a genuine re-pad fails. (The structural guard is the sibling test + // asserting the six #856 section headers stay deleted; this one bounds bulk.) const PRE_TRIM_BLOCK_CHARS = 5465; const stats = { nodes: 50, edges: 100, processes: 5 }; await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats); @@ -355,7 +361,7 @@ describe('generateAIContextFiles', () => { content.indexOf(''), content.indexOf(''), ); - expect(block.length).toBeLessThan(PRE_TRIM_BLOCK_CHARS * 0.65); + expect(block.length).toBeLessThan(PRE_TRIM_BLOCK_CHARS * 0.7); }); it('handles empty stats', async () => { diff --git a/gitnexus/test/unit/calltool-dispatch.test.ts b/gitnexus/test/unit/calltool-dispatch.test.ts index dd3cde207..8e688c45d 100644 --- a/gitnexus/test/unit/calltool-dispatch.test.ts +++ b/gitnexus/test/unit/calltool-dispatch.test.ts @@ -441,7 +441,10 @@ describe('LocalBackend.callTool', () => { direction: 'upstream', }); - expect(result).toEqual({ status: 'normalized' }); + // toMatchObject, not toEqual: since #3291 every hot-read-tool response + // also carries the `staleness` ref field. This test is about parameter + // normalization, so it pins the payload it cares about and ignores it. + expect(result).toMatchObject({ status: 'normalized' }); const dispatched = impactSpy.mock.calls[0][1] as Record; expect(dispatched.target).toBe('validate'); expect(dispatched).not.toHaveProperty('name'); @@ -459,7 +462,8 @@ describe('LocalBackend.callTool', () => { file: ' src/auth.ts ', }); - expect(result).toEqual({ status: 'normalized' }); + // toMatchObject: responses carry the #3291 `staleness` ref field too. + expect(result).toMatchObject({ status: 'normalized' }); const dispatched = contextSpy.mock.calls[0][1] as Record; expect(dispatched.file_path).toBe('src/auth.ts'); expect(dispatched).not.toHaveProperty('file'); @@ -476,7 +480,8 @@ describe('LocalBackend.callTool', () => { file: undefined, }); - expect(result).toEqual({ status: 'normalized' }); + // toMatchObject: responses carry the #3291 `staleness` ref field too. + expect(result).toMatchObject({ status: 'normalized' }); expect(contextSpy.mock.calls[0][1]).toMatchObject({ name: 'validate' }); }); @@ -5040,6 +5045,28 @@ describe('LocalBackend.resolveRepo branch scope (#2106)', () => { expect(path.basename(handle.lbugPath)).toBe('lbug'); // The branch handle reports the branch's own commit, not the primary's. expect(handle.lastCommit).toBe('featsha'); + // #3291: the pin's label, not the flat/primary slot — withToolStaleness + // copies handle.branch onto the hot-tool payload. + expect(handle.branch).toBe('feature/x'); + }); + + it('a pinned-branch tool result names the pin in staleness.branch (#3291)', async () => { + // beforeEach clearAllMocks() drops the module-level git-staleness factory + // impl; restore a resolving current so withToolStaleness attaches the ref. + const { checkStalenessAsync } = await import('../../src/core/git-staleness.js'); + (checkStalenessAsync as any).mockResolvedValue({ + isStale: false, + commitsBehind: 0, + status: 'current', + }); + vi.spyOn(backend as any, 'impact').mockResolvedValue({ ok: true }); + const result = (await backend.callTool('impact', { + target: 'doWork', + repo: 'multi', + branch: 'feature/x', + })) as { staleness: { branch?: string; lastCommit?: string } }; + expect(result.staleness.branch).toBe('feature/x'); + expect(result.staleness.lastCommit).toBe('featsha'); }); it('an un-indexed branch throws a clear error', async () => { @@ -5295,11 +5322,20 @@ describe('LocalBackend tool-staleness cache keying (#2655 review)', () => { branch: 'x', }); - // Flat index (lastCommit=FLATSHA) is 5 behind -> field present. - expect(flatRes).toMatchObject({ staleness: { commitsBehind: 5 } }); - // Branch index (different lbugPath + lastCommit) is current; it must NOT - // inherit the flat handle's cached staleness (the pre-fix repoPath-keyed bug). - expect(branchRes).not.toHaveProperty('staleness'); + // Flat index (lastCommit=FLATSHA) is 5 behind -> counted gap reported. + expect(flatRes).toMatchObject({ + staleness: { status: 'behind', commitsBehind: 5, lastCommit: 'FLATSHA' }, + }); + // Branch index (different lbugPath + lastCommit) is current. Since #3291 the + // field rides on every response, so absence can no longer be the proof; what + // shows the flat handle's cached entry was NOT reused (the pre-fix + // repoPath-keyed bug) is that this one reports its OWN commit and its own + // status, with no trace of the flat handle's counted gap. + expect(branchRes).toMatchObject({ + staleness: { status: 'current', lastCommit: 'BRANCHSHA' }, + }); + const branchStaleness = (branchRes as { staleness: { commitsBehind?: number } }).staleness; + expect(branchStaleness.commitsBehind).toBeUndefined(); }); }); diff --git a/gitnexus/test/unit/repro-3291-staleness-indexed-ref.test.ts b/gitnexus/test/unit/repro-3291-staleness-indexed-ref.test.ts new file mode 100644 index 000000000..fa79f6c85 --- /dev/null +++ b/gitnexus/test/unit/repro-3291-staleness-indexed-ref.test.ts @@ -0,0 +1,263 @@ +/** + * #3291: the tool staleness payload names the ref it describes, so an `impact` + * answer computed from a branch-pinned index is distinguishable from one + * computed from a current index of the default branch. + * + * Unlike the rest of the staleness suite, this file deliberately does NOT mock + * `core/git-staleness.js`. Real `git rev-list` runs against two real clones — + * that is the whole point: the behaviour is a composition of the real + * measurement (`..HEAD`, against the clone's own checkout) with + * what `stalenessPayload` does about it, and stubbing the measurement would + * assume the step under test. + * + * Before the fix both responses carried no `staleness` field at all and were + * byte-identical; the serialized-inequality assertion below is the one that + * could not discriminate them. + */ +import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const { lbugMocks } = vi.hoisted(() => ({ + lbugMocks: { + 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), + }, +})); + +vi.mock('../../src/core/lbug/pool-adapter.js', async (importOriginal) => ({ + ...(await importOriginal()), + ...lbugMocks, +})); +vi.mock('../../src/mcp/core/lbug-adapter.js', async (importOriginal) => ({ + ...(await importOriginal()), + ...lbugMocks, +})); + +// `readRegistry` is pinned to empty so the real `checkCwdMatch` (reached through +// the un-mocked git-staleness module) cannot read the developer's own registry. +vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => ({ + ...(await importOriginal()), + listRegisteredRepos: vi.fn().mockResolvedValue([]), + readRegistry: vi.fn().mockResolvedValue([]), + cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }), + findSiblingClones: vi.fn().mockResolvedValue([]), +})); + +vi.mock('../../src/core/search/bm25-index.js', () => ({ + searchFTSFromLbug: vi.fn().mockResolvedValue({ results: [], ftsAvailable: true }), +})); +vi.mock('../../src/mcp/core/embedder.js', () => ({ + embedQuery: vi.fn().mockResolvedValue([]), + getEmbeddingDims: vi.fn().mockReturnValue(384), +})); + +import { LocalBackend } from '../../src/mcp/local/local-backend.js'; + +const git = (cwd: string, ...args: string[]): string => + execFileSync('git', args, { + cwd, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }).trim(); + +const commit = (repo: string, file: string, body: string): void => { + writeFileSync(path.join(repo, file), body); + git(repo, 'add', '-A'); + git(repo, 'commit', '-m', `add ${file}`); +}; + +const fixtureRoots: string[] = []; + +/** + * A source repo with `main` three commits deep and `feature/x` branched off the + * first, then two clones of it: one on `main`, one pinned to `feature/x` — the + * separate-clone-per-pinned-branch layout #3199 made routine. + */ +function makeClones(): { mainClone: string; branchClone: string } { + const root = mkdtempSync(path.join(os.tmpdir(), 'gnx-3291-')); + fixtureRoots.push(root); + + const source = path.join(root, 'source'); + mkdirSync(source); + git(source, 'init', '-b', 'main'); + git(source, 'config', 'user.email', 'repro@3291.test'); + git(source, 'config', 'user.name', 'Repro 3291'); + + commit(source, 'a.ts', 'export const a = 1;\n'); + git(source, 'checkout', '-b', 'feature/x'); + commit(source, 'feature.ts', 'export const f = 1;\n'); + git(source, 'checkout', 'main'); + commit(source, 'b.ts', 'export const b = 2;\n'); + commit(source, 'c.ts', 'export const c = 3;\n'); + + const mainClone = path.join(root, 'clone-main'); + const branchClone = path.join(root, 'clone-feature'); + git(root, 'clone', '--branch', 'main', source, mainClone); + git(root, 'clone', '--branch', 'feature/x', source, branchClone); + + return { mainClone, branchClone }; +} + +interface ToolStaleness { + status: string; + branch?: string; + lastCommit?: string; + indexedAt?: string; + measuredAgainst?: string; + commitsBehind?: number; + hint?: string; +} + +const INDEXED_AT = '2026-09-15T00:00:00Z'; +const IMPACT_RESULT = { target: 'doWork', impactedCount: 0, risk: 'LOW' }; + +const handleFor = (repoPath: string, lastCommit: string, branch: string) => ({ + id: branch, + name: 'repro-3291', + repoPath, + storagePath: path.join(repoPath, '.gitnexus'), + lbugPath: path.join(repoPath, '.gitnexus', branch, 'lbug'), + indexedAt: INDEXED_AT, + lastCommit, + branch, +}); + +afterAll(() => { + for (const dir of fixtureRoots) rmSync(dir, { recursive: true, force: true }); +}); + +describe('#3291 — tool staleness names the indexed ref', () => { + let backend: LocalBackend; + + beforeEach(async () => { + vi.clearAllMocks(); + backend = new LocalBackend(); + await backend.init(); + }); + + it('distinguishes a behind-main feature-branch index from a current main index', async () => { + const { mainClone, branchClone } = makeClones(); + const mainHead = git(mainClone, 'rev-parse', 'HEAD'); + const branchHead = git(branchClone, 'rev-parse', 'HEAD'); + + // Precondition, measured rather than assumed: the feature-branch clone is + // freshly analyzed at its own head, and that head is genuinely two commits + // short of the mainline. Without this the test would pass vacuously. + const behindMain = Number( + git(branchClone, 'rev-list', '--count', `${branchHead}..origin/main`), + ); + expect(behindMain).toBe(2); + + const resolve = vi.spyOn(backend, 'selectToolRepository'); + resolve.mockResolvedValueOnce(handleFor(mainClone, mainHead, 'main') as never); + resolve.mockResolvedValueOnce(handleFor(branchClone, branchHead, 'feature/x') as never); + + // The graph answer itself is held constant so the only thing that can differ + // between the two responses is the freshness signalling under test. + vi.spyOn(backend as unknown as { impact: unknown }, 'impact').mockResolvedValue( + IMPACT_RESULT as never, + ); + + const fromMain = (await backend.callTool('impact', { + target: 'doWork', + repo: 'repro-3291', + })) as { staleness: ToolStaleness }; + const fromBranch = (await backend.callTool('impact', { + target: 'doWork', + repo: 'repro-3291', + branch: 'feature/x', + })) as { staleness: ToolStaleness }; + + // Non-vacuity: both calls really produced the graph answer. An error + // envelope or a non-object would be skipped by `canCarryStaleness` and the + // assertions below would hold for the wrong reason. + expect(fromMain).toMatchObject(IMPACT_RESULT); + expect(fromBranch).toMatchObject(IMPACT_RESULT); + + // Each index measures 0 commits behind its OWN checkout, so both are + // `current` — but each now says which ref that statement is about. + expect(fromBranch.staleness).toEqual({ + status: 'current', + branch: 'feature/x', + lastCommit: branchHead, + indexedAt: INDEXED_AT, + measuredAgainst: 'HEAD', + }); + expect(fromMain.staleness).toMatchObject({ + status: 'current', + branch: 'main', + lastCommit: mainHead, + }); + + // The regression itself: before the fix these two were byte-identical, so an + // answer two commits short of the mainline read exactly like a current one. + expect(JSON.stringify(fromBranch)).not.toBe(JSON.stringify(fromMain)); + + // And the ref is recoverable from the response alone, with no second call. + const serialized = JSON.stringify(fromBranch); + expect(serialized).toContain('feature/x'); + expect(serialized).toContain(branchHead); + }); + + // Negative control. The same wiring — real git, real `checkStalenessAsync`, + // real `attachToolStaleness` — must still report a counted gap when the index + // is behind its own checkout. Without this, the ref-carrying payload above + // could be masking a freshness signal that no longer works. + it('still reports the counted gap when the index is behind its own checkout', async () => { + const { mainClone } = makeClones(); + const twoBack = git(mainClone, 'rev-parse', 'HEAD~2'); + + vi.spyOn(backend, 'selectToolRepository').mockResolvedValue( + handleFor(mainClone, twoBack, 'main') as never, + ); + vi.spyOn(backend as unknown as { impact: unknown }, 'impact').mockResolvedValue( + IMPACT_RESULT as never, + ); + + const result = (await backend.callTool('impact', { + target: 'doWork', + repo: 'repro-3291', + })) as { staleness: ToolStaleness }; + + expect(result.staleness).toMatchObject({ + status: 'behind', + commitsBehind: 2, + branch: 'main', + lastCommit: twoBack, + measuredAgainst: 'HEAD', + }); + }); + + // A detached HEAD or a legacy index records no branch label, so the ref has to + // survive without one — `lastCommit` is the identifier that is always present. + it('names the ref by commit when the index records no branch label', async () => { + const { mainClone } = makeClones(); + const head = git(mainClone, 'rev-parse', 'HEAD'); + const { branch: _unlabelled, ...unlabelledHandle } = handleFor(mainClone, head, 'main'); + + vi.spyOn(backend, 'selectToolRepository').mockResolvedValue(unlabelledHandle as never); + vi.spyOn(backend as unknown as { impact: unknown }, 'impact').mockResolvedValue( + IMPACT_RESULT as never, + ); + + const result = (await backend.callTool('impact', { + target: 'doWork', + repo: 'repro-3291', + })) as { staleness: ToolStaleness }; + + expect(result.staleness).toEqual({ + status: 'current', + lastCommit: head, + indexedAt: INDEXED_AT, + measuredAgainst: 'HEAD', + }); + }); +}); diff --git a/gitnexus/test/unit/shipped-skills-sync.test.ts b/gitnexus/test/unit/shipped-skills-sync.test.ts index fd27892ea..1df77026b 100644 --- a/gitnexus/test/unit/shipped-skills-sync.test.ts +++ b/gitnexus/test/unit/shipped-skills-sync.test.ts @@ -249,26 +249,25 @@ describe('intended standard-skill improvements stay in every applicable copy', ( } }); - // #2899: the "Inline staleness signal" section was deleted from the - // canonical `.claude/` copy by an unrelated commit while the plugin mirror - // kept it — the same silent-deletion shape as the UNKNOWN-risk guard above, - // just for a hand-authored section instead of the machine-managed block. - // Scoped to canonical + plugin only: at the time of writing the npm mirror - // (gitnexus/skills/gitnexus-guide.md) already lacks this section as - // pre-existing, unrelated drift, so folding it into the loop above would - // fail on that unrelated copy instead of guarding this regression. - it('keeps the inline-staleness-signal section in the canonical and plugin guide copies', () => { - for (const file of [ - path.join(REPO_ROOT, '.claude', 'skills', 'gitnexus-guide', 'SKILL.md'), - path.join(REPO_ROOT, 'gitnexus-claude-plugin', 'skills', 'gitnexus-guide', 'SKILL.md'), - ]) { + // #2899 / #3291: every gitnexus-guide distribution documents the with-ref + // hot-tool field. A copy that drops the section (or stays on the pre-#3291 + // "absent when current" contract) ships a silent disagreement about identity. + it('keeps the inline-staleness-signal section in every gitnexus-guide copy', () => { + for (const file of standardSkillCopies('gitnexus-guide')) { const content = fs.readFileSync(file, 'utf-8'); expect(content).toContain('### Inline staleness signal'); expect(content).toContain('commitsBehind'); // #3256: the field gained `status`, and the `diverged` arm carries no // count — the reason an agent has to read `status` before the number. - expect(content).toContain('{ status, commitsBehind?, hint? }'); + // #3291: it also gained the indexed ref, and is now emitted for every + // status rather than suppressed when the index is current — without the + // ref, `current` cannot distinguish an index of the default branch from + // one of a feature branch. + expect(content).toContain( + '{ status, branch?, lastCommit, indexedAt, measuredAgainst, commitsBehind?, hint? }', + ); expect(content).toContain('"status": "diverged"'); + expect(content).toContain('"measuredAgainst": "HEAD"'); } }); @@ -300,6 +299,8 @@ describe('intended standard-skill improvements stay in every applicable copy', ( 'repo: "my-app"', 'bind repo; explicit repo when >1 indexed, ask if ambiguous', + + 'Re-analyze only for `behind` or `diverged`', ]; const copies = standardSkillCopies(name); expect(copies.length).toBeGreaterThan(1); diff --git a/gitnexus/test/unit/tool-staleness.test.ts b/gitnexus/test/unit/tool-staleness.test.ts index 6d36102ef..ed8968984 100644 --- a/gitnexus/test/unit/tool-staleness.test.ts +++ b/gitnexus/test/unit/tool-staleness.test.ts @@ -9,6 +9,7 @@ */ import { describe, it, expect } from 'vitest'; import type { StalenessInfo } from '../../src/core/git-staleness.js'; +import { stalenessPayload, type IndexedRef } from '../../src/core/staleness-status.js'; import { attachToolStaleness } from '../../src/mcp/local/local-backend.js'; const STALE: StalenessInfo = { @@ -103,3 +104,115 @@ describe('attachToolStaleness — status (#3256)', () => { ).toBe(result); }); }); + +// ── #3291: the ref-carrying form ───────────────────────────────────────────── +// +// `stalenessPayload` is the single builder behind three surfaces, so the fix has +// to add a shape without disturbing the one already in use. These pin both +// halves: WITHOUT a ref the output is bit-identical to the pre-#3291 shape for +// every status — which is what keeps `list_repos` and the `serve` repo routes +// byte-stable and their exact-match tests passing unmodified — and WITH one it +// names the index it describes, including when that index is `current`. + +const REF: IndexedRef = { + branch: 'feature/x', + lastCommit: 'f'.repeat(40), + indexedAt: '2026-09-15T00:00:00Z', +}; + +const CURRENT: StalenessInfo = { isStale: false, commitsBehind: 0, status: 'current' }; +const UNKNOWN: StalenessInfo = { isStale: false, commitsBehind: 0, status: 'unknown' }; +const DIVERGED: StalenessInfo = { + isStale: false, + commitsBehind: 0, + hint: 'moved on', + status: 'diverged', +}; +const BEHIND: StalenessInfo = { + isStale: true, + commitsBehind: 3, + hint: '3 behind', + status: 'behind', +}; + +describe('stalenessPayload without a ref — unchanged by #3291', () => { + it('omits the payload entirely for a current index', () => { + expect(stalenessPayload(CURRENT)).toBeUndefined(); + }); + + it('omits unknown unless the caller asks, and emits it bare when it does', () => { + expect(stalenessPayload(UNKNOWN)).toBeUndefined(); + expect(stalenessPayload(UNKNOWN, { includeUnknown: true })).toEqual({ status: 'unknown' }); + }); + + it('reports diverged with its hint and no invented count', () => { + expect(stalenessPayload(DIVERGED)).toEqual({ status: 'diverged', hint: 'moved on' }); + }); + + it('reports behind with the counted gap', () => { + expect(stalenessPayload(BEHIND)).toEqual({ + status: 'behind', + commitsBehind: 3, + hint: '3 behind', + }); + }); +}); + +describe('stalenessPayload with a ref (#3291)', () => { + it('emits a current index instead of suppressing it, naming the ref', () => { + // The regression #3291 reported: `current` alone cannot distinguish an index + // of the default branch from one of a feature branch. + expect(stalenessPayload(CURRENT, { ref: REF })).toEqual({ + status: 'current', + branch: 'feature/x', + lastCommit: REF.lastCommit, + indexedAt: REF.indexedAt, + measuredAgainst: 'HEAD', + }); + }); + + it('names the ref on unknown too — which index answered is knowable when its freshness is not', () => { + expect(stalenessPayload(UNKNOWN, { ref: REF })).toEqual({ + status: 'unknown', + branch: 'feature/x', + lastCommit: REF.lastCommit, + indexedAt: REF.indexedAt, + measuredAgainst: 'HEAD', + }); + }); + + it('keeps diverged free of an invented count while carrying the ref', () => { + const out = stalenessPayload(DIVERGED, { ref: REF }); + expect(out).toEqual({ + status: 'diverged', + branch: 'feature/x', + lastCommit: REF.lastCommit, + indexedAt: REF.indexedAt, + measuredAgainst: 'HEAD', + hint: 'moved on', + }); + expect('commitsBehind' in (out ?? {})).toBe(false); + }); + + it('carries the counted gap alongside the ref', () => { + expect(stalenessPayload(BEHIND, { ref: REF })).toEqual({ + status: 'behind', + branch: 'feature/x', + lastCommit: REF.lastCommit, + indexedAt: REF.indexedAt, + measuredAgainst: 'HEAD', + commitsBehind: 3, + hint: '3 behind', + }); + }); + + it('omits branch for a detached HEAD or legacy index, keeping lastCommit as the identifier', () => { + const { branch: _unlabelled, ...noBranch } = REF; + expect(stalenessPayload(CURRENT, { ref: noBranch })).toEqual({ + status: 'current', + lastCommit: REF.lastCommit, + indexedAt: REF.indexedAt, + measuredAgainst: 'HEAD', + }); + }); +}); diff --git a/gitnexus/test/unit/tools.test.ts b/gitnexus/test/unit/tools.test.ts index 9ba40dfe9..e08693700 100644 --- a/gitnexus/test/unit/tools.test.ts +++ b/gitnexus/test/unit/tools.test.ts @@ -116,6 +116,14 @@ describe('GITNEXUS_TOOLS', () => { } }); + it('query, context, impact, and cypher descriptions mention always-on staleness (#3291)', () => { + for (const name of ['query', 'context', 'impact', 'cypher'] as const) { + const tool = GITNEXUS_TOOLS.find((t) => t.name === name)!; + expect(tool.description).toContain('staleness'); + expect(tool.description).toMatch(/lastCommit|branch/); + } + }); + it('query tool requires "search_query" parameter (renamed from "query" for #2175)', () => { const queryTool = GITNEXUS_TOOLS.find((t) => t.name === 'query')!; expect(queryTool.inputSchema.required).toContain('search_query'); From 0ede6ae5017e0ceeb734d18bf76bcca75fed229c Mon Sep 17 00:00:00 2001 From: Abhinav Pandey Date: Wed, 16 Sep 2026 03:36:07 -0700 Subject: [PATCH 3/3] fix(rust): respect Cargo target boundaries in name fallback (#3294) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(rust): respect Cargo target boundaries in name fallback * fix(rust): read Cargo sources through validated file descriptors * fix(rust): require matching imports for crate-root guesses * fix(rust): require Cargo root identity for cross-target imports * fix(rust): enforce Cargo identity across fallback imports * fix(rust): keep Cargo membership on typical derive/macros (#3294) Abort only genuine unknown expansion, ignore Cargo artifact layouts instead of every `target` path segment, and require a covering import for unanswered crate-root name fallback so #3253 still holds on ordinary Rust sources. Co-authored-by: Cursor * test(rust): gate Cargo target membership in CI (#3253) Add a parse-dispatch-rounds-style bench so derive/macro abort, target-path globs, and superlinear membership walks fail in CI instead of staying graph-invisible. Co-authored-by: Cursor * fix(rust): verify Cargo macro and re-export evidence * style(bench): format Cargo membership benchmark * fix(rust): require imports for cross-file root guesses --------- Co-authored-by: Gergő Magyar Co-authored-by: Gergo Magyar Co-authored-by: Cursor --- .github/workflows/ci-tests.yml | 10 + README.md | 2 +- gitnexus/README.md | 2 +- .../bench/rust-cargo-targets/baselines.json | 31 + gitnexus/bench/rust-cargo-targets/measure.mjs | 309 +++++++++ gitnexus/package-lock.json | 13 + gitnexus/package.json | 1 + gitnexus/scripts/cross-platform-tests.ts | 3 + .../languages/rust/cargo-module-files.ts | 340 ++++++++++ .../ingestion/languages/rust/cargo-targets.ts | 513 ++++++++++++++ .../languages/rust/import-decomposer.ts | 15 +- .../rust/name-fallback-visibility.ts | 213 +++++- .../languages/rust/scope-resolver.ts | 2 + .../contract/scope-resolver.ts | 2 + .../passes/free-call-fallback.ts | 2 + .../scope-resolution/pipeline/run.ts | 1 + gitnexus/src/storage/parse-cache.ts | 8 +- .../rust-cargo-review-regressions.test.ts | 182 +++++ .../rust-cargo-target-fallback.test.ts | 389 +++++++++++ .../resolvers/rust-coverage.test.ts | 13 + .../test/unit/incremental-parse-cache.test.ts | 11 +- .../name-fallback-visibility.test.ts | 22 +- .../rust-cargo-targets.test.ts | 636 ++++++++++++++++++ 23 files changed, 2688 insertions(+), 32 deletions(-) create mode 100644 gitnexus/bench/rust-cargo-targets/baselines.json create mode 100644 gitnexus/bench/rust-cargo-targets/measure.mjs create mode 100644 gitnexus/src/core/ingestion/languages/rust/cargo-module-files.ts create mode 100644 gitnexus/src/core/ingestion/languages/rust/cargo-targets.ts create mode 100644 gitnexus/test/integration/resolvers/rust-cargo-review-regressions.test.ts create mode 100644 gitnexus/test/integration/resolvers/rust-cargo-target-fallback.test.ts create mode 100644 gitnexus/test/unit/scope-resolution/rust-cargo-targets.test.ts diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 641df1821..6f4bfdbec 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -608,6 +608,16 @@ jobs: run: node --import tsx bench/python-workspace-import-scan/measure.mjs --check working-directory: gitnexus + - name: Rust Cargo target membership guards (#3253) + if: ${{ !cancelled() }} + # Build-free: same baseline approach as parse-dispatch-rounds — + # exact membership floors plus a fingerprint, then ratio timing + # only (loadRustCargoTargets 4n/n). Pins typical-Rust completeness + # (derive / println!), include!-abort, and src/target vs Cargo + # artifact layouts. See bench/rust-cargo-targets/measure.mjs. + run: node --import tsx bench/rust-cargo-targets/measure.mjs --check + working-directory: gitnexus + - name: MCP tools/list countRepos vs listRepos guards (#3259, #3184) if: ${{ !cancelled() }} # Build-free: exact registry cardinality + tool-roster + schema-flag diff --git a/README.md b/README.md index e798c7284..c11f20984 100644 --- a/README.md +++ b/README.md @@ -662,7 +662,7 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas | Kotlin | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | | C# | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | Go | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| Rust | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | +| Rust | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | PHP | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | | Ruby | ✓ | — | ✓ | ✓ | — | ✓ | — | ✓ | ✓ | | Swift | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | diff --git a/gitnexus/README.md b/gitnexus/README.md index 816ca0ef0..a0f8b7fe1 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -431,7 +431,7 @@ TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift, | Kotlin | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | | C# | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | Go | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| Rust | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | +| Rust | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | PHP | ✓ | ✓ | ✓ | — | ✓ | ✓ | ✓ | ✓ | ✓ | | Ruby | ✓ | — | ✓ | ✓ | — | ✓ | — | ✓ | ✓ | | Swift | — | — | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | diff --git a/gitnexus/bench/rust-cargo-targets/baselines.json b/gitnexus/bench/rust-cargo-targets/baselines.json new file mode 100644 index 000000000..ca6f3f5ce --- /dev/null +++ b/gitnexus/bench/rust-cargo-targets/baselines.json @@ -0,0 +1,31 @@ +{ + "_what": "Baselines for bench/rust-cargo-targets/measure.mjs --check. Guards Cargo target membership after #3253: typical-Rust completeness, include!-abort, src/target and explicit target/entry.rs survival. Same approach as bench/parse-dispatch-rounds/baselines.json — exact floors and a fingerprint first; the only timing arm is a ratio.", + "_triage": "READ THIS BEFORE RE-RUNNING. packages, rust_files, typical_complete, include_unknown, explicit_disjoint, disjoint_false, shared_nested, shared_target_module and layout_fingerprint are DETERMINISTIC: a re-run never changes them, and none may be re-baselined to make CI green. load_scaling_ratio is the only timing arm; runner contention dominates it, so re-run on an idle machine before investigating and read the reported `reps` first. If exactly one arm fails and it is that one, suspect the machine.", + + "packages": 8, + "rust_files": 32, + "_shape_note": "THE FLOOR. Without these two, every arm below is a ceiling over nothing. typical_complete only asserts something while the corpus still walks many crates. Shrink it to one happy-path package and typical_complete still reads 1 and still passes, asserting a property the corpus no longer has.", + + "typical_complete": 1, + "include_unknown": 1, + "explicit_disjoint": 1, + "disjoint_false": 8, + "shared_nested": 8, + "shared_target_module": 8, + "_membership_note": "Exact membership counts. typical_complete=1 pins that #[derive]/println!/assert_eq! do not abort the snapshot. include_unknown=1 pins item-position include! still aborts. explicit_disjoint=1 pins [lib] path = target/entry.rs against tests/helper.rs as a complete negative proof. disjoint_false=8 is each crate's lib vs tests/helper. shared_nested=8 and shared_target_module=8 pin src/nested.rs and src/target/mod.rs as library modules — the latter is the glob that would drop every path segment named target.", + + "layout_fingerprint": "1f85812c93302dc4c5089b284c6c395379efc146bfc11b886de499417ec1ae01", + "_layout_fingerprint_note": "sha256 over sorted caller|candidate|share rows on the typical corpus. A change here is a BEHAVIOUR change — the loader returned a different membership set. Explain it, never re-baseline it alone.", + + "load_scaling_budget": 1.6, + "_load_scaling_note": "(t_4n / t_n) / 4 for loadRustCargoTargets over the typical corpus; ~1.0 is linear. A RATIO rather than a millisecond ceiling, deliberately: wall-clock is runner-speed-dependent, and this repo has already been bitten by a fixed ms budget. Budget is 1.6, matching parse-dispatch-rounds' pack_scaling_budget. min-of-15 estimator.", + + "_measured": { + "load_scaling_ratio": 0.924, + "load_scaling_ratio_samples": [0.681, 0.859, 0.798, 0.834, 0.924], + "small_ms": 24.43, + "large_ms_4x": 75.08, + "reps": 15 + }, + "_measured_note": "Maxima (and sample lists) over 5 consecutive local runs using the min-of-15 estimator from parse-dispatch-rounds. Milliseconds are diagnostic context only — nothing gates on them." +} diff --git a/gitnexus/bench/rust-cargo-targets/measure.mjs b/gitnexus/bench/rust-cargo-targets/measure.mjs new file mode 100644 index 000000000..3321dc841 --- /dev/null +++ b/gitnexus/bench/rust-cargo-targets/measure.mjs @@ -0,0 +1,309 @@ +/** + * Build-free bench for Rust Cargo target membership (#3253). + * + * WHY THIS EXISTS. `loadRustCargoTargets` is a negative-proof loader: a + * complete snapshot lets name-fallback refuse a cross-target unique name, and + * an incomplete one fail-opens. Graph output does not show "how many files we + * walked" or "we aborted because of `#[derive]`". A revert to treating + * derive/println! as unknown, a glob that drops every path segment named + * target, or a superlinear membership walk can still emit the same one CALLS + * edge on a tiny fixture. + * This file is the same shape as `bench/parse-dispatch-rounds`: exact floors + * first, one ratio timing arm, never a millisecond ceiling. + * + * ARMS: + * + * - `typical_complete` / `include_unknown` / `explicit_disjoint` — EXACT. + * Typical crates (derive + expression-position std macros) must still + * certify a snapshot. `include!` must still abort it. An explicit + * `[lib] path = "target/entry.rs"` must stay a complete negative proof + * against `tests/helper.rs`, not vanish into the artifact glob. + * + * - `packages` / `rust_files` / `disjoint_false` / `shared_nested` / + * `shared_target_module` — EXACT, and they are the FLOOR. `typical_complete` + * only asserts something while the corpus still has many crates to walk. + * Shrink it to one happy-path package and the complete arm still passes, + * gating a property the corpus no longer has. + * + * - `layout_fingerprint` — EXACT. sha256 over sorted `caller|candidate|share` + * rows on the typical corpus. Catches a membership-set change that leaves + * the counts intact. + * + * - `load_scaling_ratio` — the only timing arm, a RATIO not a millisecond + * ceiling. `(t_4n / t_n) / 4` divides the machine out; ~1.0 is linear. + * Superlinear AST work over crate count lands here. + * + * Usage: + * node --import tsx bench/rust-cargo-targets/measure.mjs + * node --import tsx bench/rust-cargo-targets/measure.mjs --check + */ +import { createHash } from 'node:crypto'; +import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { + loadRustCargoTargets, + rustFilesShareCargoTarget, +} from '../../src/core/ingestion/languages/rust/cargo-targets.ts'; + +const baselines = JSON.parse(readFileSync(new URL('./baselines.json', import.meta.url), 'utf8')); + +const REPS = 15; +const PACKAGES = 8; +const TYPICAL_LIB = [ + '#[derive(Debug)]', + 'struct S;', + 'mod nested;', + 'mod target;', + 'pub fn helper() {}', + 'fn t() { println!("hi"); assert_eq!(1, 1); let _ = vec![1]; let _ = format!("{}", 1); }', + '', +].join('\n'); + +function writeCrate(root, name, { libPath = 'src/lib.rs', libBody = TYPICAL_LIB } = {}) { + const crateDir = path.join(root, 'crates', name); + mkdirSync(path.join(crateDir, 'src', 'target'), { recursive: true }); + mkdirSync(path.join(crateDir, 'src', 'nested'), { recursive: true }); + mkdirSync(path.join(crateDir, 'tests'), { recursive: true }); + mkdirSync(path.join(crateDir, path.dirname(libPath)), { recursive: true }); + const manifest = ['[package]', `name="${name}"`, 'version="0.1.0"', 'edition="2021"', '']; + if (libPath !== 'src/lib.rs') { + manifest.push('[lib]', `path="${libPath}"`, ''); + } + writeFileSync(path.join(crateDir, 'Cargo.toml'), `${manifest.join('\n')}`); + writeFileSync(path.join(crateDir, libPath), libBody); + const moduleDir = path.posix.dirname(libPath); + writeFileSync(path.join(crateDir, moduleDir, 'nested.rs'), 'pub fn nested_helper() {}\n'); + writeFileSync(path.join(crateDir, 'src', 'target', 'mod.rs'), 'pub fn target_helper() {}\n'); + writeFileSync(path.join(crateDir, 'tests', 'helper.rs'), 'pub fn helper() {}\n'); +} + +function writeTypical(root, packages) { + const names = Array.from({ length: packages }, (_, i) => `c${i}`); + writeFileSync( + path.join(root, 'Cargo.toml'), + `[workspace]\nmembers=[${names.map((n) => `"crates/${n}"`).join(', ')}]\n`, + ); + for (const name of names) writeCrate(root, name); + return names; +} + +function cratePaths(name, libPath = 'src/lib.rs') { + const lib = `crates/${name}/${libPath}`; + const moduleDir = path.posix.dirname(libPath); + return { + lib, + nested: `crates/${name}/${moduleDir}/nested.rs`, + target: `crates/${name}/src/target/mod.rs`, + tests: `crates/${name}/tests/helper.rs`, + }; +} + +function countRs(dir) { + let n = 0; + for (const ent of readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, ent.name); + if (ent.isDirectory()) n += countRs(p); + else if (ent.name.endsWith('.rs')) n++; + } + return n; +} + +function shareLabel(value) { + if (value === true) return 'true'; + if (value === false) return 'false'; + return 'undefined'; +} + +function probesFor(config, names, libPath = 'src/lib.rs') { + const rows = []; + let disjointFalse = 0; + let sharedNested = 0; + let sharedTarget = 0; + for (const name of names) { + const paths = cratePaths(name, libPath); + const nested = rustFilesShareCargoTarget(config, paths.lib, paths.nested); + const target = rustFilesShareCargoTarget(config, paths.lib, paths.target); + const tests = rustFilesShareCargoTarget(config, paths.lib, paths.tests); + if (tests === false) disjointFalse++; + if (nested === true) sharedNested++; + if (target === true) sharedTarget++; + rows.push( + `${paths.lib}|${paths.nested}|${shareLabel(nested)}`, + `${paths.lib}|${paths.target}|${shareLabel(target)}`, + `${paths.lib}|${paths.tests}|${shareLabel(tests)}`, + ); + } + return { + disjointFalse, + sharedNested, + sharedTarget, + fingerprint: createHash('sha256').update(rows.sort().join('\n')).digest('hex'), + }; +} + +async function fastest(fn, reps) { + await fn(); + let best = Infinity; + for (let r = 0; r < reps; r++) { + const t0 = performance.now(); + await fn(); + best = Math.min(best, performance.now() - t0); + } + return best; +} + +const roots = []; +function workspace(build) { + const root = mkdtempSync(path.join(tmpdir(), 'gn-rust-cargo-bench-')); + roots.push(root); + build(root); + return root; +} + +try { + const typicalRoot = workspace((root) => writeTypical(root, PACKAGES)); + const typical4xRoot = workspace((root) => writeTypical(root, PACKAGES * 4)); + const explicitRoot = workspace((root) => { + writeFileSync(path.join(root, 'Cargo.toml'), '[workspace]\nmembers=["crates/explicit"]\n'); + writeCrate(root, 'explicit', { + libPath: 'target/entry.rs', + libBody: '#[derive(Debug)] struct S;\nmod nested;\npub fn helper() {}\n', + }); + }); + const includeRoot = workspace((root) => { + writeFileSync(path.join(root, 'Cargo.toml'), '[workspace]\nmembers=["crates/unknown"]\n'); + writeCrate(root, 'unknown', { libBody: 'include!("generated.rs");\n' }); + }); + + const typicalNames = Array.from({ length: PACKAGES }, (_, i) => `c${i}`); + const [typicalConfig, explicitConfig, includeConfig] = await Promise.all([ + loadRustCargoTargets(typicalRoot), + loadRustCargoTargets(explicitRoot), + loadRustCargoTargets(includeRoot), + ]); + + const typical = probesFor(typicalConfig, typicalNames); + const explicitPaths = cratePaths('explicit', 'target/entry.rs'); + const explicitShare = rustFilesShareCargoTarget( + explicitConfig, + explicitPaths.lib, + explicitPaths.tests, + ); + const includeShare = rustFilesShareCargoTarget( + includeConfig, + 'crates/unknown/src/lib.rs', + 'crates/unknown/tests/helper.rs', + ); + + const rustFiles = countRs(typicalRoot); + const typicalComplete = typicalConfig !== undefined ? 1 : 0; + const includeUnknown = includeConfig === undefined && includeShare === undefined ? 1 : 0; + const explicitDisjoint = explicitShare === false ? 1 : 0; + + const smallMs = await fastest(() => loadRustCargoTargets(typicalRoot), REPS); + const largeMs = await fastest(() => loadRustCargoTargets(typical4xRoot), REPS); + const loadScaling = largeMs / smallMs / 4; + + console.log(`packages : ${PACKAGES} (expect ${baselines.packages})`); + console.log(`rust_files : ${rustFiles} (expect ${baselines.rust_files})`); + console.log( + `typical_complete : ${typicalComplete} (expect ${baselines.typical_complete})`, + ); + console.log(`include_unknown : ${includeUnknown} (expect ${baselines.include_unknown})`); + console.log( + `explicit_disjoint : ${explicitDisjoint} (expect ${baselines.explicit_disjoint})`, + ); + console.log( + `disjoint_false : ${typical.disjointFalse} (expect ${baselines.disjoint_false})`, + ); + console.log( + `shared_nested : ${typical.sharedNested} (expect ${baselines.shared_nested})`, + ); + console.log( + `shared_target_module : ${typical.sharedTarget} (expect ${baselines.shared_target_module})`, + ); + console.log(`layout_fingerprint : ${typical.fingerprint}`); + console.log( + `load_scaling_ratio : ${loadScaling.toFixed(3)} (budget <= ${baselines.load_scaling_budget}; ~1.0 is linear)`, + ); + console.log( + `reps : ${REPS} small ${smallMs.toFixed(2)}ms / 4x ${largeMs.toFixed(2)}ms`, + ); + + if (process.argv.includes('--check')) { + let failed = false; + + if (typical.fingerprint !== baselines.layout_fingerprint) { + failed = true; + console.error( + `\nFAIL layout_fingerprint: ${typical.fingerprint}\n` + + ` expected ${baselines.layout_fingerprint}\n` + + ` Typical-corpus membership moved. Explain it; do not re-baseline alone.`, + ); + } + + if (typicalComplete !== baselines.typical_complete) { + failed = true; + console.error( + `\nFAIL typical_complete: ${typicalComplete}, expected ${baselines.typical_complete}.\n` + + ` Ordinary #[derive] / println! / assert_eq! aborted the snapshot, so the\n` + + ` #3253 veto never loads on typical crates.`, + ); + } + if (includeUnknown !== baselines.include_unknown) { + failed = true; + console.error( + `\nFAIL include_unknown: ${includeUnknown}, expected ${baselines.include_unknown}.\n` + + ` Item-position include! must still abort the membership proof.`, + ); + } + if (explicitDisjoint !== baselines.explicit_disjoint) { + failed = true; + console.error( + `\nFAIL explicit_disjoint: ${explicitDisjoint}, expected ${baselines.explicit_disjoint}.\n` + + ` [lib] path = "target/entry.rs" was dropped or left unknown — usually\n` + + ` **/target/** glob ignore rather than Cargo artifact layouts.`, + ); + } + if ( + typical.disjointFalse !== baselines.disjoint_false || + typical.sharedNested !== baselines.shared_nested || + typical.sharedTarget !== baselines.shared_target_module + ) { + failed = true; + console.error( + `\nFAIL membership counts: disjoint_false ${typical.disjointFalse} (expected ${baselines.disjoint_false}), ` + + `shared_nested ${typical.sharedNested} (expected ${baselines.shared_nested}), ` + + `shared_target_module ${typical.sharedTarget} (expected ${baselines.shared_target_module}).\n` + + ` Cross-target tests/helper.rs must stay proven-false; src/target/mod.rs must stay a library module.`, + ); + } + + if (PACKAGES !== baselines.packages || rustFiles !== baselines.rust_files) { + failed = true; + console.error( + `\nFAIL shape: packages ${PACKAGES} (expected ${baselines.packages}), ` + + `rust_files ${rustFiles} (expected ${baselines.rust_files}).\n` + + ` The corpus must stay large enough that the complete/disjoint arms still measure a walk.`, + ); + } + + if (loadScaling > baselines.load_scaling_budget) { + failed = true; + console.error( + `\nFAIL load_scaling_ratio: ${loadScaling.toFixed(3)} exceeds ` + + `${baselines.load_scaling_budget} (~1.0 is linear).\n` + + ` Re-run on an idle machine before investigating, and check \`reps\` first.`, + ); + } + + if (failed) process.exit(1); + console.log('\nOK — within budget.'); + } +} finally { + for (const root of roots) { + rmSync(root, { recursive: true, force: true }); + } +} diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 5a4af535b..d39daf3c1 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -37,6 +37,7 @@ "pino": "^10.3.1", "pino-pretty": "^13.1.3", "proxy-addr": "^2.0.7", + "smol-toml": "^1.8.0", "tree-sitter": "0.21.1", "tree-sitter-c-sharp": "0.23.1", "tree-sitter-cpp": "0.23.2", @@ -4184,6 +4185,18 @@ "dev": true, "license": "ISC" }, + "node_modules/smol-toml": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", + "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", diff --git a/gitnexus/package.json b/gitnexus/package.json index 73b76a481..1e2bc42ad 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -92,6 +92,7 @@ "pino": "^10.3.1", "pino-pretty": "^13.1.3", "proxy-addr": "^2.0.7", + "smol-toml": "^1.8.0", "tree-sitter": "0.21.1", "tree-sitter-c-sharp": "0.23.1", "tree-sitter-cpp": "0.23.2", diff --git a/gitnexus/scripts/cross-platform-tests.ts b/gitnexus/scripts/cross-platform-tests.ts index 6b1d46fe1..89e5842f7 100644 --- a/gitnexus/scripts/cross-platform-tests.ts +++ b/gitnexus/scripts/cross-platform-tests.ts @@ -305,6 +305,9 @@ const NATIVE_ADDON_SMOKE = [ // Filesystem behavior tests — exercise operations that vary across // platforms (CRLF, symlinks, permissions, temp dirs) const FILESYSTEM = [ + // Cargo membership uses path normalization, descriptor validation, symlinks, + // and Rust native parsing (including long Windows source strings). + 'test/unit/scope-resolution/rust-cargo-targets.test.ts', // The durable ParsedFile store's prune tolerates a chunk directory it cannot // delete (#3204). The failures that motivate it — held handles, read-only // mounts — are Windows- and macOS-flavored, and the permission-based case diff --git a/gitnexus/src/core/ingestion/languages/rust/cargo-module-files.ts b/gitnexus/src/core/ingestion/languages/rust/cargo-module-files.ts new file mode 100644 index 000000000..c73ede641 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/cargo-module-files.ts @@ -0,0 +1,340 @@ +import path from 'node:path'; +import type Parser from 'tree-sitter'; +import { splitRustUseDeclaration } from './import-decomposer.js'; + +// Built-in attributes cannot expand to new module declarations. cfg is a union: +// visiting both alternatives is conservative; cfg_attr may change a path. +const NON_EXPANDING_ATTRIBUTES = new Set([ + 'cfg', + 'path', + 'allow', + 'warn', + 'deny', + 'forbid', + 'expect', + 'doc', + 'test', + 'should_panic', + 'ignore', + 'automatically_derived', + 'proc_macro', + 'proc_macro_derive', + 'proc_macro_attribute', + 'inline', + 'cold', + 'no_mangle', + 'export_name', + 'repr', + 'non_exhaustive', + 'must_use', + 'deprecated', + 'no_std', + 'no_main', + 'feature', + 'crate_type', + 'crate_name', + 'recursion_limit', + 'type_length_limit', +]); + +const BUILTIN_DERIVES = new Set([ + 'Clone', + 'Copy', + 'Debug', + 'Default', + 'Eq', + 'Hash', + 'Ord', + 'PartialEq', + 'PartialOrd', +]); +const EXPRESSION_MACROS = new Set([ + 'print', + 'println', + 'eprint', + 'eprintln', + 'assert', + 'assert_eq', + 'assert_ne', + 'debug_assert', + 'debug_assert_eq', + 'debug_assert_ne', + 'vec', + 'format', + 'format_args', + 'write', + 'writeln', + 'panic', + 'todo', + 'unimplemented', + 'unreachable', + 'dbg', + 'matches', +]); + +/** Token arguments can contain blocks/modules or further macro expansion. + * Inspect token nodes, never strings/comments that merely mention those words. */ +function hasExpandingArguments(tokens: Parser.SyntaxNode): boolean { + for (let i = 0; i < tokens.childCount; i++) { + const child = tokens.child(i)!; + if (child.type === 'mod' || child.type === '#') return true; + if (child.type === '!' && tokens.child(i - 1)?.type === 'identifier') return true; + if (child.type === 'token_tree' && hasExpandingArguments(child)) return true; + } + return false; +} + +/** Public use evidence from the same AST used for membership. Keeps restricted + * and private globs distinct without changing the shared import/cache shape. */ +export function rustPublicUses(root: Parser.SyntaxNode): ReadonlySet { + const uses = new Set(); + const pending = [{ node: root, module: '' }]; + while (pending.length > 0) { + const { node, module } = pending.pop()!; + for (const child of node.namedChildren) { + if (child.type === 'mod_item') { + const body = child.childForFieldName('body'); + const name = child.childForFieldName('name')?.text; + if (body && name) + pending.push({ node: body, module: [module, name].filter(Boolean).join('::') }); + } else if ( + child.type === 'use_declaration' && + child.namedChildren.some( + (part) => part.type === 'visibility_modifier' && part.text === 'pub', + ) + ) { + for (const capture of splitRustUseDeclaration(child)) { + uses.add( + JSON.stringify([ + module, + capture['@import.source']?.text, + capture['@import.kind']?.text, + capture['@import.name']?.text, + ]), + ); + } + } + } + } + return uses; +} + +/** Decode a literal path without mistaking strings/comments for Rust syntax. */ +function literalPath(text: string): string | undefined { + const raw = /^r(#+)?"([\s\S]*)"\1$/.exec(text); + if (raw) return raw[2]; + // Escape forms beyond JSON's subset remain unknown, never a guessed path. + try { + const value: unknown = JSON.parse(text); + return typeof value === 'string' ? value : undefined; + } catch { + return undefined; + } +} + +/** External modules reachable from one source file; undefined is incomplete. */ +export function rustModuleFiles( + root: Parser.SyntaxNode, + file: string, + ownsDirectory: boolean, + files: ReadonlySet, + missingFiles?: Set, + isCrateRoot = false, +): readonly { file: string; ownsDirectory: boolean }[] | undefined { + if (root.hasError) return undefined; + // Built-in spellings are not proof when an import/local macro can shadow + // them. Cross-file macro_use/macro_export remain unknown attributes below. + const shadowed = new Set(); + const globs: { node: Parser.SyntaxNode; path: string }[] = []; + const scan = [root]; + while (scan.length) { + const node = scan.pop()!; + if (node.type === 'macro_definition' || node.type === 'mod_item') { + const name = node.childForFieldName('name')?.text; + if (name) shadowed.add(name); + // macro_rules textual scope can extend into child module files. Without + // expansion/scope receipts, do not assume their same-named calls are std. + if (node.type === 'macro_definition' && name && EXPRESSION_MACROS.has(name)) return undefined; + } + if (node.type === 'use_declaration') { + for (const capture of splitRustUseDeclaration(node)) { + if (capture['@import.kind']?.text === 'wildcard') + globs.push({ node, path: capture['@import.source']?.text ?? '' }); + const name = capture['@import.name']?.text; + if (name) shadowed.add(name); + } + } + if (node.type !== 'macro_definition' && node.type !== 'token_tree') + scan.push(...node.namedChildren); + } + const wildcard = globs.some(({ node, path: imported }) => { + const parts = imported.split('::').filter(Boolean); + // Cargo aliases for these names are rejected by the loader. A local + // module/import can still shadow a standard-library path in this file. + if (['std', 'core', 'alloc'].includes(parts[0] ?? '') && !shadowed.has(parts[0]!)) return false; + let depth = 0; + let localModuleScope = true; + for (let parent = node.parent; parent && parent !== root; parent = parent.parent) { + if (parent.type === 'mod_item') depth++; + if ( + parent.type === 'function_item' || + parent.type === 'block' || + parent.type === 'closure_expression' + ) + localModuleScope = false; + } + // The common inline unit-test `use super::*` stays within this AST. + // A file-level super glob has an external parent and remains unknown. + if ( + localModuleScope && + parts.length > 0 && + parts.every((part) => part === 'super') && + parts.length <= depth + ) + return false; + // At a Cargo root, bare/self/crate paths to inline modules have the same + // meaning across editions. Do not extend this assumption to file modules. + if (isCrateRoot && node.parent === root) { + if (parts[0] === 'self' || parts[0] === 'crate') parts.shift(); + let body: Parser.SyntaxNode | undefined = root; + for (const part of parts) { + body = + body?.namedChildren + .find( + (child) => + child.type === 'mod_item' && child.childForFieldName('name')?.text === part, + ) + ?.childForFieldName('body') ?? undefined; + } + if (parts.length > 0 && body !== undefined) return false; + } + return true; + }); + const builtin = (name: string): boolean => !wildcard && !shadowed.has(name); + const fileDir = path.posix.dirname(file); + const moduleDir = + ownsDirectory || path.posix.basename(file) === 'mod.rs' + ? fileDir + : file.slice(0, -'.rs'.length); + const pending = [{ node: root, moduleDir, attributeDir: fileDir }]; + const result: { file: string; ownsDirectory: boolean }[] = []; + let unresolved = false; + while (pending.length) { + const context = pending.pop()!; + let attributes: Parser.SyntaxNode[] = []; + for (const node of context.node.namedChildren) { + if (node.type === 'line_comment' || node.type === 'block_comment') continue; + if (node.type === 'attribute_item' || node.type === 'inner_attribute_item') { + const attribute = node.namedChildren[0]; + const name = attribute?.namedChildren[0]?.text; + if (name === 'derive') { + const argumentsNode = attribute?.childForFieldName('arguments'); + if ( + !argumentsNode || + !builtin('derive') || + argumentsNode.namedChildren.length === 0 || + argumentsNode.namedChildren.some( + (item) => + item.type !== 'identifier' || + !BUILTIN_DERIVES.has(item.text) || + !builtin(item.text), + ) || + argumentsNode.children.some( + (item) => + !['(', ')', ',', 'identifier', 'line_comment', 'block_comment'].includes(item.type), + ) + ) + return undefined; + } else if (!name || !NON_EXPANDING_ATTRIBUTES.has(name)) return undefined; + if (node.type === 'attribute_item') attributes.push(node); + continue; + } + const attrs = attributes; + attributes = []; + // The current scope captures do not carry extern-crate aliases. Do not + // certify a negative import-root proof from an incomplete namespace view. + if (node.type === 'extern_crate_declaration' && node.childForFieldName('alias') !== null) { + return undefined; + } + const macro = + node.type === 'macro_invocation' + ? node + : node.type === 'expression_statement' && + node.namedChildren[0]?.type === 'macro_invocation' + ? node.namedChildren[0] + : undefined; + if (macro) { + const name = macro.childForFieldName('macro')?.text; + const tokens = macro.namedChildren.find((child) => child.type === 'token_tree'); + const parts = name?.split('::').filter(Boolean) ?? []; + const standard = + parts.length === 1 || + (parts.length === 2 && ['std', 'core', 'alloc'].includes(parts[0]!)); + if ( + !standard || + !EXPRESSION_MACROS.has(parts.at(-1) ?? '') || + !parts.every(builtin) || + !tokens || + hasExpandingArguments(tokens) || + context.node.type === 'source_file' || + context.node.type === 'declaration_list' + ) + return undefined; + continue; + } + if (node.type !== 'mod_item') { + // Items (including external #[path] modules) can also occur in blocks. + // Inspect them too; a macro expansion there can add shared membership. + // Macro definitions/token trees and literal contents are not expansions. + if ( + node.type !== 'macro_definition' && + node.type !== 'token_tree' && + node.namedChildCount > 0 + ) { + pending.push({ ...context, node }); + } + continue; + } + const name = node.childForFieldName('name')?.text; + if (!name) return undefined; + let override: string | undefined; + for (const attr of attrs) { + const attribute = attr.namedChildren[0]!; + if (attribute.namedChildren[0]?.text !== 'path') continue; + const value = attribute.childForFieldName('value'); + if (!value || override !== undefined) return undefined; + override = literalPath(value.text); + if (override === undefined || path.posix.isAbsolute(override) || override.includes('\\')) + return undefined; + } + const body = node.childForFieldName('body'); + if (body) { + const dir = + override === undefined + ? path.posix.join(context.moduleDir, name) + : path.posix.join(context.attributeDir, override); + pending.push({ node: body, moduleDir: dir, attributeDir: dir }); + continue; + } + const candidates = + override !== undefined + ? [path.posix.normalize(path.posix.join(context.attributeDir, override))] + : [ + path.posix.join(context.moduleDir, `${name}.rs`), + path.posix.join(context.moduleDir, name, 'mod.rs'), + ]; + const existing = candidates.filter((candidate) => files.has(candidate)); + if (existing.length === 0) { + if (!missingFiles) return undefined; + for (const candidate of candidates) missingFiles.add(candidate); + unresolved = true; + continue; + } + // Union conditional alternatives; shared membership must never be erased. + // #[path] makes the loaded file own its containing directory, just like + // a crate root; its children do NOT acquire the file stem as a prefix. + result.push(...existing.map((file) => ({ file, ownsDirectory: override !== undefined }))); + } + } + return unresolved ? undefined : result; +} diff --git a/gitnexus/src/core/ingestion/languages/rust/cargo-targets.ts b/gitnexus/src/core/ingestion/languages/rust/cargo-targets.ts new file mode 100644 index 000000000..95377f032 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/cargo-targets.ts @@ -0,0 +1,513 @@ +/** Cargo target evidence for the name-guess veto, never a directory heuristic. */ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { glob, escape } from 'glob'; +import { parse } from 'smol-toml'; +import Parser from 'tree-sitter'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { getLanguageGrammar } from '../../../tree-sitter/parser-loader.js'; +import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; +import { readRepoControlFile } from '../../../../config/repo-control-file.js'; +import { rustModuleFiles, rustPublicUses } from './cargo-module-files.js'; + +const MAX_FILES = 100_000; +type Table = Record; +const table = (value: unknown): value is Table => + value !== null && typeof value === 'object' && !Array.isArray(value); + +/** A target is identified by its entry file, not its package directory. */ +export function cargoTargetRoots( + manifest: string, + content: string, + files: ReadonlySet, + workspaceEditions?: ReadonlyMap, +): readonly string[] | undefined { + let data: Table; + try { + data = parse(content); + } catch { + return undefined; + } + if (!table(data.package)) return table(data.workspace) ? [] : undefined; + const pkg = data.package; + if (typeof pkg.name !== 'string') return undefined; + if (pkg.build !== undefined && typeof pkg.build !== 'string' && typeof pkg.build !== 'boolean') + return undefined; + const dir = path.posix.dirname(manifest); + let edition: unknown = pkg.edition ?? '2015'; + if (table(edition) && edition.workspace === true) { + let workspace = + typeof pkg.workspace === 'string' ? path.posix.join(dir, pkg.workspace, '.') : dir; + while ( + !workspaceEditions?.has(workspace) && + typeof pkg.workspace !== 'string' && + workspace !== '.' + ) { + workspace = path.posix.dirname(workspace); + } + edition = workspaceEditions?.get(workspace); + } + if (typeof edition !== 'string' || !['2015', '2018', '2021', '2024'].includes(edition)) + return undefined; + const relative = (file: string): string => path.posix.normalize(path.posix.join(dir, file)); + const roots = new Set(); + for (const [kind, folder] of [ + ['lib', 'src'], + ['bin', 'src/bin'], + ['test', 'tests'], + ['bench', 'benches'], + ['example', 'examples'], + ] as const) { + const autoKey = { + lib: 'autolib', + bin: 'autobins', + test: 'autotests', + bench: 'autobenches', + example: 'autoexamples', + }[kind]; + if (pkg[autoKey] !== undefined && typeof pkg[autoKey] !== 'boolean') return undefined; + const discovered = new Map(); + if (kind === 'lib') { + if (files.has(relative('src/lib.rs'))) discovered.set(pkg.name, relative('src/lib.rs')); + } else { + if (kind === 'bin' && files.has(relative('src/main.rs'))) { + discovered.set(pkg.name, relative('src/main.rs')); + } + const prefix = `${relative(folder)}/`; + for (const file of files) { + if (!file.startsWith(prefix)) continue; + const tail = file.slice(prefix.length); + const match = /^([^/]+)\.rs$/.exec(tail) ?? /^([^/]+)\/main\.rs$/.exec(tail); + if (match) discovered.set(match[1]!, file); + } + } + const explicit = data[kind] === undefined ? [] : kind === 'lib' ? [data[kind]] : data[kind]; + if (!Array.isArray(explicit)) return undefined; + // Cargo 2015's opt-in discovery rule is PER target kind: an explicit + // binary does not disable integration tests, examples, benches or the lib. + const legacy = edition === '2015' && explicit.length > 0; + const overridden = new Set(); + for (const entry of explicit) { + if (!table(entry)) return undefined; + const name = kind === 'lib' ? pkg.name : entry.name; + if (typeof name !== 'string') return undefined; + if (entry.path !== undefined && typeof entry.path !== 'string') return undefined; + if (typeof entry.path === 'string' && path.posix.isAbsolute(entry.path)) return undefined; + const file = typeof entry.path === 'string' ? relative(entry.path) : discovered.get(name); + if (!file || !files.has(file)) return undefined; + roots.add(file); + overridden.add(name); + } + if (pkg[autoKey] === true || (pkg[autoKey] !== false && !legacy)) { + for (const [name, file] of discovered) if (!overridden.has(name)) roots.add(file); + } + } + // Build scripts are crates too, even when located outside src/. + if (typeof pkg.build === 'string') { + const file = relative(pkg.build); + if (!files.has(file)) return undefined; + if (roots.has(file)) return undefined; // Multiple target roles need separate identities. + roots.add(file); + } else if (pkg.build !== false && files.has(relative('build.rs'))) { + if (roots.has(relative('build.rs'))) return undefined; + roots.add(relative('build.rs')); + } + return [...roots]; +} + +class RustCargoTargets { + constructor( + readonly targetsByFile: ReadonlyMap>, + readonly rootImports: ReadonlyMap>>, + readonly publicUsesByFile: ReadonlyMap>, + ) {} +} + +/** Exact public-use evidence, independent of the capture's coarse reexport kind. */ +export function rustCargoPubliclyReexports( + config: unknown, + file: string, + module: string, + target: string, + kind: string, + name: string, +): boolean { + return ( + config instanceof RustCargoTargets && + config.publicUsesByFile.get(file)?.has(JSON.stringify([module, target, kind, name])) === true + ); +} + +/** Positive evidence that this import names this library's ROOT, not a module + * elsewhere (or a binary/test entry point that cannot be imported as a lib). */ +export function rustImportNamesCargoRoot( + config: unknown, + caller: string, + candidate: string, + importedModule: string, +): boolean { + if (!(config instanceof RustCargoTargets)) return false; + const segments = importedModule.split('::').filter(Boolean); + if (segments.length !== 1) return false; + for (const target of config.targetsByFile.get(caller) ?? []) { + if (config.rootImports.get(target)?.get(segments[0]!)?.has(candidate)) return true; + } + return false; +} + +/** Every known membership must identify this file as the entry point. A file + * shared as a module in another target does not have a single root role. */ +export function rustIsExclusiveCargoRoot(config: unknown, file: string): boolean { + if (!(config instanceof RustCargoTargets)) return false; + const targets = config.targetsByFile.get(file); + return targets?.size === 1 && targets.has(file); +} + +/** Establish crate identity before the existing module-path plausibility test. */ +export function rustImportReachesCargoTarget( + config: unknown, + caller: string, + candidate: string, + importedModule: string, +): boolean { + if (!(config instanceof RustCargoTargets)) return false; + const name = importedModule.split('::').filter(Boolean)[0]; + if (!name) return false; + const candidates = config.targetsByFile.get(candidate); + for (const target of config.targetsByFile.get(caller) ?? []) { + for (const imported of config.rootImports.get(target)?.get(name) ?? []) { + if (candidates?.has(imported)) return true; + } + } + return false; +} + +/** Import names are target/package-relative. A dependency alias in another + * package must not authorize a guess here merely because its spelling matches. */ +function cargoRootImports( + manifests: ReadonlyMap, + targets: ReadonlyMap, +): Map>> { + const libraries = new Map(); + for (const [manifest, data] of manifests) { + if (!table(data.package)) continue; + const lib = table(data.lib) ? data.lib : undefined; + if (!lib && data.package.autolib === false) continue; + const root = path.posix.join( + path.posix.dirname(manifest), + typeof lib?.path === 'string' ? lib.path : 'src/lib.rs', + ); + const name = lib?.name ?? data.package.name; + if (typeof name === 'string' && targets.get(manifest)?.includes(root)) { + libraries.set(manifest, { + root, + name: name.replaceAll('-', '_'), + }); + } + } + const result = new Map>>(); + for (const [manifest, data] of manifests) { + if (!table(data.package)) continue; + const dir = path.posix.dirname(manifest); + let workspace = + typeof data.package.workspace === 'string' + ? path.posix.join(dir, data.package.workspace, '.') + : dir; + while ( + !table(manifests.get(path.posix.join(workspace, 'Cargo.toml'))?.workspace) && + typeof data.package.workspace !== 'string' && + workspace !== '.' + ) + workspace = path.posix.dirname(workspace); + const workspaceData = manifests.get(path.posix.join(workspace, 'Cargo.toml'))?.workspace; + const workspaceDeps = + table(workspaceData) && table(workspaceData.dependencies) ? workspaceData.dependencies : {}; + const sections = [ + data, + ...(table(data.target) ? Object.values(data.target).filter(table) : []), + ]; + for (const target of targets.get(manifest) ?? []) { + let imports = result.get(target); + if (!imports) result.set(target, (imports = new Map())); + const add = (name: string, root: string) => { + let roots = imports.get(name); + if (!roots) imports.set(name, (roots = new Set())); + roots.add(root); + }; + const own = libraries.get(manifest); + const buildRoot = path.posix.join( + dir, + typeof data.package.build === 'string' ? data.package.build : 'build.rs', + ); + const isBuild = data.package.build !== false && target === buildRoot; + if (own && !isBuild) add(own.name, own.root); + for (const section of sections) { + // Libraries/binaries can also compile as unit-test targets, so retain + // dev dependencies across cfg modes. Build scripts have their own + // dependency namespace and cannot import the package's own library. + const kinds = isBuild ? ['build-dependencies'] : ['dependencies', 'dev-dependencies']; + for (const kind of kinds) { + const deps = section[kind]; + if (!table(deps)) continue; + for (const [key, declared] of Object.entries(deps)) { + const inherited = table(declared) && declared.workspace === true; + const dep = inherited ? workspaceDeps[key] : declared; + if (!table(dep) || typeof dep.path !== 'string') continue; + const dependency = libraries.get( + path.posix.join(inherited ? workspace : dir, dep.path, 'Cargo.toml'), + ); + if (!dependency) continue; + // Cargo uses the dependency key whenever `package` is explicit, + // even if it equals the package name and [lib].name differs. + const renamed = typeof dep.package === 'string'; + add(renamed ? key.replaceAll('-', '_') : dependency.name, dependency.root); + } + } + } + } + } + return result; +} + +/** Undefined means no complete membership proof; never interpret it as disjoint. */ +export function rustFilesShareCargoTarget( + config: unknown, + caller: string, + candidate: string, +): boolean | undefined { + if (!(config instanceof RustCargoTargets)) return undefined; + const callers = config.targetsByFile.get(caller); + const candidates = config.targetsByFile.get(candidate); + if (!callers || !candidates) return undefined; + return [...callers].some((root) => candidates.has(root)); +} + +/** + * Static, bounded, one-shot provider loader. No cargo/rustc, build scripts, + * repository wrappers, or network. Parse only files reachable from Cargo roots. + * Unknown expansion anywhere can add shared membership, so it invalidates the + * negative proof for this snapshot rather than producing a partial veto. + */ +export async function loadRustCargoTargets(repoPath: string): Promise { + try { + const root = await fs.realpath(repoPath); + const files = new Set(); + const manifests: string[] = []; + for await (const entry of glob.iterate('**/Cargo.toml', { + // Cargo metadata must include targets the graph scanner omits (notably + // src/bin). An omitted target can share a source file with another crate. + cwd: root, + nodir: true, + follow: false, + posix: true, + dot: true, + ignore: [ + '**/.git/**', + '**/node_modules/**', + '**/.gitnexus/**', + '**/target/debug/**', + '**/target/release/**', + '**/target/incremental/**', + '**/target/doc/**', + '**/target/tmp/**', + '**/target/.fingerprint/**', + '**/target/CACHEDIR.TAG', + ], + })) { + if (files.size >= MAX_FILES) return undefined; + files.add(entry); + manifests.push(entry); + } + if (manifests.length === 0) return undefined; + // Artifact-layout pruning (`target/debug`, `target/release`, …) must not + // erase a source path whose segment is named `target` (`src/target/mod.rs`, + // `[lib] path = "target/entry.rs"`, `src/bin/target/main.rs`). Re-scan + // Cargo's auto-target slots without that filter. + for (const manifest of manifests) { + for await (const entry of glob.iterate( + [ + 'src/lib.rs', + 'src/main.rs', + 'src/bin/*.rs', + 'src/bin/*/main.rs', + 'tests/*.rs', + 'tests/*/main.rs', + 'benches/*.rs', + 'benches/*/main.rs', + 'examples/*.rs', + 'examples/*/main.rs', + 'build.rs', + ], + { + cwd: path.join(root, path.posix.dirname(manifest)), + nodir: true, + follow: false, + posix: true, + }, + )) { + if (files.size >= MAX_FILES) return undefined; + files.add(path.posix.join(path.posix.dirname(manifest), entry)); + } + } + const read = async (file: string): Promise => { + const requested = path.resolve(root, file); + const absolute = await fs.realpath(requested); + const rel = path.relative(root, absolute); + if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) { + throw new Error('Cargo module outside repository'); + } + if (absolute !== requested) throw new Error('Cargo module alias has unknown membership'); + // The shared reader validates its opened descriptor and bounds streamed + // bytes; the outer realpath check is a separate containment/alias guard. + const content = await readRepoControlFile(root, file); + if (content === null) throw new Error('Cargo source disappeared'); + return content; + }; + const contents = new Map(); + const manifestData = new Map(); + const workspaceEditions = new Map(); + for (const manifest of manifests) { + const content = await read(manifest); + contents.set(manifest, content); + const data = parse(content); + // A dependency alias can replace a std/core/alloc extern-prelude entry. + // In that case the module walker cannot identify standard macros safely. + const sections = [ + data, + ...(table(data.target) ? Object.values(data.target).filter(table) : []), + ]; + if ( + sections.some((section) => + ['dependencies', 'dev-dependencies', 'build-dependencies'].some( + (kind) => + table(section[kind]) && + ['std', 'core', 'alloc'].some((name) => Object.hasOwn(section[kind], name)), + ), + ) + ) + return undefined; + manifestData.set(manifest, data); + if ( + table(data.workspace) && + table(data.workspace.package) && + typeof data.workspace.package.edition === 'string' + ) { + workspaceEditions.set(path.posix.dirname(manifest), data.workspace.package.edition); + } + } + // Manifest discovery prunes artifacts, but explicit source paths and mod + // declarations are authoritative candidates even beneath a `target` folder. + // Probe only those literal paths; never crawl the artifact tree recursively. + const discover = async (candidates: Iterable): Promise => { + const patterns = [...candidates].map((file) => { + if ( + path.posix.isAbsolute(file) || + file === '..' || + file.startsWith('../') || + file.includes('\\') + ) + throw new Error('Cargo source outside repository'); + if ( + !file.endsWith('.rs') || + file + .split('/') + .some((part) => ['.git', '.gitnexus', 'node_modules'].includes(part.toLowerCase())) + ) + throw new Error('Cargo source excluded from inventory'); + return escape(file); + }); + for await (const entry of glob.iterate(patterns, { + cwd: root, + nodir: true, + follow: false, + posix: true, + dot: true, + })) { + if (files.size >= MAX_FILES) throw new Error('Cargo file limit'); + files.add(entry); + } + }; + for (const [manifest, data] of manifestData) { + const explicit = [ + data.lib, + ...['bin', 'test', 'bench', 'example'].flatMap((kind) => + Array.isArray(data[kind]) ? data[kind] : [], + ), + ]; + const paths = explicit + .filter(table) + .map((entry) => entry.path) + .filter((value): value is string => typeof value === 'string'); + if (table(data.package) && typeof data.package.build === 'string') + paths.push(data.package.build); + await discover(paths.map((file) => path.posix.join(path.posix.dirname(manifest), file))); + } + const roots = new Set(); + const targetsByManifest = new Map(); + for (const [manifest, content] of contents) { + const targets = cargoTargetRoots(manifest, content, files, workspaceEditions); + if (targets === undefined) return undefined; + targetsByManifest.set(manifest, targets); + for (const target of targets) roots.add(target); + } + const parser = new Parser(); + parser.setLanguage(getLanguageGrammar(SupportedLanguages.Rust)); + const targetsByFile = new Map>(); + const publicUsesByFile = new Map>(); + // A file may be reached conventionally AND through #[path]. Those have + // different submodule bases, so cache and visit both contexts separately. + const childrenByFile = new Map>>(); + let visits = 0; + for (const target of roots) { + const pending = [{ file: target, ownsDirectory: true }]; + const visited = new Set(); + while (pending.length > 0) { + if (++visits > MAX_FILES) return undefined; + const { file, ownsDirectory } = pending.pop()!; + const key = `${file === target ? 'root' : ownsDirectory ? 'owned' : 'module'}:${file}`; + if (visited.has(key)) continue; + visited.add(key); + let owners = targetsByFile.get(file); + if (!owners) targetsByFile.set(file, (owners = new Set())); + owners.add(target); + let children = childrenByFile.get(key); + if (!children) { + const tree = parseSourceSafe(parser, await read(file)); + const missing = new Set(); + let result = rustModuleFiles( + tree.rootNode, + file, + ownsDirectory, + files, + missing, + file === target, + ); + if (result === undefined && missing.size > 0) { + await discover(missing); + result = rustModuleFiles( + tree.rootNode, + file, + ownsDirectory, + files, + undefined, + file === target, + ); + } + if (result === undefined) return undefined; + publicUsesByFile.set(file, rustPublicUses(tree.rootNode)); + children = result; + childrenByFile.set(key, children); + } + pending.push(...children); + } + } + return new RustCargoTargets( + targetsByFile, + cargoRootImports(manifestData, targetsByManifest), + publicUsesByFile, + ); + } catch { + // I/O, parse or containment failure cannot establish target separation. + return undefined; + } +} diff --git a/gitnexus/src/core/ingestion/languages/rust/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/rust/import-decomposer.ts index c209d570b..2ed1ce418 100644 --- a/gitnexus/src/core/ingestion/languages/rust/import-decomposer.ts +++ b/gitnexus/src/core/ingestion/languages/rust/import-decomposer.ts @@ -10,16 +10,19 @@ import type { SyntaxNode } from '../../utils/ast-helpers.js'; export function splitRustUseDeclaration(node: SyntaxNode): CaptureMatch[] { if (node.type !== 'use_declaration') return []; - const isReexport = hasVisibilityModifier(node); + const isReexport = hasUnrestrictedPub(node); const argument = getUseArgument(node); if (argument === null) return []; return decomposeUseArgument(argument, '', isReexport, node); } -function hasVisibilityModifier(node: SyntaxNode): boolean { +function hasUnrestrictedPub(node: SyntaxNode): boolean { for (let i = 0; i < node.childCount; i++) { - if (node.child(i)?.type === 'visibility_modifier') return true; + const child = node.child(i); + // `pub(crate)` / `pub(super)` / `pub(in …)` / `crate` are not the crate's + // public surface, so they must not walk as `pub use` re-export evidence. + if (child?.type === 'visibility_modifier' && child.text === 'pub') return true; } return false; } @@ -146,7 +149,9 @@ function buildScopedPath(node: SyntaxNode): string { if (node.type === 'scoped_identifier') { const parts: string[] = []; collectScopedParts(node, parts); - return parts.join('::'); + // An absolute extern-prelude path bypasses a same-named local module. + // Keep that evidence when flattening the AST into an import specifier. + return `${node.text.startsWith('::') ? '::' : ''}${parts.join('::')}`; } return node.text; } @@ -174,7 +179,7 @@ function buildWildcardPath(node: SyntaxNode): string { const child = node.child(i); if (child === null) continue; if (child.type === 'scoped_identifier') return buildScopedPath(child); - if (child.type === 'identifier') return child.text; + if (['identifier', 'crate', 'self', 'super'].includes(child.type)) return child.text; } return ''; } diff --git a/gitnexus/src/core/ingestion/languages/rust/name-fallback-visibility.ts b/gitnexus/src/core/ingestion/languages/rust/name-fallback-visibility.ts index 1afdb900f..52140814d 100644 --- a/gitnexus/src/core/ingestion/languages/rust/name-fallback-visibility.ts +++ b/gitnexus/src/core/ingestion/languages/rust/name-fallback-visibility.ts @@ -20,6 +20,13 @@ */ import type { ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import { + rustFilesShareCargoTarget, + rustImportNamesCargoRoot, + rustIsExclusiveCargoRoot, + rustImportReachesCargoTarget, + rustCargoPubliclyReexports, +} from './cargo-targets.js'; import { modulePathReaches, stripExtension, @@ -34,6 +41,16 @@ const RUST_CRATE_ROOT_DIRS: ReadonlySet = new Set(['src', 'tests', 'benc /** Path prefixes of a `use` that name a root rather than a module segment. */ const RUST_USE_ROOT_PREFIXES: ReadonlySet = new Set(['crate', '$crate']); +const RUST_TYPE_NAMESPACE_KINDS: ReadonlySet = new Set([ + 'Namespace', + 'Class', + 'Struct', + 'Enum', + 'Trait', + 'Interface', + 'TypeAlias', + 'Union', +]); // One scope lookup per immutable parsed-file snapshot, not per fallback site. // Weak keys release both the snapshot and its index at the end of ingestion. @@ -92,6 +109,8 @@ function rustUsePathOf(targetRaw: string, callerFilePath: string): string { export function rustIsGlobalNameFallbackPlausible(ctx: { readonly callerParsed: ParsedFile; readonly candidate: SymbolDefinition; + readonly resolutionConfig?: unknown; + readonly parsedFileOf?: (filePath: string) => ParsedFile | undefined; readonly site: { readonly name: string; readonly rawQualifiedName?: string; @@ -106,12 +125,79 @@ export function rustIsGlobalNameFallbackPlausible(ctx: { if (ctx.site.rawQualifiedName !== undefined) return true; const candidateModule = rustModulePathOf(ctx.candidate.filePath); - // A candidate whose file maps to no module path (a crate root reduced to '') - // is not something this rule can speak about; allow the labeled edge rather - // than refuse on an unanswered question. - if (candidateModule === '') return true; + const sharesTarget = rustFilesShareCargoTarget( + ctx.resolutionConfig, + ctx.callerParsed.filePath, + ctx.candidate.filePath, + ); + const separateRoot = + sharesTarget === false && + rustIsExclusiveCargoRoot(ctx.resolutionConfig, ctx.candidate.filePath); + // Cargo membership does not establish cross-file lexical visibility. + // Root candidates must also pass the import checks below; same-file and + // explicitly qualified calls have already been handled above. const candidateName = rustSimpleNameOf(ctx.candidate); + const exportModules = new Set([(ctx.candidate.namespacePrefix ?? '').replaceAll('.', '::')]); + const candidateParsed = + sharesTarget === false ? ctx.parsedFileOf?.(ctx.candidate.filePath) : undefined; + if (candidateParsed !== undefined) { + const moduleByScope = new Map(); + const moduleScopes = new Set(); + for (const scope of candidateParsed.scopes) { + const parent = scope.parent === null ? '' : (moduleByScope.get(scope.parent) ?? ''); + const own = + scope.kind === 'Namespace' + ? scope.ownedDefs.find((def) => def.type === 'Namespace')?.qualifiedName + : undefined; + moduleByScope.set(scope.id, [parent, own].filter(Boolean).join('::')); + if (scope.kind === 'Namespace' || scope.kind === 'Module') moduleScopes.add(scope.id); + } + // Follow same-file re-exports without confusing the defining module with + // the module an importer sees. Each iteration adds a known module scope, + // so cycles terminate. Cargo's AST snapshot supplies public visibility, + // which the coarse parsed reexport/wildcard kind does not preserve. + let changed = true; + while (changed) { + changed = false; + for (const imp of candidateParsed.parsedImports) { + if (imp.declaredAtScope === undefined || !moduleScopes.has(imp.declaredAtScope)) continue; + if ( + imp.kind !== 'wildcard' && + (imp.kind !== 'reexport' || + imp.localName !== candidateName || + imp.importedName !== candidateName) + ) + continue; + if (imp.targetRaw.startsWith('::')) continue; + const owner = moduleByScope.get(imp.declaredAtScope)!; + if ( + !rustCargoPubliclyReexports( + ctx.resolutionConfig, + ctx.candidate.filePath, + owner, + imp.targetRaw, + imp.kind, + imp.kind === 'wildcard' ? '*' : imp.localName, + ) + ) + continue; + const parts = imp.targetRaw.split('::').filter(Boolean); + if (imp.kind !== 'wildcard') parts.pop(); + const base = + parts[0] === 'self' || parts[0] === 'super' ? owner.split('::').filter(Boolean) : []; + if (parts[0] === 'crate' || parts[0] === 'self') parts.shift(); + while (parts[0] === 'super') { + base.pop(); + parts.shift(); + } + if (exportModules.has([...base, ...parts].join('::')) && !exportModules.has(owner)) { + exportModules.add(owner); + changed = true; + } + } + } + } // Imports are lexical evidence, not a file-wide allowlist. Legacy/synthetic // imports without a scope receipt retain the previous conservative behavior. let visibleScopes: Set | undefined; @@ -130,25 +216,120 @@ export function rustIsGlobalNameFallbackPlausible(ctx: { current = scope.parent; } } - for (const imp of ctx.callerParsed.parsedImports) { - if ( - imp.declaredAtScope !== undefined && - visibleScopes !== undefined && - !visibleScopes.has(imp.declaredAtScope) - ) - continue; + const visibleImports = ctx.callerParsed.parsedImports.filter( + (imp) => + imp.declaredAtScope === undefined || + visibleScopes === undefined || + visibleScopes.has(imp.declaredAtScope), + ); + const scopeRanks = new Map([...(visibleScopes ?? [])].map((scope, rank) => [scope, rank])); + const namesCandidateRoot = (module: string, entryOnly: boolean): 'root' | 'module' | false => { + const pending = [module]; + const seen = new Set(); + while (pending.length > 0) { + const name = pending.pop()!; + const parts = name.split('::').filter(Boolean); + const head = parts[0]; + const bindingName = name.startsWith('::') ? name : head; + if (!bindingName || seen.has(bindingName)) continue; + seen.add(bindingName); + const aliases = visibleImports.filter( + (imported) => 'localName' in imported && imported.localName === bindingName, + ); + const rank = (imported: (typeof visibleImports)[number]) => + imported.declaredAtScope === undefined + ? Infinity + : (scopeRanks.get(imported.declaredAtScope) ?? Infinity); + const nearest = aliases.length > 0 ? Math.min(...aliases.map(rank)) : Infinity; + let localTypeRank = Infinity; + for (const [scopeId, depth] of scopeRanks) { + const bindings = scopeLookupByFile + .get(ctx.callerParsed) + ?.get(scopeId) + ?.bindings.get(bindingName); + if ( + bindings?.some( + (binding) => + binding.origin === 'local' && RUST_TYPE_NAMESPACE_KINDS.has(binding.def.type), + ) + ) { + localTypeRank = depth; + break; + } + } + // A local module/type shadows the extern prelude, but a nearer import + // can shadow that declaration. Value-namespace functions do not block it. + if (localTypeRank !== Infinity && localTypeRank <= nearest) continue; + if (aliases.length > 0) { + const destinations = new Set( + aliases + .filter((imported) => rank(imported) === nearest) + .map((imported) => imported.targetRaw), + ); + if (destinations.size !== 1) continue; + const destination = [...destinations][0]!; + if (destination !== bindingName) { + pending.push([destination, ...parts.slice(1)].join('::')); + continue; + } + } + // A root FILE can also contain inline modules. Its Cargo identity names + // the crate, while the scope model supplies the member's module suffix. + if ( + rustImportNamesCargoRoot( + ctx.resolutionConfig, + ctx.callerParsed.filePath, + ctx.candidate.filePath, + head!, + ) + ) { + if (exportModules.has(parts.slice(1).join('::'))) return 'root'; + continue; + } + if ( + !entryOnly && + rustImportReachesCargoTarget( + ctx.resolutionConfig, + ctx.callerParsed.filePath, + ctx.candidate.filePath, + name, + ) + ) + return 'module'; + } + return false; + }; + for (const imp of visibleImports) { + let importedRoot = false; // `crate::` is the caller's crate. A same trailing module in another // workspace crate is a different item and cannot authorize the guess. if (imp.targetRaw === 'crate' || imp.targetRaw.startsWith('crate::')) { + if (sharesTarget === false) continue; const callerRoot = rustCrateRootOf(ctx.callerParsed.filePath); const candidateRoot = rustCrateRootOf(ctx.candidate.filePath); - if (callerRoot !== '' && candidateRoot !== '' && callerRoot !== candidateRoot) continue; + if ( + sharesTarget === undefined && + callerRoot !== '' && + candidateRoot !== '' && + callerRoot !== candidateRoot + ) + continue; + } + if (sharesTarget === false) { + const module = + imp.kind === 'wildcard' + ? imp.targetRaw + : imp.targetRaw.slice(0, Math.max(0, imp.targetRaw.lastIndexOf('::'))); + const reached = namesCandidateRoot(module, separateRoot); + if (!reached) continue; + importedRoot = reached === 'root'; } const usePath = rustUsePathOf(imp.targetRaw, ctx.callerParsed.filePath); // Only a glob introduces every bare item of a module. A named import must // match both the candidate's original name and the call's local spelling. if (imp.kind === 'wildcard') { - if (modulePathReaches(usePath, candidateModule)) return true; + if (importedRoot || candidateModule === '' || modulePathReaches(usePath, candidateModule)) + return true; continue; } if (!('localName' in imp) || imp.localName !== ctx.site.name) continue; @@ -158,8 +339,12 @@ export function rustIsGlobalNameFallbackPlausible(ctx: { // parent-path match used to accept every item of `a` on its strength. // An alias authorizes only the local spelling checked above. if (importedNameOf(imp) !== candidateName) continue; - if (modulePathReaches(usePath, candidateModule)) return true; + // A different target may import the library crate's root exports. Even + // when that root has no path segment to compare, a named import must name + // THIS callable: `use std::fmt` cannot revive a rejected `crate::helper`. const parent = usePath.slice(0, Math.max(0, usePath.lastIndexOf('::'))); + if (importedRoot || candidateModule === '') return true; + if (modulePathReaches(usePath, candidateModule)) return true; if (parent !== '' && modulePathReaches(parent, candidateModule)) return true; } return false; diff --git a/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts index 71fb697db..4beda7c03 100644 --- a/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts @@ -20,6 +20,7 @@ import type { HeritageTypeArgumentSink } from '../../scope-resolution/utils/gene import type { KnowledgeGraph } from '../../../graph/types.js'; import { generateId } from '../../../../lib/utils.js'; import { rustIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js'; +import { loadRustCargoTargets } from './cargo-targets.js'; /** * Emit Rust `S IMPLEMENTS T` edges from `impl T for S` trait implementations. @@ -157,6 +158,7 @@ export const rustScopeResolver: ScopeResolver = { language: SupportedLanguages.Rust, languageProvider: rustProvider, importEdgeReason: 'rust-scope: use', + loadResolutionConfig: loadRustCargoTargets, resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => resolveRustImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig), diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index d6da6d4d9..71385a41f 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -920,6 +920,8 @@ export interface ScopeResolver { readonly isGlobalNameFallbackPlausible?: (ctx: { readonly callerParsed: ParsedFile; readonly candidate: SymbolDefinition; + /** Opaque workspace metadata from this provider's loadResolutionConfig. */ + readonly resolutionConfig?: unknown; readonly parsedFileOf: (filePath: string) => ParsedFile | undefined; /** * Raw source of any parsed file, for languages whose visibility rule needs 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 7dcc792f3..3e7c3013c 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 @@ -74,6 +74,7 @@ export function emitFreeCallFallback( /** Per-language veto on a name guess — see * `ScopeResolver.isGlobalNameFallbackPlausible`. */ readonly isGlobalNameFallbackPlausible?: ScopeResolver['isGlobalNameFallbackPlausible']; + readonly resolutionConfig?: unknown; /** Raw source lookup handed to `isGlobalNameFallbackPlausible` (optional). */ readonly sourceTextOf?: (filePath: string) => string | undefined; /** When true, `Type(...)` constructor calls link to the Class def @@ -650,6 +651,7 @@ export function emitFreeCallFallback( options.isGlobalNameFallbackPlausible?.({ callerParsed: parsed, candidate: vetoCandidate, + resolutionConfig: options.resolutionConfig, parsedFileOf: parsedFileByPath(), sourceTextOf: options.sourceTextOf, site: { diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 7cc96e829..58dd19248 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -1102,6 +1102,7 @@ export function runScopeResolution( allowGlobalFallback: provider.allowGlobalFreeCallFallback === true, language: provider.language, isGlobalNameFallbackPlausible: provider.isGlobalNameFallbackPlausible, + resolutionConfig, sourceTextOf: provider.isGlobalNameFallbackPlausible !== undefined ? (filePath: string) => getFileContents().get(filePath) diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 1c7c1073b..e49044b7e 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -763,7 +763,13 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid // #3190. Old durable ParsedFiles lack the facts needed for scoped binding; // invalidate both stores so warm indexing actually applies the correction. // origin/main took 98 for #3219; 99 is the next free value. -const SCHEMA_BUMP = 99; +// v100 (#3253): Rust import captures preserve the leading `::` that selects +// the extern prelude. Old warm captures erase it and cannot distinguish an +// absolute library import from a same-named local module. Reparse both stores. +// v101 (#3294 review): Rust bare-keyword glob imports retain crate/self/super +// instead of an empty target path; restricted pub(...) imports are no longer +// captured as unrestricted reexports. Re-extract both facts on warm indexes. +const SCHEMA_BUMP = 101; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/integration/resolvers/rust-cargo-review-regressions.test.ts b/gitnexus/test/integration/resolvers/rust-cargo-review-regressions.test.ts new file mode 100644 index 000000000..7a56418ec --- /dev/null +++ b/gitnexus/test/integration/resolvers/rust-cargo-review-regressions.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { getRelationships, runPipelineFromRepo, writeFixtureRepo } from './helpers.js'; +import { + loadRustCargoTargets, + rustFilesShareCargoTarget, +} from '../../../src/core/ingestion/languages/rust/cargo-targets.js'; + +const PACKAGE = '[package]\nname="demo"\nedition="2021"\n'; + +async function check(files: Record, expectedCalls: number, complete = true) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-cargo-review-')); + try { + writeFixtureRepo(dir, { 'Cargo.toml': PACKAGE, ...files }); + const config = await loadRustCargoTargets(dir); + if (complete) expect(config).toBeDefined(); + else expect(config).toBeUndefined(); + const result = await runPipelineFromRepo(dir, () => {}); + const candidates = [...result.graph.iterNodes()].filter( + (node) => node.properties.name === 'helper', + ); + expect(candidates.length).toBeGreaterThan(0); + const calls = getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'caller' && edge.target === 'helper', + ); + expect(calls).toHaveLength(expectedCalls); + return { config, calls }; + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } +} + +describe('Cargo review regressions (#3294)', () => { + it.each(['', 'use crate::helper;', 'use super::helper;', 'use crate::*;', 'use super::*;'])( + 'requires lexical import evidence within the same Cargo target: %s', + async (imported) => { + await check( + { + 'src/lib.rs': 'fn helper() {} mod child;', + 'src/child.rs': `${imported} pub fn caller() { helper(); }`, + }, + imported === '' ? 0 : 1, + ); + }, + ); + it.each([ + '#[derive(Debug, Clone)] pub struct T;', + 'fn noisy() { println!("x"); assert_eq!(1, 1); let _v = vec![1, 2]; }', + 'fn noisy() { std::println!("x"); core::assert_eq!(1, 1); }', + '#[derive(Debug)] struct T; #[cfg(test)] mod tests { use super::*; #[test] fn f() { assert_eq!(1,1); } }', + 'use std::fmt::*; fn noisy() { println!("x"); }', + '#[test] #[should_panic] #[ignore] fn expected_panic() { panic!("expected"); }', + ])('retains target separation with ordinary Rust: %s', async (source) => { + await check( + { + 'src/lib.rs': `${source} use crate::helper; pub fn caller() { helper(); }`, + 'tests/helper.rs': 'pub fn helper() {}', + }, + 0, + ); + }); + + it('ordinary macros in a sibling target do not erase the proof', async () => { + await check( + { + 'src/lib.rs': 'use crate::helper; pub fn caller() { helper(); }', + 'tests/helper.rs': 'pub fn helper() {}', + 'tests/other.rs': '#[test] fn ordinary() { assert_eq!(1, 1); }', + }, + 0, + ); + }); + + it('restores a real module directory named target', async () => { + const { config } = await check( + { + 'src/lib.rs': 'mod target; use crate::helper; pub fn caller() { helper(); }', + 'src/target/mod.rs': 'pub fn other() {}', + 'tests/helper.rs': 'pub fn helper() {}', + }, + 0, + ); + expect(rustFilesShareCargoTarget(config, 'src/lib.rs', 'src/target/mod.rs')).toBe(true); + }); + + it('does not import a build-script root into the library', async () => { + await check( + { + 'src/lib.rs': 'use std::build::*; pub fn caller() { helper(); }', + 'build.rs': 'pub fn helper() {} fn main() {}', + }, + 0, + ); + }); + + it('restores explicit roots under artifact-pruned directories', async () => { + await check( + { + 'Cargo.toml': `${PACKAGE}[[bin]]\nname="custom"\npath="target/entry.rs"\n`, + 'target/entry.rs': 'fn main() {}', + 'src/lib.rs': 'use crate::helper; pub fn caller() { helper(); }', + 'tests/helper.rs': 'pub fn helper() {}', + }, + 0, + ); + }); + + it('keeps a custom library root import when that file is shared with a binary', async () => { + await check( + { + 'Cargo.toml': `${PACKAGE}[lib]\npath="custom/entry.rs"\n`, + 'custom/entry.rs': 'pub fn helper() {}', + 'src/main.rs': '#[path="../custom/entry.rs"] mod shared; fn main() {}', + 'tests/caller.rs': 'use demo::*; pub fn caller() { helper(); }', + }, + 1, + ); + }); + + it.each(['', 'pub(crate) ', 'pub(super) ', 'pub(in crate) ', 'pub '])( + 'honors re-export visibility for %suse', + async (visibility) => { + for (const imported of ['helper', '*']) { + await check( + { + 'src/lib.rs': `#[derive(Debug)] struct T; mod nested { pub fn helper() {} } ${visibility}use nested::${imported};`, + 'tests/caller.rs': 'use demo::*; pub fn caller() { helper(); }', + }, + visibility === 'pub ' ? 1 : 0, + ); + } + }, + ); + + it.each([ + ['pub fn helper() {}', 'demo', 1], + ['pub fn helper() {}', 'demo::nested', 0], + ['pub mod nested { pub fn helper() {} }', 'demo', 0], + ['pub mod nested { pub fn helper() {} }', 'demo::nested', 1], + ] as const)( + 'selects the imported root role even for a shared root: %s / %s', + async (source, imported, count) => { + await check( + { + 'src/lib.rs': source, + 'src/main.rs': '#[path="lib.rs"] mod shared; fn main() {}', + 'tests/caller.rs': `use ${imported}::*; pub fn caller() { helper(); }`, + }, + count, + ); + }, + ); + + it('keeps uncertainty for an unknown macro that can share both files', async () => { + const { calls } = await check( + { + 'src/lib.rs': 'use crate::helper; pub fn caller() { helper(); }', + 'tests/helper.rs': 'pub fn helper() {}', + 'src/main.rs': + 'macro_rules! share { () => { include!("../tests/helper.rs"); #[path="lib.rs"] mod library; } } share!(); fn main() {}', + }, + 1, + false, + ); + expect(calls[0]?.rel).toMatchObject({ reason: 'global-name-fallback', confidence: 0.5 }); + }); + + it('preserves a labeled guess for an unmodeled extern-crate alias, not an alias-resolution claim', async () => { + const { calls } = await check( + { + 'src/lib.rs': 'pub fn helper() {}', + 'tests/caller.rs': 'extern crate demo as api; use api::*; pub fn caller() { helper(); }', + }, + 1, + false, + ); + expect(calls[0]?.targetFilePath).toBe('src/lib.rs'); + expect(calls[0]?.rel).toMatchObject({ reason: 'global-name-fallback', confidence: 0.5 }); + }); +}); diff --git a/gitnexus/test/integration/resolvers/rust-cargo-target-fallback.test.ts b/gitnexus/test/integration/resolvers/rust-cargo-target-fallback.test.ts new file mode 100644 index 000000000..87d59d441 --- /dev/null +++ b/gitnexus/test/integration/resolvers/rust-cargo-target-fallback.test.ts @@ -0,0 +1,389 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { getRelationships, runPipelineFromRepo, writeFixtureRepo } from './helpers.js'; +import { loadRustCargoTargets } from '../../../src/core/ingestion/languages/rust/cargo-targets.js'; + +describe('Rust Cargo target boundaries in name fallback (#3253)', () => { + it.each([ + ['use demo::*;', false], + ['use demo::helper;', false], + ['use demo::nested::*;', true], + ['use demo::nested::helper;', true], + ['use demo as api; use api::nested::*;', true], + ['use ::demo as demo; use demo::nested::*;', true], + ['use b as a; use a as b; use a::*;', false], + ] as const)('matches inline modules within a Cargo root: %s', async (source, allowed) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-cargo-inline-root-')); + try { + writeFixtureRepo(dir, { + 'Cargo.toml': '[package]\nname="demo"\nedition="2021"\n', + 'src/lib.rs': 'pub mod nested { pub fn helper() {} }', + 'tests/caller.rs': `${source} pub fn caller() { helper(); }`, + }); + const result = await runPipelineFromRepo(dir, () => {}); + expect( + getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'caller' && edge.target === 'helper', + ), + ).toHaveLength(allowed ? 1 : 0); + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); + + it.each([ + ['pub mod nested { pub fn helper() {} } pub use nested::helper;', 'demo'], + ['pub mod nested { pub fn helper() {} } pub use nested::*;', 'demo'], + ['pub fn helper() {} pub mod nested { pub use super::helper; }', 'demo::nested'], + [ + 'pub mod a { pub fn helper() {} } pub mod b { pub use crate::a::helper; } pub use b::helper;', + 'demo', + ], + ])('preserves same-file re-export evidence: %s', async (library, imported) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-cargo-reexport-')); + try { + writeFixtureRepo(dir, { + 'Cargo.toml': '[package]\nname="demo"\nedition="2021"\n', + 'src/lib.rs': library, + 'tests/caller.rs': `use ${imported}::*; pub fn caller() { helper(); }`, + }); + const result = await runPipelineFromRepo(dir, () => {}); + expect( + getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'caller' && edge.target === 'helper', + ), + ).toHaveLength(1); + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); + + it.each(['tests/fmt.rs', 'benches/fmt.rs', 'examples/fmt.rs'])( + 'uses Cargo root identity for the nonempty file stem %s', + async (target) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-cargo-root-stem-')); + try { + writeFixtureRepo(dir, { + 'Cargo.toml': '[package]\nname="demo"\nversion="0.1.0"\nedition="2021"\n', + 'src/lib.rs': 'use std::fmt::*; pub fn caller() { helper(); }', + [target]: 'pub fn helper() {}', + }); + const result = await runPipelineFromRepo(dir, () => {}); + expect(result.graph.getNode(`Function:${target}:helper`)).toBeDefined(); + expect( + getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'caller' && edge.target === 'helper', + ), + ).toEqual([]); + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }, + ); + + it('recognizes a custom library entry file as the imported crate root', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-cargo-custom-root-')); + try { + writeFixtureRepo(dir, { + 'Cargo.toml': + '[package]\nname="demo"\nversion="0.1.0"\nedition="2021"\n[lib]\npath="library/entry.rs"\n', + 'library/entry.rs': 'pub fn helper() {}', + 'tests/caller.rs': 'use demo::*; pub fn caller() { helper(); }', + }); + const result = await runPipelineFromRepo(dir, () => {}); + expect( + getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'caller' && edge.target === 'helper', + ), + ).toHaveLength(1); + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); + + it.each([ + ['use target_boundary::shared::*;', true], + ['use target_boundary as api; use api::shared::*;', true], + ['use std::shared::*;', false], + ] as const)( + 'keeps crate identity for a file shared as both module and target: %s', + async (source, allowed) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-cargo-shared-root-')); + try { + writeFixtureRepo(dir, { + 'Cargo.toml': '[package]\nname="target-boundary"\nversion="0.1.0"\nedition="2021"\n', + 'src/lib.rs': '#[path="../tests/shared.rs"] pub mod shared;', + 'tests/shared.rs': 'pub fn helper() {}', + 'examples/caller.rs': `${source} pub fn caller() { helper(); }`, + }); + const result = await runPipelineFromRepo(dir, () => {}); + expect( + getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'caller' && edge.target === 'helper', + ), + ).toHaveLength(allowed ? 1 : 0); + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }, + ); + + it.each(['use std::fmt::*;', 'use target_boundary::nested::*;', 'use std::helper;'])( + 'an unrelated import cannot reach a binary-root helper: %s', + async (source) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-cargo-unrelated-glob-')); + try { + writeFixtureRepo(dir, { + 'Cargo.toml': '[package]\nname="target-boundary"\nversion="0.1.0"\nedition="2021"\n', + 'src/lib.rs': `${source} pub fn caller() { helper(); }`, + 'src/main.rs': 'pub fn helper() {}', + }); + const result = await runPipelineFromRepo(dir, () => {}); + expect(result.graph.getNode('Function:src/main.rs:helper')).toBeDefined(); + expect( + getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'caller' && edge.target === 'helper', + ), + ).toEqual([]); + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }, + ); + + it('an unrelated import cannot revive a rejected crate-root candidate', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-cargo-unrelated-')); + try { + writeFixtureRepo(dir, { + 'Cargo.toml': '[package]\nname="target-boundary"\nversion="0.1.0"\nedition="2021"\n', + 'src/lib.rs': 'use crate::helper; use std::fmt; pub fn caller() { helper(); }', + 'src/main.rs': 'pub fn helper() {}', + }); + const result = await runPipelineFromRepo(dir, () => {}); + expect(result.graph.getNode('Function:src/main.rs:helper')).toBeDefined(); + expect( + getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'caller' && edge.target === 'helper', + ), + ).toEqual([]); + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); + + it.each([ + 'use target_boundary::helper;', + 'use target_boundary::*;', + 'use target_boundary as api; use api::*;', + ])('preserves an explicit library import from an integration target: %s', async (source) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-cargo-library-')); + try { + writeFixtureRepo(dir, { + 'Cargo.toml': '[package]\nname="target-boundary"\nversion="0.1.0"\nedition="2021"\n', + 'src/lib.rs': 'pub fn helper() {}', + 'tests/caller.rs': `${source} pub fn caller() { helper(); }`, + }); + expect(await loadRustCargoTargets(dir)).toBeDefined(); + const result = await runPipelineFromRepo(dir, () => {}); + const calls = getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'caller' && edge.target === 'helper', + ); + expect(calls).toHaveLength(1); + expect(calls[0]!.targetFilePath).toBe('src/lib.rs'); + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); + + it.each([ + ['mod target_boundary {} use target_boundary::*;', '', false], + ['mod target_boundary {} use ::target_boundary::*;', '', true], + ['fn target_boundary() {} use target_boundary::*;', '', true], + ['mod api {} fn allowed() { use target_boundary as api; use api::*; helper(); }', '', true], + ['use std::*;', '', false], + ['use target_boundary::nested::*;', '', false], + ['use public_api::*;', '[lib]\nname="public_api"\n', true], + ['use target_boundary::*;', '[lib]\nname="public_api"\n', false], + [ + 'use target_boundary as api; fn denied() { use std::fmt as api; use api::*; helper(); }', + '', + false, + ], + ] as const)('requires the actual library root for %s', async (source, lib, allowed) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-cargo-root-name-')); + try { + writeFixtureRepo(dir, { + 'Cargo.toml': `[package]\nname="target-boundary"\nversion="0.1.0"\nedition="2021"\n${lib}`, + 'src/lib.rs': 'pub fn helper() {}', + 'tests/caller.rs': `${source} pub fn caller() { helper(); }`, + }); + const result = await runPipelineFromRepo(dir, () => {}); + const calls = getRelationships(result, 'CALLS').filter((edge) => edge.target === 'helper'); + expect(calls).toHaveLength(allowed ? 1 : 0); + expect(calls.map((edge) => edge.source)).toEqual( + allowed ? [source.includes('fn allowed()') ? 'allowed' : 'caller'] : [], + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); + + it.each([false, true])( + 'recognizes a renamed path dependency (workspace inherited: %s)', + async (inherited) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-cargo-dep-alias-')); + try { + writeFixtureRepo(dir, { + 'Cargo.toml': + '[package]\nname="root-lib"\nversion="0.1.0"\nedition="2021"\n[workspace]\nmembers=["consumer"]\n' + + (inherited ? '[workspace.dependencies]\nrenamed={package="root-lib",path="."}\n' : ''), + 'src/lib.rs': 'pub fn helper() {}', + 'consumer/Cargo.toml': + '[package]\nname="consumer"\nversion="0.1.0"\nedition="2021"\n[dependencies]\n' + + (inherited ? 'renamed={workspace=true}\n' : 'renamed={package="root-lib",path=".."}\n'), + 'consumer/src/lib.rs': 'use renamed::*; pub fn caller() { helper(); }', + }); + const result = await runPipelineFromRepo(dir, () => {}); + const calls = getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'caller' && edge.target === 'helper', + ); + expect(calls).toHaveLength(1); + expect(calls[0]!.targetFilePath).toBe('src/lib.rs'); + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }, + ); + + it.each([ + 'tests/helper.rs', + 'benches/helper.rs', + 'examples/helper.rs', + 'src/bin/helper.rs', + 'src/main.rs', + ])('does not use the separate target %s to satisfy a library crate import', async (target) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-cargo-target-')); + try { + writeFixtureRepo(dir, { + 'Cargo.toml': '[package]\nname = "target-boundary"\nversion = "0.1.0"\nedition = "2021"\n', + '.gitnexusignore': '!src/bin/\n', + 'src/lib.rs': 'use crate::helper;\npub fn caller() { helper(); }\n', + [target]: 'pub fn helper() {}\n', + }); + const result = await runPipelineFromRepo(dir, () => {}); + expect(result.graph.getNode(`Function:${target}:helper`)).toBeDefined(); + const calls = getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'caller' && edge.target === 'helper', + ); + expect(calls).toEqual([]); + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); + + it.each([ + ['library module', 'src/helper.rs', 'mod helper; use crate::helper::helper;'], + [ + 'unit-test module', + 'src/tests/helper.rs', + '#[cfg(test)] mod tests { pub mod helper; } use crate::tests::helper::helper;', + ], + [ + 'shared integration-test source', + 'tests/helper.rs', + '#[path="../tests/helper.rs"] mod shared; use crate::shared::helper;', + ], + ])('preserves a valid %s call', async (_name, target, source) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-cargo-positive-')); + try { + writeFixtureRepo(dir, { + 'Cargo.toml': '[package]\nname="positive"\nversion="0.1.0"\nedition="2021"\n', + 'src/lib.rs': `${source}\npub fn caller() { helper(); }\n`, + [target]: 'pub fn helper() {}\n', + }); + const result = await runPipelineFromRepo(dir, () => {}); + const calls = getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'caller' && edge.target === 'helper', + ); + expect(calls).toHaveLength(1); + expect(calls[0]!.targetFilePath).toBe(target); + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); + + it.each([ + ['missing metadata', undefined, ''], + ['malformed metadata', '[package', ''], + [ + 'unmodeled module expansion', + '[package]\nname="unknown"\nversion="0.1.0"\nedition="2021"\n', + 'include!("generated.rs");', + ], + [ + 'unmodeled extern-crate alias', + '[package]\nname="unknown"\nversion="0.1.0"\nedition="2021"\n', + 'extern crate self as api;', + ], + ])('preserves a labeled guess with %s', async (_name, manifest, prefix) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-cargo-unknown-')); + try { + writeFixtureRepo(dir, { + ...(manifest === undefined ? {} : { 'Cargo.toml': manifest }), + 'src/lib.rs': `${prefix}\nuse crate::helper; pub fn caller() { helper(); }`, + 'tests/helper.rs': 'pub fn helper() {}', + }); + expect(await loadRustCargoTargets(dir)).toBeUndefined(); + const result = await runPipelineFromRepo(dir, () => {}); + const calls = getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'caller' && edge.target === 'helper', + ); + expect(calls).toHaveLength(1); + expect(calls[0]!.rel.reason).toBe('global-name-fallback'); + expect(calls[0]!.rel.confidence).toBe(0.5); + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); + + it('refuses a crate-root helper from another target on typical derive/assert_eq source', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-cargo-derive-')); + try { + writeFixtureRepo(dir, { + 'Cargo.toml': '[package]\nname="demo"\nversion="0.1.0"\nedition="2021"\n', + 'src/lib.rs': + '#[derive(Debug)] struct S;\npub fn helper() {}\nfn t() { assert_eq!(1, 1); }\n', + 'tests/caller.rs': 'pub fn caller() { helper(); }\n', + }); + const result = await runPipelineFromRepo(dir, () => {}); + expect( + getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'caller' && edge.target === 'helper', + ), + ).toEqual([]); + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); + + it('does not treat pub(crate) use as a public re-export for integration targets', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-cargo-pub-crate-')); + try { + writeFixtureRepo(dir, { + 'Cargo.toml': '[package]\nname="demo"\nedition="2021"\n', + 'src/lib.rs': 'pub mod nested { pub fn helper() {} } pub(crate) use nested::helper;', + 'tests/caller.rs': 'use demo::*; pub fn caller() { helper(); }', + }); + const result = await runPipelineFromRepo(dir, () => {}); + expect( + getRelationships(result, 'CALLS').filter( + (edge) => edge.source === 'caller' && edge.target === 'helper', + ), + ).toEqual([]); + } finally { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }); +}); diff --git a/gitnexus/test/integration/resolvers/rust-coverage.test.ts b/gitnexus/test/integration/resolvers/rust-coverage.test.ts index 6d8d983b8..a317743b9 100644 --- a/gitnexus/test/integration/resolvers/rust-coverage.test.ts +++ b/gitnexus/test/integration/resolvers/rust-coverage.test.ts @@ -88,3 +88,16 @@ describe('F72 — macro invocations (capture layer)', () => { expect(macroDecls[0]['@declaration.name'].text).toBe('greet'); }); }); + +describe('restricted visibility use is not a re-export', () => { + it.each([ + ['pub use foo::helper;', 'reexport'], + ['pub(crate) use foo::helper;', 'named'], + ['pub(super) use foo::helper;', 'named'], + ['use foo::helper;', 'named'], + ])('%s', (src, kind) => { + const matches = emitRustScopeCaptures(src, 'test.rs') as CaptureMatch[]; + const imps = matches.filter((m) => m['@import.kind']); + expect(imps.map((m) => m['@import.kind']?.text)).toEqual([kind]); + }); +}); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index feedc664f..f02c3879e 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -275,9 +275,12 @@ describe('PARSE_CACHE_VERSION', () => { // registered accessor goes back to `epistemic: "exact"`: the #3399 defect, // silently un-fixed on exactly the incremental path most users are on. // Moved 98 -> 99 for #3190: lexical import provenance and corrected export - // evidence. origin/main took 98 for #3219; 99 is the next free value. - it('pins SCHEMA_BUMP to 99 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(99); + // evidence. origin/main took 98 for #3219 and 99 for #3190. + // Moved 99 -> 100 for #3253: retain absolute Rust import qualifiers. + // Moved 100 -> 101 for #3294 review: retain keyword glob paths and distinguish + // restricted pub(...) imports from unrestricted reexports. + it('pins SCHEMA_BUMP to 101 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(101); expect(PARSE_CACHE_BUCKET_COUNT).toBe(128); // The PREVIOUS version must fail the reuse gate, not merely differ from the // current one — a hardcoded number outside the conflict hunk rebases cleanly @@ -285,7 +288,7 @@ describe('PARSE_CACHE_VERSION', () => { // Every nearby historical or in-flight value is rejected. for (const taken of [ 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, - 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, + 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, ]) { expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken); } diff --git a/gitnexus/test/unit/scope-resolution/name-fallback-visibility.test.ts b/gitnexus/test/unit/scope-resolution/name-fallback-visibility.test.ts index 8548a0232..64d095d9e 100644 --- a/gitnexus/test/unit/scope-resolution/name-fallback-visibility.test.ts +++ b/gitnexus/test/unit/scope-resolution/name-fallback-visibility.test.ts @@ -3,11 +3,11 @@ * shared path arithmetic they are built on. * * These hooks decide whether a UNIQUE-NAME GUESS is allowed to become a labeled - * CALLS edge or must be dropped as impossible. The asymmetry matters for how - * these tests are written: a wrong `false` deletes a real edge, so every case - * that the language cannot decide is asserted to return `true`. "Refuses when - * impossible" and "does not refuse when merely unproven" are therefore BOTH - * requirements, and both are tested per language. + * CALLS edge or must be dropped by a language visibility rule. A wrong `false` + * deletes a real edge, so uncertainty alone must not invent a refusal. It also + * must not bypass an independent rule: Rust cross-file bare calls still need + * a visible use path, whether Cargo membership is known or unknown. Tests pin + * both required refusals and permitted guesses under incomplete evidence. * * Pure functions over synthetic stubs — no pipeline, no fixtures. */ @@ -438,13 +438,23 @@ describe('Rust: isGlobalNameFallbackPlausible', () => { ).toBe(true); }); - it('does not refuse when the candidate file maps to no module path', () => { + it('REFUSES a crate-root candidate without cargo identity or a covering use', () => { expect( rustIsGlobalNameFallbackPlausible({ site: BARE_SITE, callerParsed: mkCaller('src/b.rs'), candidate: mkCandidate('lib.rs', 'unique_helper_xyz'), }), + ).toBe(false); + }); + + it('allows a crate-root candidate when a covering use names it', () => { + expect( + rustIsGlobalNameFallbackPlausible({ + site: BARE_SITE, + callerParsed: mkCaller('src/b.rs', [{ kind: 'wildcard', targetRaw: 'crate' }]), + candidate: mkCandidate('lib.rs', 'unique_helper_xyz'), + }), ).toBe(true); }); diff --git a/gitnexus/test/unit/scope-resolution/rust-cargo-targets.test.ts b/gitnexus/test/unit/scope-resolution/rust-cargo-targets.test.ts new file mode 100644 index 000000000..7458c5fcc --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/rust-cargo-targets.test.ts @@ -0,0 +1,636 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + cargoTargetRoots, + loadRustCargoTargets, + rustFilesShareCargoTarget, + rustImportNamesCargoRoot, +} from '../../../src/core/ingestion/languages/rust/cargo-targets.js'; +import { emitRustScopeCaptures } from '../../../src/core/ingestion/languages/rust/captures.js'; +import { interpretRustImport } from '../../../src/core/ingestion/languages/rust/interpret.js'; + +const PACKAGE = '[package]\nname = "demo"\nversion = "0.1.0"\nedition = "2021"\n'; +const temporary: string[] = []; +afterEach(() => { + vi.restoreAllMocks(); + for (const dir of temporary.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); +function fixture(files: Record): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-cargo-targets-')); + temporary.push(dir); + for (const [file, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); + fs.writeFileSync(path.join(dir, file), content); + } + return dir; +} + +describe('Cargo manifest target metadata', () => { + it.each(['crate', 'self', 'super'])('preserves the %s keyword in a glob import', (keyword) => { + const imports = emitRustScopeCaptures(`use ${keyword}::*;`, 'fixture.rs') + .map(interpretRustImport) + .filter((entry) => entry !== null); + expect(imports).toEqual([{ kind: 'wildcard', targetRaw: keyword }]); + }); + const files = new Set([ + 'src/lib.rs', + 'src/main.rs', + 'src/bin/tool.rs', + 'src/bin/other/main.rs', + 'tests/helper.rs', + 'benches/speed.rs', + 'examples/demo/main.rs', + 'custom/entry.rs', + 'build.rs', + ]); + + it('discovers lib, main, binary, test, bench, example and build-script roots', () => { + expect(new Set(cargoTargetRoots('Cargo.toml', PACKAGE, files))).toEqual( + new Set([...files].filter((file) => file !== 'custom/entry.rs')), + ); + }); + + it('accepts Cargo build=true and declines overlapping build and library roles', () => { + expect(cargoTargetRoots('Cargo.toml', `${PACKAGE}build=true\n`, files)).toContain('build.rs'); + expect( + cargoTargetRoots('Cargo.toml', `${PACKAGE}[lib]\npath="build.rs"\n`, files), + ).toBeUndefined(); + }); + + it.each([ + ['autolib', 'src/lib.rs'], + ['autobins', 'src/bin/tool.rs'], + ['autotests', 'tests/helper.rs'], + ['autobenches', 'benches/speed.rs'], + ['autoexamples', 'examples/demo/main.rs'], + ])('honors %s = false', (key, absent) => { + const roots = cargoTargetRoots('Cargo.toml', `${PACKAGE}${key} = false\n`, files); + expect(roots).toBeDefined(); + expect(roots).not.toContain(absent); + expect(roots).toContain(key === 'autolib' ? 'src/main.rs' : 'src/lib.rs'); + }); + + it('uses an explicit build-script path instead of the default', () => { + const roots = cargoTargetRoots('Cargo.toml', `${PACKAGE}build="custom/entry.rs"\n`, files); + expect(roots).toContain('custom/entry.rs'); + expect(roots).toContain('src/lib.rs'); + expect(roots).not.toContain('build.rs'); + }); + + it('explicit paths override auto-discovered targets of the same name', () => { + const roots = cargoTargetRoots( + 'Cargo.toml', + `${PACKAGE}\n[[test]]\nname = 'helper'\npath = 'custom/entry.rs'\n`, + files, + ); + expect(roots).toContain('custom/entry.rs'); + expect(roots).not.toContain('tests/helper.rs'); + }); + + it('explicit lib paths work with autolib disabled', () => { + const roots = cargoTargetRoots( + 'Cargo.toml', + `${PACKAGE}autolib = false\n[lib]\npath = 'custom/entry.rs'\n`, + files, + ); + expect(roots).toContain('custom/entry.rs'); + expect(roots).not.toContain('src/lib.rs'); + }); + + it.each([ + ['bin', 'tool', ['src/main.rs', 'src/bin/other/main.rs']], + ['test', 'helper', ['tests/extra.rs']], + ['bench', 'speed', ['benches/extra.rs']], + ['example', 'demo', ['examples/extra.rs']], + ] as const)( + 'Cargo 2015 explicit %s targets only disable discovery of that kind', + (kind, name, excluded) => { + const discovered = new Set([ + ...files, + 'tests/extra.rs', + 'benches/extra.rs', + 'examples/extra.rs', + ]); + expect( + new Set( + cargoTargetRoots( + 'Cargo.toml', + `[package]\nname="demo"\nbuild=false\n[[${kind}]]\nname="${name}"\n`, + discovered, + ), + ), + ).toEqual( + new Set( + [...discovered].filter( + (file) => + file !== 'build.rs' && + file !== 'custom/entry.rs' && + !(excluded as readonly string[]).includes(file), + ), + ), + ); + }, + ); + + it('retains workspace/package prefixes', () => { + expect( + cargoTargetRoots('crates/a/Cargo.toml', PACKAGE, new Set(['crates/a/src/lib.rs'])), + ).toEqual(['crates/a/src/lib.rs']); + expect(cargoTargetRoots('Cargo.toml', '[workspace]\nmembers=["crates/a"]\n', files)).toEqual( + [], + ); + }); + + it.each([ + '[package', + `${PACKAGE}\n[[test]]\npath="missing.rs"\n`, + `${PACKAGE}autotests="false"\n`, + `${PACKAGE}build=1\n`, + `${PACKAGE}[[bin]]\npath="custom/entry.rs"\n`, + `${PACKAGE}[[test]]\npath="custom/entry.rs"\n`, + ])('does not manufacture evidence from malformed metadata', (manifest) => { + expect(cargoTargetRoots('Cargo.toml', manifest, files)).toBeUndefined(); + }); +}); + +describe('Rust module membership', () => { + it.each([ + '.env', + '.git/hidden.rs', + '.GiT/hidden.rs', + '.gitnexus/hidden.rs', + 'node_modules/pkg/hidden.rs', + ])('does not restore excluded non-source/control paths: %s', async (hidden) => { + const dir = fixture({ + 'Cargo.toml': PACKAGE, + 'src/lib.rs': `#[path="../${hidden}"] mod hidden;`, + [hidden]: 'pub fn helper() {}', + }); + const realpath = vi.spyOn(fs.promises, 'realpath'); + expect(await loadRustCargoTargets(dir)).toBeUndefined(); + expect(realpath.mock.calls.some(([file]) => String(file) === path.join(dir, hidden))).toBe( + false, + ); + }); + + it('does not interpret a Cargo std alias as the standard library', async () => { + const dir = fixture({ + 'Cargo.toml': `${PACKAGE}[dependencies]\nstd={package="custom",version="1"}\n`, + 'src/lib.rs': 'use std::*; fn f(){ println!(); }', + }); + expect(await loadRustCargoTargets(dir)).toBeUndefined(); + }); + it('retains path dependencies from conditional target sections', async () => { + const dir = fixture({ + 'Cargo.toml': `${PACKAGE}[target.'cfg(unix)'.dependencies]\nother={path="other"}\n`, + 'src/lib.rs': '', + 'other/Cargo.toml': '[package]\nname="other"\nedition="2021"\n', + 'other/src/lib.rs': '', + }); + const config = await loadRustCargoTargets(dir); + expect(config).toBeDefined(); + expect(rustImportNamesCargoRoot(config, 'src/lib.rs', 'other/src/lib.rs', 'other')).toBe(true); + }); + it.each(['../..', '../../'])( + 'resolves a directory-form workspace pointer: %s', + async (workspace) => { + const dir = fixture({ + 'Cargo.toml': + '[workspace]\nmembers=["crates/a", "crates/b"]\n[workspace.package]\nedition="2021"\n[workspace.dependencies]\nb={path="crates/b"}\n', + 'crates/a/Cargo.toml': `[package]\nname="a"\nworkspace="${workspace}"\nedition.workspace=true\n[dependencies]\nb.workspace=true\n`, + 'crates/a/src/lib.rs': '', + 'crates/a/tests/helper.rs': '', + 'crates/b/Cargo.toml': '[package]\nname="b"\nedition="2021"\n', + 'crates/b/src/lib.rs': '', + }); + const config = await loadRustCargoTargets(dir); + expect(config).toBeDefined(); + expect( + rustFilesShareCargoTarget(config, 'crates/a/src/lib.rs', 'crates/a/tests/helper.rs'), + ).toBe(false); + expect( + rustImportNamesCargoRoot(config, 'crates/a/src/lib.rs', 'crates/b/src/lib.rs', 'b'), + ).toBe(true); + }, + ); + + it('rejects a manifest filename as workspace pointer, as Cargo does', async () => { + const dir = fixture({ + 'Cargo.toml': '[workspace]\nmembers=["crates/a"]\n[workspace.package]\nedition="2021"\n', + 'crates/a/Cargo.toml': + '[package]\nname="a"\nworkspace="../../Cargo.toml"\nedition.workspace=true\n', + 'crates/a/src/lib.rs': '', + }); + expect(await loadRustCargoTargets(dir)).toBeUndefined(); + }); + + it.each([ + '#[derive(Custom)] struct T;', + 'use custom::Debug; #[derive(Debug)] struct T;', + 'macro_rules! println { () => { #[path="../tests/helper.rs"] mod shared; } } fn f() { println!(); }', + 'use custom::println; fn f() { println!(); }', + 'use custom::*; fn f() { println!(); }', + 'fn f() { println!("{}", { #[path="../tests/helper.rs"] mod shared; 1 }); }', + 'fn f() { println!("{}", include!("generated.rs")); }', + '#[tokio::test] async fn f() {}', + '#[some_macro::cfg] fn f() {}', + '#[some_macro::allow] fn f() {}', + 'fn f() { custom::println!(); }', + 'println!("item position");', + ])('does not mistake unknown or shadowed expansion for a builtin: %s', async (source) => { + const dir = fixture({ 'Cargo.toml': PACKAGE, 'src/lib.rs': source, 'tests/helper.rs': '' }); + expect(await loadRustCargoTargets(dir)).toBeUndefined(); + }); + + it('does not assume a child-module macro is std when its parent shadows that name', async () => { + const dir = fixture({ + 'Cargo.toml': PACKAGE, + 'src/lib.rs': 'macro_rules! println { () => { mod generated; } } mod child;', + 'src/child.rs': 'fn f() { println!(); }', + 'tests/helper.rs': '', + }); + expect(await loadRustCargoTargets(dir)).toBeUndefined(); + }); + it('keeps build dependencies separate while retaining unit-test dependencies', async () => { + const dir = fixture({ + 'Cargo.toml': `${PACKAGE}[dependencies]\nnormal={path="normal"}\n[dev-dependencies]\ndev={path="dev"}\n[build-dependencies]\nbuilder={path="builder"}\n`, + 'src/lib.rs': '', + 'build.rs': 'fn main() {}', + ...Object.fromEntries( + ['normal', 'dev', 'builder'].flatMap((name) => [ + [`${name}/Cargo.toml`, `[package]\nname="${name}"\nedition="2021"\n`], + [`${name}/src/lib.rs`, ''], + ]), + ), + }); + const config = await loadRustCargoTargets(dir); + expect(config).toBeDefined(); + for (const name of ['normal', 'dev', 'builder']) { + expect(rustImportNamesCargoRoot(config, 'src/lib.rs', `${name}/src/lib.rs`, name)).toBe( + name !== 'builder', + ); + expect(rustImportNamesCargoRoot(config, 'build.rs', `${name}/src/lib.rs`, name)).toBe( + name === 'builder', + ); + } + expect(rustImportNamesCargoRoot(config, 'build.rs', 'src/lib.rs', 'demo')).toBe(false); + }); + + it.each([false, true])( + 'uses the correct import name for a custom library (package explicit: %s)', + async (explicit) => { + const dir = fixture({ + 'Cargo.toml': `${PACKAGE}[lib]\nname="public_api"\n`, + 'src/lib.rs': '', + 'consumer/Cargo.toml': `[package]\nname="consumer"\nedition="2021"\n[dependencies]\ndemo={${explicit ? 'package="demo",' : ''}path=".."}\n`, + 'consumer/src/lib.rs': '', + }); + const config = await loadRustCargoTargets(dir); + expect( + rustImportNamesCargoRoot( + config, + 'consumer/src/lib.rs', + 'src/lib.rs', + explicit ? 'demo' : 'public_api', + ), + ).toBe(true); + expect( + rustImportNamesCargoRoot( + config, + 'consumer/src/lib.rs', + 'src/lib.rs', + explicit ? 'public_api' : 'demo', + ), + ).toBe(false); + }, + ); + + it('a dependency alias in another package is not import evidence for this caller', async () => { + const dir = fixture({ + 'Cargo.toml': PACKAGE, + 'src/lib.rs': '', + 'a/Cargo.toml': + '[package]\nname="a"\nedition="2021"\n[dependencies]\napi={package="demo",path=".."}\n', + 'a/src/lib.rs': '', + 'b/Cargo.toml': '[package]\nname="b"\nedition="2021"\n', + 'b/src/lib.rs': '', + }); + const config = await loadRustCargoTargets(dir); + expect(rustImportNamesCargoRoot(config, 'a/src/lib.rs', 'src/lib.rs', 'api')).toBe(true); + expect(rustImportNamesCargoRoot(config, 'b/src/lib.rs', 'src/lib.rs', 'api')).toBe(false); + }); + + it('uses Cargo library metadata rather than the entry file name', async () => { + const dir = fixture({ + 'Cargo.toml': `${PACKAGE}[lib]\npath="src/main.rs"\nname="api"\n`, + 'src/main.rs': '', + 'tests/caller.rs': '', + }); + expect( + rustImportNamesCargoRoot( + await loadRustCargoTargets(dir), + 'tests/caller.rs', + 'src/main.rs', + 'api', + ), + ).toBe(true); + fs.writeFileSync( + path.join(dir, 'Cargo.toml'), + `${PACKAGE}autolib=false\n[[bin]]\nname="api"\npath="src/main.rs"\n`, + ); + expect( + rustImportNamesCargoRoot( + await loadRustCargoTargets(dir), + 'tests/caller.rs', + 'src/main.rs', + 'api', + ), + ).toBe(false); + }); + + it('distinguishes all package targets even though directory prefixes overlap', async () => { + const paths = [ + 'src/lib.rs', + 'src/main.rs', + 'src/bin/tool.rs', + 'tests/helper.rs', + 'benches/helper.rs', + 'examples/helper.rs', + ]; + const dir = fixture({ + 'Cargo.toml': PACKAGE, + ...Object.fromEntries(paths.map((file) => [file, 'pub fn helper() {}'])), + }); + const config = await loadRustCargoTargets(dir); + for (const target of paths.slice(1)) + expect(rustFilesShareCargoTarget(config, paths[0]!, target)).toBe(false); + }); + + it('follows normal, nested, inline and unit-test modules', async () => { + const dir = fixture({ + 'Cargo.toml': PACKAGE, + 'src/lib.rs': 'mod foo; #[cfg(test)] mod tests { mod helper; }', + 'src/foo.rs': 'mod nested;', + 'src/foo/nested.rs': '', + 'src/tests/helper.rs': '', + }); + const config = await loadRustCargoTargets(dir); + for (const file of ['src/foo.rs', 'src/foo/nested.rs', 'src/tests/helper.rs']) { + expect(rustFilesShareCargoTarget(config, 'src/lib.rs', file)).toBe(true); + } + }); + + it('does not mistake an auto-target named target for a build artifact directory', async () => { + const dir = fixture({ + 'Cargo.toml': PACKAGE, + 'src/lib.rs': '', + 'tests/helper.rs': '', + 'src/bin/target/main.rs': + '#[path="../../lib.rs"] mod lib; #[path="../../../tests/helper.rs"] mod helper;', + }); + expect( + rustFilesShareCargoTarget(await loadRustCargoTargets(dir), 'src/lib.rs', 'tests/helper.rs'), + ).toBe(true); + }); + + it('permits a tests/ file shared with the library using #[path]', async () => { + const dir = fixture({ + 'Cargo.toml': PACKAGE, + 'src/lib.rs': '#[path = "../tests/helper.rs"] mod helper;', + 'tests/helper.rs': '', + }); + expect( + rustFilesShareCargoTarget(await loadRustCargoTargets(dir), 'src/lib.rs', 'tests/helper.rs'), + ).toBe(true); + }); + + it('a #[path] file owns its directory when loading its own submodules', async () => { + const dir = fixture({ + 'Cargo.toml': PACKAGE, + 'src/lib.rs': '#[path="../tests/helper.rs"] mod helper;', + 'tests/helper.rs': 'pub mod inner;', + 'tests/inner.rs': 'pub fn found() {}', + 'tests/helper/inner.rs': 'pub fn different() {}', + }); + const config = await loadRustCargoTargets(dir); + expect(rustFilesShareCargoTarget(config, 'src/lib.rs', 'tests/inner.rs')).toBe(true); + expect( + rustFilesShareCargoTarget(config, 'src/lib.rs', 'tests/helper/inner.rs'), + ).toBeUndefined(); + }); + + it('supports raw-string paths and inline path bases in non-mod.rs files', async () => { + const dir = fixture({ + 'Cargo.toml': PACKAGE, + 'src/lib.rs': 'mod foo;', + 'src/foo.rs': 'mod inner { #[path = r#"helper.rs"#] mod helper; }', + 'src/foo/inner/helper.rs': '', + }); + expect( + rustFilesShareCargoTarget( + await loadRustCargoTargets(dir), + 'src/lib.rs', + 'src/foo/inner/helper.rs', + ), + ).toBe(true); + }); + + it('an inline module path override is relative to the source directory', async () => { + const dir = fixture({ + 'Cargo.toml': PACKAGE, + 'src/lib.rs': 'mod foo;', + 'src/foo.rs': '#[path="thread_files"] mod thread { #[path="tls.rs"] mod local_data; }', + 'src/thread_files/tls.rs': '', + }); + expect( + rustFilesShareCargoTarget( + await loadRustCargoTargets(dir), + 'src/lib.rs', + 'src/thread_files/tls.rs', + ), + ).toBe(true); + }); + + it('does not treat derive or expression-position std macros as unknown expansion', async () => { + const dir = fixture({ + 'Cargo.toml': PACKAGE, + 'src/lib.rs': + '#[derive(Debug)] struct S;\n#[test] #[should_panic] fn t() { println!("hi"); assert_eq!(1, 1); let _ = vec![1]; let _ = format!("{}", 1); }', + 'tests/helper.rs': '', + }); + expect( + rustFilesShareCargoTarget(await loadRustCargoTargets(dir), 'src/lib.rs', 'tests/helper.rs'), + ).toBe(false); + }); + + it('keeps a library module whose path segment is named target', async () => { + const dir = fixture({ + 'Cargo.toml': PACKAGE, + 'src/lib.rs': 'mod target;', + 'src/target/mod.rs': '', + 'tests/helper.rs': '', + }); + const config = await loadRustCargoTargets(dir); + expect(rustFilesShareCargoTarget(config, 'src/lib.rs', 'src/target/mod.rs')).toBe(true); + expect(rustFilesShareCargoTarget(config, 'src/lib.rs', 'tests/helper.rs')).toBe(false); + }); + + it('keeps an explicit [lib] path under target/', async () => { + const dir = fixture({ + 'Cargo.toml': `${PACKAGE}[lib]\npath = "target/entry.rs"\n`, + 'target/entry.rs': '', + 'tests/helper.rs': '', + }); + expect( + rustFilesShareCargoTarget( + await loadRustCargoTargets(dir), + 'target/entry.rs', + 'tests/helper.rs', + ), + ).toBe(false); + }); + + it.each([ + 'include!("generated.rs");', + 'extern crate self as api;', + '#[cfg_attr(feature="x", path="elsewhere.rs")] mod helper;', + '#[custom_macro] mod helper;', + 'mod missing;', + 'mod broken {', + ])('returns unknown for incomplete module evidence: %s', async (source) => { + const dir = fixture({ 'Cargo.toml': PACKAGE, 'src/lib.rs': source, 'tests/helper.rs': '' }); + expect( + rustFilesShareCargoTarget(await loadRustCargoTargets(dir), 'src/lib.rs', 'tests/helper.rs'), + ).toBeUndefined(); + }); + + it('ignores module-like text in strings and comments', async () => { + const dir = fixture({ + 'Cargo.toml': PACKAGE, + 'src/lib.rs': '// mod missing;\nconst S: &str = "mod absent;";', + 'tests/helper.rs': '', + }); + expect( + rustFilesShareCargoTarget(await loadRustCargoTargets(dir), 'src/lib.rs', 'tests/helper.rs'), + ).toBe(false); + }); + + it('does not retain membership across changed source snapshots', async () => { + const dir = fixture({ 'Cargo.toml': PACKAGE, 'src/lib.rs': '', 'tests/helper.rs': '' }); + expect( + rustFilesShareCargoTarget(await loadRustCargoTargets(dir), 'src/lib.rs', 'tests/helper.rs'), + ).toBe(false); + fs.writeFileSync(path.join(dir, 'src/lib.rs'), '#[path="../tests/helper.rs"] mod helper;'); + expect( + rustFilesShareCargoTarget(await loadRustCargoTargets(dir), 'src/lib.rs', 'tests/helper.rs'), + ).toBe(true); + }); + + it('honors inherited workspace editions and package-local custom targets', async () => { + const dir = fixture({ + 'Cargo.toml': + '[workspace]\nmembers=["crates/a", "crates/b"]\n[workspace.package]\nedition="2021"\n', + 'crates/a/Cargo.toml': + '[package]\nname="a"\nedition.workspace=true\n[lib]\npath="library/entry.rs"\n', + 'crates/a/library/entry.rs': '', + 'crates/a/tests/helper.rs': '', + 'crates/b/Cargo.toml': PACKAGE, + 'crates/b/src/lib.rs': '', + }); + const config = await loadRustCargoTargets(dir); + expect( + rustFilesShareCargoTarget(config, 'crates/a/library/entry.rs', 'crates/a/tests/helper.rs'), + ).toBe(false); + expect( + rustFilesShareCargoTarget(config, 'crates/a/library/entry.rs', 'crates/b/src/lib.rs'), + ).toBe(false); + }); + + it('a disabled integration target can still be a library module', async () => { + const dir = fixture({ + 'Cargo.toml': `${PACKAGE}autotests=false\n`, + 'src/lib.rs': '#[path="../tests/helper.rs"] mod helper;', + 'tests/helper.rs': '', + }); + expect( + rustFilesShareCargoTarget(await loadRustCargoTargets(dir), 'src/lib.rs', 'tests/helper.rs'), + ).toBe(true); + fs.writeFileSync(path.join(dir, 'src/lib.rs'), ''); + expect( + rustFilesShareCargoTarget(await loadRustCargoTargets(dir), 'src/lib.rs', 'tests/helper.rs'), + ).toBeUndefined(); + }); + + it('inspects external modules and expansion uncertainty in function bodies', async () => { + const dir = fixture({ + 'Cargo.toml': PACKAGE, + 'src/lib.rs': 'fn local() { #[path="../tests/helper.rs"] mod helper; }', + 'tests/helper.rs': '', + }); + expect( + rustFilesShareCargoTarget(await loadRustCargoTargets(dir), 'src/lib.rs', 'tests/helper.rs'), + ).toBe(true); + fs.writeFileSync(path.join(dir, 'src/lib.rs'), 'fn local() { include!("generated.rs"); }'); + expect( + rustFilesShareCargoTarget(await loadRustCargoTargets(dir), 'src/lib.rs', 'tests/helper.rs'), + ).toBeUndefined(); + }); + + it('uses the safe parser for sources exceeding the native Windows string limit', async () => { + const dir = fixture({ + 'Cargo.toml': PACKAGE, + 'src/lib.rs': `// ${'x'.repeat(40_000)}\nmod helper;`, + 'src/helper.rs': '', + 'tests/helper.rs': '', + }); + const config = await loadRustCargoTargets(dir); + expect(rustFilesShareCargoTarget(config, 'src/lib.rs', 'src/helper.rs')).toBe(true); + expect(rustFilesShareCargoTarget(config, 'src/lib.rs', 'tests/helper.rs')).toBe(false); + }); + + it('does not read through a module symlink outside the repository', async () => { + const outside = fixture({ 'helper.rs': 'pub fn helper() {}' }); + const dir = fixture({ + 'Cargo.toml': PACKAGE, + 'src/lib.rs': 'mod helper;', + 'tests/helper.rs': '', + }); + fs.symlinkSync(path.join(outside, 'helper.rs'), path.join(dir, 'src/helper.rs')); + expect( + rustFilesShareCargoTarget(await loadRustCargoTargets(dir), 'src/lib.rs', 'tests/helper.rs'), + ).toBeUndefined(); + }); + + it('discards membership when a checked source is replaced before the read', async () => { + const dir = fixture({ 'Cargo.toml': PACKAGE, 'src/lib.rs': '', 'tests/helper.rs': '' }); + const source = path.join(dir, 'src/lib.rs'); + const originalStat = fs.statSync(source); + let replaced = false; + const replace = (stat: fs.Stats) => { + if (!replaced && stat.dev === originalStat.dev && stat.ino === originalStat.ino) { + replaced = true; + fs.renameSync(source, `${source}.old`); + fs.writeFileSync(source, 'pub fn replacement() {}'); + } + }; + // Exercise the same replacement against the old path-stat/read sequence + // and the descriptor-based reader. Neither may accept the unchecked file. + const pathStat = fs.promises.stat.bind(fs.promises); + vi.spyOn(fs.promises, 'stat').mockImplementation(async (...args) => { + const stat = await pathStat(...args); + replace(stat as fs.Stats); + return stat; + }); + const descriptorStat = fs.fstatSync.bind(fs); + vi.spyOn(fs, 'fstatSync').mockImplementation((...args) => { + const stat = descriptorStat(...args); + replace(stat as fs.Stats); + return stat; + }); + const config = await loadRustCargoTargets(dir); + expect(replaced).toBe(true); + expect(rustFilesShareCargoTarget(config, 'src/lib.rs', 'tests/helper.rs')).toBeUndefined(); + }); +});