Merge branch 'main' into main

This commit is contained in:
Иван 2026-08-28 16:38:48 +03:00 • committed by GitHub
commit 978e6901a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
142 changed files with 19410 additions and 638 deletions

View file

@ -6,7 +6,7 @@
"plugins": [
{
"name": "gitnexus",
"version": "1.6.9",
"version": "1.6.10",
"source": {
"source": "local",
"path": "./gitnexus-claude-plugin"

View file

@ -11,7 +11,7 @@
"plugins": [
{
"name": "gitnexus",
"version": "1.6.9",
"version": "1.6.10",
"source": "./gitnexus-claude-plugin",
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase."
}

12
.gitattributes vendored
View file

@ -15,3 +15,15 @@
*.so binary
*.dll binary
*.dylib binary
# TypeScript sources are always text for diff purposes. Git's binary
# heuristic fires when EITHER blob in a pair carries a NUL, so a source
# file that carried one on a base commit still renders as "Binary files
# differ" — with no hunks and no inline comments — long after the byte
# itself is gone from the working tree. A head-side guard cannot see
# that, by construction. This does not mark the files binary or change
# how they are stored; it only stops the heuristic from hiding a diff.
*.ts diff
*.tsx diff
*.mts diff
*.cts diff

View file

@ -48,7 +48,7 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
languages: ${{ matrix.language }}
queries: security-and-quality
@ -73,6 +73,6 @@ jobs:
- '**/test/**/fixtures/**'
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
category: '/language:${{ matrix.language }}'

View file

@ -141,7 +141,7 @@ jobs:
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Install Cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2

View file

@ -53,6 +53,6 @@ jobs:
retention-days: 5
- name: Upload to Security tab
uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
sarif_file: results.sarif

View file

@ -50,7 +50,7 @@ jobs:
persist-credentials: false
- name: Setup Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Build image (load locally for scan)
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
@ -76,7 +76,7 @@ jobs:
exit-code: '0'
- name: Upload to Security tab
uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
sarif_file: trivy-${{ matrix.image.name }}.sarif
category: trivy-${{ matrix.image.name }}

View file

@ -76,7 +76,7 @@ jobs:
continue-on-error: true
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
sarif_file: zizmor.sarif
category: zizmor

View file

@ -174,7 +174,7 @@ converging on the routes phase's `(method, url)` registry:
| Filesystem convention | path → URL, no parsing | Next.js `app/`, Expo, PHP |
| Single-file framework route | `isRouteFile` + worker extraction | Laravel `routes/*.php` |
| Cross-file framework route | `discoverRootRouteFiles` + `extractRoutes` | Django `urlpatterns` |
| AST-level route in a normal file | `extractDecoratorRoutes` | Spring, FastAPI, NestJS, **JS/TS dispatch guards and static data route tables** |
| AST-level route in a normal file | `extractDecoratorRoutes` | Spring, FastAPI, NestJS (`@Controller` + `@Get`/`@Post`/…; URLs are controller-relative — `setGlobalPrefix` and URI versioning live in the bootstrap file and are not applied), **JS/TS dispatch guards and static data route tables** |
The last row is the one whose name undersells it. A route is DECLARED by a
decorator, but it can also be **inferred** from a raw `node:http` server's own
@ -403,6 +403,7 @@ Each language implements `LanguageProvider` (`language-provider.ts`). Key fields
| `typeConfig` | Type annotation extraction rules |
| `mroStrategy` | `first-wins` / `c3` / `none` |
| `descriptionExtractor` | Optional hook returning a symbol's doc-comment text as its `description`; feeds the embedding metadata header so doc-only terms are semantically searchable (issue #2270). Most languages register `createLeadingDocDescriptionExtractor` (shared, language-neutral; per-language comment/wrapper config passed at the call site) |
| `definitionPropertiesExtractor` | Optional language-owned hook for structured, clone-safe definition metadata. Shared ingestion persists these properties opaquely; the owning provider supplies the extraction semantics. |
16 providers in `languages/index.ts` via `satisfies Record<SupportedLanguages, LanguageProvider>` — missing a language is a compile error.

View file

@ -28,8 +28,10 @@ from .proposer_sandbox import (
SandboxError,
)
PINNED_GITNEXUS_VERSION = "1.6.9"
HARNESS_ROOT = Path(__file__).resolve().parents[2]
# The mounted runtime is built from this checkout, so the pin tracks the harness'
# own package version. A hardcoded copy only drifts on release day (#3064).
PINNED_GITNEXUS_VERSION = json.loads((HARNESS_ROOT / "gitnexus" / "package.json").read_text())["version"]
CE_ARMS = frozenset({"ce_workflow", "ce_workflow_direct", "ce_review"})
SANDBOX_CE_PLUGIN = "/opt/compound-engineering-plugin"

View file

@ -1,7 +1,7 @@
{
"name": "gitnexus",
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase.",
"version": "1.6.9",
"version": "1.6.10",
"author": {
"name": "GitNexus"
},

View file

@ -1,7 +1,7 @@
{
"name": "gitnexus",
"description": "Code intelligence powered by a knowledge graph. Provides execution flow tracing, blast radius analysis, and augmented search across your codebase.",
"version": "1.6.9",
"version": "1.6.10",
"skills": "./skills",
"mcpServers": "./.mcp.json",
"hooks": "./hooks/hooks.json",

View file

@ -2,7 +2,7 @@
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@1.6.9", "mcp"]
"args": ["-y", "gitnexus@1.6.10", "mcp"]
}
}
}

View file

@ -2,7 +2,7 @@
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@1.6.9", "mcp"]
"args": ["-y", "gitnexus@1.6.10", "mcp"]
}
}
}

View file

@ -2,7 +2,7 @@
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@1.6.9", "mcp"]
"args": ["-y", "gitnexus@1.6.10", "mcp"]
}
}
}

View file

@ -2,7 +2,7 @@
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@1.6.9", "mcp"]
"args": ["-y", "gitnexus@1.6.10", "mcp"]
}
}
}

View file

@ -2,7 +2,7 @@
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@1.6.9", "mcp"]
"args": ["-y", "gitnexus@1.6.10", "mcp"]
}
}
}

View file

@ -2,7 +2,7 @@
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@1.6.9", "mcp"]
"args": ["-y", "gitnexus@1.6.10", "mcp"]
}
}
}

View file

@ -2,7 +2,7 @@
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@1.6.9", "mcp"]
"args": ["-y", "gitnexus@1.6.10", "mcp"]
}
}
}

View file

@ -2,7 +2,7 @@
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@1.6.9", "mcp"]
"args": ["-y", "gitnexus@1.6.10", "mcp"]
}
}
}

View file

@ -2,7 +2,7 @@
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@1.6.9", "mcp"]
"args": ["-y", "gitnexus@1.6.10", "mcp"]
}
}
}

View file

@ -2,7 +2,7 @@
"mcpServers": {
"gitnexus": {
"command": "npx",
"args": ["-y", "gitnexus@1.6.9", "mcp"]
"args": ["-y", "gitnexus@1.6.10", "mcp"]
}
}
}

View file

@ -36,28 +36,28 @@
"pandemonium": "^2.4.0",
"react": "^19.2.5",
"react-dom": "^19.2.8",
"react-i18next": "^17.0.11",
"react-i18next": "^17.0.12",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.1",
"react-zoom-pan-pinch": "^4.0.3",
"remark-gfm": "^4.0.1",
"sigma": "^3.0.3",
"tailwindcss": "^4.3.3",
"uuid": "^14.0.1",
"uuid": "^14.0.2",
"zod": "^4.4.3"
},
"devDependencies": {
"@babel/types": "^8.0.4",
"@playwright/test": "^1.62.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@testing-library/user-event": "^14.6.6",
"@types/dompurify": "^3.2.0",
"@types/node": "^26.0.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.4",
"@types/react-syntax-highlighter": "^15.5.13",
"@vercel/node": "^5.9.9",
"@vercel/node": "^5.10.1",
"@vitejs/plugin-react": "^6.0.5",
"@vitest/coverage-v8": "^4.1.9",
"jsdom": "^29.1.1",
@ -246,9 +246,9 @@
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@ -2091,9 +2091,9 @@
}
},
"node_modules/@testing-library/jest-dom": {
"version": "6.9.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
"integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz",
"integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2105,9 +2105,12 @@
"redent": "^3.0.0"
},
"engines": {
"node": ">=14",
"node": ">=22",
"npm": ">=6",
"yarn": ">=1"
},
"peerDependencies": {
"@testing-library/dom": ">=10 <11"
}
},
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
@ -2146,9 +2149,9 @@
}
},
"node_modules/@testing-library/user-event": {
"version": "14.6.1",
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
"integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
"version": "14.6.6",
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.6.tgz",
"integrity": "sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw==",
"dev": true,
"license": "MIT",
"engines": {
@ -2638,9 +2641,9 @@
}
},
"node_modules/@vercel/build-utils": {
"version": "14.0.5",
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-14.0.5.tgz",
"integrity": "sha512-ChbTraIvChbcFXMwDPLE8MoWpNGSRhJ2cXsE0V3iJQIVYDRgjFoT6JzWfkuc7w/3ojLLr8eMoae7M1v6OXoC5Q==",
"version": "14.1.1",
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-14.1.1.tgz",
"integrity": "sha512-kW9CeW0aokEBvX1rSgNyOKg90VyIQOmT0wBl7KXneM3Qs1+x4Puakqp97BdIgttWEtmN96UvdhQVG2bCA5JsPA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@ -2691,9 +2694,9 @@
}
},
"node_modules/@vercel/node": {
"version": "5.9.9",
"resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.9.9.tgz",
"integrity": "sha512-jaMocJLa+rP3WpwYrbx2kUpHObjXK/JZOsbtmodDMAtfXbwl7niPNcEbdYYj/fBPSX8yRUXBF3tQsasocbjD5Q==",
"version": "5.10.1",
"resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.10.1.tgz",
"integrity": "sha512-muj+t8sZ2XHQDkWcHxkql2rbvr/HhZOqYdZBG7pw8F5RLasL3o0gjHLXJKwHAEHp2I3fd3AgbMud2oz+hzeV0g==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@ -2701,7 +2704,7 @@
"@edge-runtime/primitives": "4.1.0",
"@edge-runtime/vm": "3.2.0",
"@types/node": "20.11.0",
"@vercel/build-utils": "14.0.5",
"@vercel/build-utils": "14.1.1",
"@vercel/error-utils": "2.2.1",
"@vercel/nft": "1.10.0",
"@vercel/static-config": "3.4.1",
@ -7414,12 +7417,12 @@
}
},
"node_modules/react-i18next": {
"version": "17.0.11",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz",
"integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==",
"version": "17.0.12",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.12.tgz",
"integrity": "sha512-lFWPEGkxQ6RhusdUkysFBD58VHfSSzvHBzqMgN0SvfVpdQGfwtNkStTqdy08/sJd7s807qqutgx93fRpD0DJ3Q==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"@babel/runtime": "^7.29.7",
"html-parse-stringify": "^4.0.1",
"use-sync-external-store": "^1.6.0"
},
@ -8313,9 +8316,9 @@
}
},
"node_modules/uuid": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz",
"integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==",
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz",
"integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"

View file

@ -46,28 +46,28 @@
"pandemonium": "^2.4.0",
"react": "^19.2.5",
"react-dom": "^19.2.8",
"react-i18next": "^17.0.11",
"react-i18next": "^17.0.12",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.1",
"react-zoom-pan-pinch": "^4.0.3",
"remark-gfm": "^4.0.1",
"sigma": "^3.0.3",
"tailwindcss": "^4.3.3",
"uuid": "^14.0.1",
"uuid": "^14.0.2",
"zod": "^4.4.3"
},
"devDependencies": {
"@babel/types": "^8.0.4",
"@playwright/test": "^1.62.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@testing-library/user-event": "^14.6.6",
"@types/dompurify": "^3.2.0",
"@types/node": "^26.0.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.4",
"@types/react-syntax-highlighter": "^15.5.13",
"@vercel/node": "^5.9.9",
"@vercel/node": "^5.10.1",
"@vitejs/plugin-react": "^6.0.5",
"@vitest/coverage-v8": "^4.1.9",
"jsdom": "^29.1.1",

View file

@ -31,6 +31,22 @@ describe('filterRepoFiles', () => {
expect(r.droppedCount).toBe(4);
});
it('excludes emitted _next output, including the Capacitor/Cordova copy', () => {
// `.next` was listed but `_next` was not, so a mobile-wrapped Next.js app
// uploaded its whole minified bundle against the server's caps for files
// the analyzer then discards anyway (#3007).
const input = [
f('repo/android/app/src/main/assets/public/_next/static/chunks/main.js'),
f('repo/ios/App/App/public/_next/static/chunks/framework.js'),
f('repo/_next/static/chunks/x.js'),
f('repo/src/index.ts'),
f('repo/src/_nextgen/index.ts'),
];
const r = filterRepoFiles(input);
expect(r.manifest).toEqual(['repo/src/index.ts', 'repo/src/_nextgen/index.ts']);
expect(r.droppedCount).toBe(3);
});
it('drops files over the per-file size cap', () => {
const input = [f('repo/big.bin', MAX_FILE_BYTES + 1), f('repo/small.ts', 10)];
const r = filterRepoFiles(input);

View file

@ -22,6 +22,17 @@ export const EXCLUDED_DIRS = new Set([
'build',
'out',
'.next',
// `.next` is the build CACHE, `_next` the EMITTED output — different
// directories. A Capacitor/Cordova shell leaves the emitted bundle at
// `<platform>/app/src/main/assets/public/_next/`, so without this the whole
// minified tree is uploaded against the server's file/byte caps only to be
// discarded by the analyzer's own ignore list (#3007).
//
// This pre-filter reads no repository ignore rules, so unlike the CLI walker
// a `.gitnexusignore` negation cannot recover anything dropped here. Names
// added below must therefore stay a subset of the analyzer's own list; see
// `gitnexus/test/unit/upload-filter-ignore-drift.test.ts`.
'_next',
'.nuxt',
'.cache',
'coverage',

View file

@ -4,6 +4,97 @@ All notable changes to GitNexus will be documented in this file.
## [Unreleased]
## [1.6.10] - 2026-08-27
### Added
- **Spring framework modeling expanded end to end** — AOP transactions, caching and security (#2783), `@Bean` factories and `@Resource` injection (#2740), profiles/conditions/auto-configuration (#2678), constructor and standard injection (#2632), bean candidate inventory (#2494), configuration-property consumers, and non-HTTP handler entry points (#2891)
- **Receiver chains typed from AST structure across all 14 languages**, with an explicit epistemic lower bound on what the graph can claim (#2708, #2744, #2747)
- **Java enum constant bodies modeled as first-class instances**, with JLS 13.1 anonymous-class naming (#2558)
- **More route surfaces indexed** — Java constant-based route paths such as `@PostMapping(ApiPathConstants.X)` (#2980) and JavaScript data route tables (#2972)
- **MCP server hardening** — repository allowlist, fail-closed read-only mode, deterministic output budgets, and normalized `impact`/`context` aliases
- **`bunx` lane so bun-only machines can run GitNexus** (#2765)
- **Codex support** — hooks, plugin marketplace and setup (#2328, #2369) — plus CodeBuddy and Qoder coding-agent integrations (#2368)
- **Skills mirrored to `.agents/skills/`** when an `.agents/` directory exists
- **One-click Render deploy** (#2804)
- **`serve` origin/proxy configuration is validated and port-scoped** (#2820)
- **Expanded TypeScript/JavaScript taint sink model** (#2490)
- **Wiki generation accepts explicit HTTP LLM hosts** (#2491)
- **Embedding request-body dimensions configurable** via `GITNEXUS_EMBEDDING_REQUEST_DIMS` (#2574)
- **Refreshed MiniMax model and endpoint configuration** (#2780)
- **`MAX_CALLABLE_VALUE_TARGETS` and `MAX_PROPERTY_DISPATCH_FANOUT` configurable via env** (#2725, #2726)
- **Opt-in `analyze --self-commit`** for AGENTS.md/CLAUDE.md churn (#2640)
- **Buffer pool sized to the graph before the database opens**, with an adaptive size hint
- **CI review agent runs as a coordinated reviewer swarm** on Sonnet 5 with structured, linked reviews (#2570, #2572), alongside the GitNexus Engineering Tool Kit skills (#2566) and an online skill-evolution loop (#2571)
- **Icebug community-engine prototype behind a gate** (#2376)
### Fixed
- **`group sync` stops claiming matching it never did** — the advertised BM25/embedding cascade was config, help text and MCP schema with no matcher behind it; the unread `matching.bm25_threshold`, `matching.embedding_threshold`, `detect.embedding_fallback` and `--skip-embeddings` surfaces are removed (#3020)
- **Emitted Next.js build output is ignored during ingestion**, and the inert `public/build` entry is deleted (#3018)
- **NestJS decorator routes are indexed** so `api_impact` and `route_map` stop reporting live endpoints as non-existent (#3017)
- **Import resolution gated by real module configuration** instead of path-suffix guessing — TypeScript config (#2953, #2956), Java and Kotlin declared packages (#2955, #2990), Go module paths (#2984), PHP Composer autoload maps (#2987), Python `__init__.py` re-exports (#2864) and unaliased dotted namespace imports (#2826, #2828), and JavaScript module extensions (#3034)
- **Interface dispatch is generic-instantiation aware** (#2912, #2939), fans out from Case 3b receivers (#2832, #2842) and from C# record interface calls (#2904), and resolves through generic-typed field receivers in every language (#2833, #2855)
- **Go method sets modeled exactly** so interface satisfaction is decidable (#2813, #2829), out-of-repo package qualifiers resolve, and an undecided interface check is no longer reported as a decided negative (#2873, #2921)
- **Go pointer-receiver calls resolve**, reporting the program boundary instead of hedging (#2766, #2782)
- **Java record support** — graph nodes for `record_declaration`, component accessors, enum and record interface heritage (#2564, #2916, #2935, #2936), plus `E.CONST.method()` enum-constant receiver dispatch (#2561) and JLS binary-name identities for local classes, enums, records and interfaces (#2562, #2653)
- **Rust module-qualified calls resolve against the module tree** (#2730, #2741), items are qualified by their enclosing `mod` chain (#2742, #2745), duplicate type names stay ambiguous in range binding (#2514, #2652), and `Box<dyn Trait>` names normalize
- **Closure bindings are call sources in every language**, and function-local values carry their own identity (#2693, #2695, #2699, #2718)
- **A named receiver's member never resolves lexically** (#2714), platform builtins stop resolving to unrelated same-file symbols (#2549), and inline constructor receivers are typed in every spelling (#2708, #2737)
- **Python calls resolve through constructor-injected fields** (#2628) and module-imported classes (#2770)
- **Package directories that repeat higher in the path resolve correctly** (#2881, #2929)
- **`check` stops reporting erased and deferred imports as initialization cycles** (#2934)
- **`detect_changes` no longer scales its query with the diff's hunk count** (#2915, #2930), and CR-only line-ending diffs are ignored (#2839)
- **`group` stops reporting what could not be measured as a measurement of zero** (#3012), resolves HTTP consumers through configured clients and constant route tables (#3008), and preserves manifest-only impact crossings (#2784)
- **`impact` and `context` are reproducible** — deterministic ordering on every capped query (#2787, #2796) — and Convex caller results are marked incomplete rather than empty (#3044)
- **Object handler identity is preserved** during ingestion (#3046), nested source directories are discovered (#3043), and parse-node insertion is canonicalized
- **Large-repo analyze OOM and the false worker-timeout cascade are fixed** (#2649, #2679)
- **Single-writer lock on the index write path** (#2658, #2677), atomic index swap with read-pool staleness invalidation (#2614), and reliable large incremental writeback commits (#2409, #2425)
- **Remote URLs are stripped of credentials before they are persisted** (#2914, #2928), and every registry write gets its own tmp path (#2888, #2920)
- **Schema version derived from a DDL fingerprint** instead of a hand-incremented constant (#2798, #2808), and the scope-resolution relation cross product is fully declared (#2792, #2793)
- **FTS reliability** — binary payloads stay out of the description column and an unbuildable index is confined to its own table (#2919), FTS-indexed DML is gated before the incremental writeback (#2841, #2854), analyze degrades instead of aborting on index-build failure (#2548), real LOAD errors surface and broken extension files self-heal (#2374, #2375), and Windows missing-dependency load failures are diagnosed (#2383)
- **`VECTOR` is loaded only when needed** (#3045) and before the incremental writeback touches embedding rows (#2623, #2624)
- **Buffer pool bounded instead of taking the native 80%-of-RAM default** (#2560), scaled by the OS page-size granule ratio (#2631, #2636), with a COPY-safe floor and actionable diagnostics for non-4K page sizes (#2424)
- **`Napi::Error` SIGABRT on analyze eliminated** — C++ type lookups are indexed and workers terminate only at JS-safe points (#2432, #2436)
- **Native-load failures fail closed**, including truncated-binary SIGBUS (#2441, #2651), and glibc-too-old loads are no longer misdiagnosed (#2672, #2689)
- **Index staleness reporting fixed** — no false-stale status after analyze, with inline staleness in `query`/`context`/`impact`/`cypher` (#2655, #2668, #2683)
- **Windows path handling** — the `\\?\` long-path prefix no longer breaks repo path matching (#2667, #2700), `parts` negation is honored (#2720), and missing-shadow errors let `serve` repo-switch recover (#2382, #2387)
- **Embeddings survive partial failures** — unparseable 200 responses are retried (#2790, #2795), batch inserts are retry-safe (#2453), HTTP generation is resumable, resume checkpoints bind to their provider, and proxy-blocked installs self-heal (#2370, #2372)
- **Custom HTTP embedding endpoint failures are reported as themselves**, not as Hugging Face download errors (#2385, #2386)
- **Exact symbol content with 0-based line storage and 1-based MCP display** (#2377, #2379, #2380)
- **`rename` reports every edit that apply writes** and reconciles its report on partial failure (#2605)
- **Global registry transactions serialized across processes** (#2716)
- **Swift indented conditional directives are preprocessed** so class bodies survive parsing (#2771), and Swift member-containment pairs are declared in the `CONTAINS` DDL (#2769)
- **JavaScript `exports.foo = function () {}` CommonJS exports are indexed** (#2723, #2729), and `const X = () => {}` is no longer double-indexed as a Function plus an edgeless Const twin (#2687, #2691)
- **JVM sibling injection is proximity-bounded** (#2732), and C#/Kotlin free calls are gated by instance ownership (#2563, #2654)
- **Dart extension type symbols are extracted** (#2539), and declarations recover after embedded NUL bytes (#2430)
- **CLI and hooks fail loudly on backend error payloads**, with an MCP query hint when the server owns the DB lock (#2396, #2397)
- **Committed agent guides stop churning**, with an `--index-only` nudge (#2907, #2927), and `gitnexus-plan` artifacts publish on macOS without an interpreter (#2905, #2922)
- **The 300-flows cap is removed for large repositories** (#2198)
### Changed
- **BREAKING: Node `^22.18.0 || >=24.11.0` is now the supported floor**; the `@types/uuid` stub is dropped
- **BREAKING: the non-functional `group` matching knobs are gone** — `matching.bm25_threshold`, `matching.embedding_threshold`, `detect.embedding_fallback` in `group.yaml`, the `gitnexus group sync --skip-embeddings` flag, and the MCP `group_sync` `skipEmbeddings` argument (#3020)
- **Structural relationships are held out of the JS heap by default** during analyze (#2680, #2685)
- **Global ignore support** — `core.excludesFile`, `.git/info/exclude`, and a user-level global ignore file are honored (#2606)
- **Plugin manifests sync on every version bump** (#2445), and planning output under `docs/plans` is no longer tracked
### Performance
- **Import resolution indexed instead of scanned** — every scanning resolver with a consolidated memo (#2911), a per-run workspace index for Go/C#/Dart/Ruby (#2898), and Kotlin import resolution (#2872)
- **MCP server startup drops the analyze-only language-provider closure** (#2802, #2806)
- **C++ qualified namespace members indexed once per pipeline run** (#2788, #2794)
- **Vendored Leiden O(communities × N) copy removed**, with Icebug wired to its real API (#2337, #2692)
- **`core.excludesFile` / `info/exclude` resolution memoized** (#2606)
### Chore / Dependencies
- **`@ladybugdb/core` bumped to ^0.18.3** for the rel-property IN-predicate fix (#2508, #2634)
- **Security overrides** — `sharp` >=0.35.0 for libvips vulnerabilities (#2993) and `adm-zip` >=0.6.0 for a memory-allocation vulnerability (#2992)
- **~130 dependency bumps** across the CLI, web app and GitHub Actions, including `@modelcontextprotocol/sdk`, LangChain, Vite, Vitest, TypeScript, React and the Docker/CodeQL action suite
- **CI hardening** — Windows shard watchdog widened with exit diagnostics (#2449), platform-sensitive matrix sharded to fix the Windows cross-platform timeout (#2394), and CI Report no longer dies silently when the tests job fails (#2728)
## [1.6.9] - 2026-07-04
### Added

View file

@ -58,9 +58,6 @@ packages: {}
detect:
http: true
matching:
bm25_threshold: 0.7
embedding_threshold: 0.65
max_candidates_per_step: 3
`;
}

View file

@ -1,7 +1,8 @@
{
"fingerprint": "4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5",
"fingerprint": "c4d799c5336d616955b3530ba051b7dca300d1a0e412a66741cf2f27e04c533e",
"scaling_budget": 1.8,
"max_ms_large": 1000,
"_rebaselined_2856_property_is_detail": "Third and last of the bench guards this branch left red. The Property node table gained an `isDetail` BOOLEAN column (see PROPERTY_SCHEMA in src/core/lbug/schema.ts), so `streamAllCSVsToDisk` writes one more header field and one more cell per Property row — csv-generator.ts `propertyHeader` and the `node.label === 'Property'` tail. Verified to be header-only drift rather than a change in what is emitted: dumping every CSV this bench produces on `origin/main` and on this branch and diffing per-file (filename, byte length, sha256) shows the file SET is identical at 35 CSVs on both sides, 34 of the 35 are byte-identical, and the sole difference is `property.csv` growing 68 -> 77 bytes, `id,name,filePath,startLine,endLine,content,description,declaredType` -> `...,declaredType,isDetail`. The synthetic graph has no Property nodes, so no ROW moved at all. That is the check that matters here: a row routed to the wrong pair file, or a within-file reordering, is what this fingerprint exists to catch, and neither happened. Prior 69e9182ae205183ade24c3d8ad5d7292aea677144b1cbe443dd631bc25b0cafe -> 4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5. Both timing gates passed unchanged while this was red (scaling_ratio 0.783 vs budget 1.8, elapsed_ms_large 229ms vs the 1000ms backstop), so no throughput claim is being rebaselined away.",
"_rebaselined_3040_convex_endpoint_factory": "Const and Function gained a trailing convexEndpointFactory column. A deterministic 2,400-entity emit produced the same 35 CSV files and fingerprint c4d799c5336d616955b3530ba051b7dca300d1a0e412a66741cf2f27e04c533e. Removing the new Const and Function header fields plus the new trailing empty Function cell from each of 4,800 Function rows restored the exact prior fingerprint 4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5. No file or row moved or reordered. The measured scaling ratio remained 0.826 against the 1.8 budget and elapsed_ms_large was 307.75ms against the 1000ms backstop.",
"_note": "fingerprint = sha256 over per-file digests (filename + sha256(file bytes)), entry list sorted — binds each emitted line to its file so a row routed to the WRONG pair file changes the hash, AND catches within-file row reordering (file bytes hashed as-written). Byte-identity gate for #2203 U2/U3. NOTE: a future change that legitimately reorders emit (without changing the node/edge SET) will trip --check; regenerate then, and record WHY in a `_rebaselined_<reason>` key alongside — bench/scope-capture/baselines.json sets that convention and it is what makes a regenerated hash reviewable. scaling_budget bounds (t_large/t_small)/(LARGE/SMALL): observed ~0.95-1.05 (linear); 1.8 tolerates disk-I/O timing noise on CI while still catching an O(n^2) re-regression (~4x). max_ms_large=1000ms is a coarse absolute backstop (observed ~200ms) that catches a gross uniform slowdown the ratio gate misses; generous so CI host noise won't flake it. Regenerate via `node --import tsx bench/emit-persistence/measure.mjs`."
}

View file

@ -1,12 +1,12 @@
{
"name": "gitnexus",
"version": "1.6.9",
"version": "1.6.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitnexus",
"version": "1.6.9",
"version": "1.6.10",
"hasInstallScript": true,
"license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {

View file

@ -1,6 +1,6 @@
{
"name": "gitnexus",
"version": "1.6.9",
"version": "1.6.10",
"description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
"author": "Abhigyan Patwari",
"license": "PolyForm-Noncommercial-1.0.0",

View file

@ -65,6 +65,16 @@ export const WINDOWS_WEIGHTS_SEC: Readonly<Record<string, number>> = {
'test/integration/antigravity-hook-e2e.test.ts': 7,
'test/unit/index-lock.test.ts': 5,
'test/unit/setup.test.ts': 5,
// ESTIMATE, not a measurement. This file asserts almost nothing; it READS —
// one 4893-file pass over every tracked text file, plus an 830-file pass over
// `src/`. Measured at 2.3 s and 0.3 s per pass on a virtualised and a local
// Linux filesystem respectively, so the cost is entirely per-file open
// latency, which is the term Windows inflates most (NTFS plus Defender on
// every read). Scaled from the slower Linux figure to keep the split
// conservative rather than let the 8 s PER_FILE_OVERHEAD floor under-charge
// a file that touches more paths than anything else here. Replace with a real
// figure after the first green Windows matrix run.
'test/unit/source-control-bytes.test.ts': 15,
};
/**

View file

@ -208,6 +208,18 @@ const SPAWN_CLI = [
// exposed a file-backend double-admit race here (#2658 review); the reclaim is
// now judgment-verified so a live holder is never displaced.
'test/integration/analyze-index-lock-concurrency.test.ts',
// The per-group sync lock (R9), same class of guarantee one level up: real
// child processes contend for one group's lock while this process runs a real
// `syncGroup`, and the CLI case spawns the real command. Everything that
// varies here is platform-owned — which backend `selectBackend()` picks
// (Windows named pipe / Linux abstract socket / macOS file lock), kernel
// auto-release on SIGKILL vs. the file backend's pid-liveness reclaim, and
// `mkdir` over an occupied path. The fail-closed cases pin
// GITNEXUS_INDEX_LOCK_BACKEND=file so the filesystem branch is exercised on
// every OS rather than only where it is the default; no case is skipped on
// any platform, because a skipped case turns "a sync that cannot be protected
// does not run" into a claim that holds on Ubuntu only.
'test/integration/group/group-sync-lock-concurrency.test.ts',
// The three `dist/` module-load closure guards, all built on the shared
// child-process probe in `test/helpers/module-load-probe.ts`. That probe IS
// the platform-varying part: it spawns `process.execPath` in array form,
@ -261,6 +273,28 @@ const FILESYSTEM = [
'test/integration/filesystem-walker.test.ts',
'test/integration/markdown-processor-crlf.test.ts',
'test/integration/ignore-and-skip-e2e.test.ts',
// Pins that the bridge pairing verdict is measured before the database is
// opened. The property it protects is about mtime behavior across OS and
// filesystem, and the alternative — really opening the bridge — cannot run on
// Windows at all (in-process write→read reopen of the same bridge.lbug is a
// documented limitation). Running it on every platform is the whole point:
// Windows is where an unverified assumption about mtime would hurt most.
'test/unit/group/bridge-pairing-precedes-open.test.ts',
// The raw-control-byte guard reads every tracked text file `git ls-files`
// reports — 4893 of them — and decides membership from the git path, which is
// always `/`-separated no matter what the host separator is. Both halves of
// that are platform-varying: the collector basename-matches with
// `path.posix.basename` against `git ls-files -z` output while the reads go
// through `path.join`, so on Windows the same string is consumed under two
// separator conventions in one pass, and only a real windows-latest run
// proves they agree. It is also the file-count-heaviest read loop in the
// suite, so it is where a per-file filesystem cost (NTFS + Defender, or
// macOS's slower stat path) would show up first. No case is skipped on any
// platform: a guard that only holds on Ubuntu is not a guard on the file
// whose NUL it exists to catch. Budget: the heaviest single case is one
// 4893-file pass — 2.3 s on a slow virtualised filesystem, 0.34 s on a local
// disk — against a 30 s testTimeout.
'test/unit/source-control-bytes.test.ts',
];
const ALL_CROSS_PLATFORM = [

View file

@ -1,6 +1,8 @@
// gitnexus/src/cli/group.ts
import { createRequire } from 'node:module';
import type { Command } from 'commander';
import type { RegistryWriteOutcome } from '../core/group/sync.js';
import type { MatchType } from '../core/group/types.js';
import { logger } from '../core/logger.js';
const _require = createRequire(import.meta.url);
@ -120,16 +122,43 @@ export function registerGroupCommands(program: Command): void {
indexStale: boolean;
contractsStale: boolean;
missing: boolean;
/**
* Optional here on purpose: a payload produced before the split
* carries no such key, and an absent one must degrade to the
* label this command has always printed rather than to the new
* one — an unrecorded cause is not evidence of a cause.
*/
unresolvable?: boolean;
unresolvableReason?: string;
commitsBehind?: number;
}
>;
missingRepos?: string[];
unreadableRepos?: string[];
suppressedMatchStages?: string[];
};
console.log(' Repo index / contracts staleness:');
for (const [repoPath, row] of Object.entries(st.repos || {})) {
if (row.missing) {
console.log(` ${repoPath.padEnd(25)} MISSING (not in registry or unreadable)`);
// Two different facts with two different remedies: a repo the
// registry never heard of is fixed by indexing it, while an entry
// the resolver choked on is fixed by repairing the registry.
// Printing "no entry in the registry" for the second one states a
// cause that was never measured, and points at the wrong repair.
if (row.unresolvable) {
// The reason can be multi-line — an ambiguous registry names
// every colliding clone. Fold it onto this row's line rather
// than truncating it: those paths are what the operator acts on,
// and a table row that swallows half its own explanation is the
// failure this label exists to stop.
const why = (row.unresolvableReason ?? 'the registry entry could not be resolved')
.replace(/\s+/g, ' ')
.trim();
console.log(` ${repoPath.padEnd(25)} UNRESOLVABLE (${why})`);
continue;
}
console.log(` ${repoPath.padEnd(25)} MISSING (no entry in the registry)`);
continue;
}
const idx = row.indexStale
@ -138,9 +167,41 @@ export function registerGroupCommands(program: Command): void {
const ctr = row.contractsStale ? ' CONTRACTS_STALE' : '';
console.log(` ${repoPath.padEnd(25)} ${idx}${ctr}`);
}
// `undefined` and `[]` are different answers here: a registry written
// before this was tracked has no opinion, while an empty array is a
// measurement. Printing nothing for both would let an unmeasured sync
// read as evidence that every index opened cleanly.
//
// `undefined` covers two ways of not knowing — the field is absent, or
// it held something that was not a list of repo paths and `getStatus`
// declined to guess. Naming only the first would make a corrupt
// registry read as a merely old one, which is the same shape of wrong
// answer this command exists to stop giving.
const unreadable = st.unreadableRepos;
if (unreadable === undefined) {
console.log(
`\n Last sync unreadable repos: not recorded` +
`\n (the registry predates this field, or its value could not be read)` +
`\n Re-run \`gitnexus group sync\` to record it.`,
);
} else if (unreadable.length > 0) {
console.log(`\n Last sync unreadable repos: ${unreadable.join(', ')}`);
}
if ((st.missingRepos || []).length > 0) {
console.log(`\n Last sync missing repos: ${st.missingRepos!.join(', ')}`);
}
// Only the populated case prints. Absent means a registry that predates
// the field, and empty is the ordinary clean sync — neither is worth a
// line, whereas a narrowed registry changes how every later answer
// should be read.
const skippedStages = st.suppressedMatchStages ?? [];
if (skippedStages.length > 0) {
console.log(
`\n Last sync skipped matching stages: ${skippedStages.join(', ')}` +
`\n Cross-links those stages would have found are absent by request.` +
`\n Re-run \`gitnexus group sync\` without --exact-only for the complete set.`,
);
}
} finally {
await backend.dispose().catch(() => {});
}
@ -149,39 +210,131 @@ export function registerGroupCommands(program: Command): void {
group
.command('sync <name>')
.description('Sync Contract Registry — extract contracts and build cross-links')
.option('--skip-embeddings', 'Exact + BM25 only (no embedding fallback)')
.option('--exact-only', 'Exact match only')
.option('--allow-stale', 'Skip stale index warnings')
.option('--verbose', 'Show each cross-link detail')
.option(
'--exact-only',
'Skip wildcard service matching; cross-link on exact contract-id match only (manifest links still apply)',
)
.option('--verbose', 'Show additional sync diagnostics')
.option('--json', 'JSON output')
.action(async (name: string, opts: Record<string, boolean | undefined>) => {
const { getGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js');
const { loadGroupConfig } = await import('../core/group/config-parser.js');
const { syncGroup } = await import('../core/group/sync.js');
const { GroupSyncLockError } = await import('../core/group/group-lock.js');
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
const config = await loadGroupConfig(groupDir);
console.log(`Syncing group "${name}" (${Object.keys(config.repos).length} repos)...\n`);
const result = await syncGroup(config, {
groupDir,
allowStale: Boolean(opts.allowStale),
verbose: Boolean(opts.verbose),
skipEmbeddings: Boolean(opts.skipEmbeddings),
exactOnly: Boolean(opts.exactOnly),
});
let result: Awaited<ReturnType<typeof syncGroup>>;
try {
result = await syncGroup(config, {
groupDir,
verbose: Boolean(opts.verbose),
exactOnly: Boolean(opts.exactOnly),
});
} catch (err) {
// A sync that could not take the group's lock did NOT run and wrote
// nothing (R9 fails closed). That is an operator-actionable outcome, not
// a crash, so report it as a failed command rather than letting it
// surface as an unhandled rejection with a stack trace — commander's
// async actions have no error handler, so an uncaught throw here would
// print exactly that.
if (!(err instanceof GroupSyncLockError)) throw err;
logger.error(`⚠️ Did not sync group "${name}": ${err.message}`);
process.exitCode = 1;
return;
}
if (opts.json) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(`\nMatching cascade:`);
const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact');
console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`);
console.log(` unmatched: ${result.unmatched.length} contracts`);
console.log(
`\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`,
);
// Repos we could not read are the most likely explanation for a small
// or empty contract count, so they are reported before the counts —
// otherwise a run that read nothing looks exactly like a clean run.
if (result.unreadableRepos.length > 0) {
// No "re-run with GITNEXUS_LOG_LEVEL=warn" hint: the default level is
// `info`, and pino emits `warn` (40) at `info` (30), so the reason was
// already printed by this same run — raising the level to `warn` would
// only suppress the surrounding `info` output.
console.log(
`\n ⚠️ Could not extract contracts from: ${result.unreadableRepos.join(', ')}` +
`\n None of their contracts are included in this sync (the warning above says why),` +
`\n or check \`gitnexus doctor\` in the affected repo.`,
);
}
if (result.missingRepos.length > 0) {
console.log(
`\n ⚠️ Not found in the registry: ${result.missingRepos.join(', ')}` +
`\n Index them with \`gitnexus analyze\`, or remove them from group.yaml.`,
);
}
// Every stage that produced a link, not just `exact`. This used to print
// `Matching cascade:` and then count `exact` alone, while the `Wrote
// contracts.json (…)` line below reports `result.crossLinks.length` —
// which also includes `manifest` and `wildcard` links. For any group with
// those, the two numbers disagreed with nothing on screen explaining why.
// Summing the stages here makes them reconcile by construction.
console.log(`\nMatching:`);
// Exhaustive by construction, same idiom as OUTCOME_LINE below: adding a
// MatchType fails the build here instead of silently going uncounted and
// reopening the very mismatch this replaced. Every stage prints even at
// zero — a stage that is absent reads as "did not apply", not "found none".
const STAGE_COUNTS: Record<MatchType, number> = {
exact: 0,
manifest: 0,
wildcard: 0,
};
for (const link of result.crossLinks) STAGE_COUNTS[link.matchType] += 1;
// A stage the sync was told to skip is reported as skipped, not as a
// zero count. The two are different facts — "ran, matched nothing" and
// "never ran" — and printing both as `0` is the same conflation this
// block replaced. Driven by what the sync did (`suppressedMatchStages`)
// rather than by what the caller asked for, so it stays correct on the
// outcomes where the run ended without writing a registry.
for (const stage of Object.keys(STAGE_COUNTS) as MatchType[]) {
const count = STAGE_COUNTS[stage];
const label = `${stage}:`.padEnd(10);
if (result.suppressedMatchStages.includes(stage)) {
console.log(` ${label} skipped (--exact-only)`);
continue;
}
const confidence = stage === 'exact' ? ' (confidence 1.0)' : '';
console.log(` ${label} ${count} cross-links${confidence}`);
}
console.log(` ${'unmatched:'.padEnd(10)} ${result.unmatched.length} contracts`);
// Driven by what actually happened to the file. This line used to be
// unconditional, so a run that deliberately preserved the previous
// registry still announced `Wrote contracts.json (0 contracts, 0
// cross-links)` — a confident false statement about persisted state, on
// the exact path this command exists to make legible.
// Exhaustive by construction: a `Record` keyed on the union means a
// new outcome fails the build here instead of printing nothing, which
// is what previously pushed a distinct state into `preserved` and made
// this summary false on one of the two branches it then covered.
const OUTCOME_LINE: Record<RegistryWriteOutcome, string | null> = {
written:
`\nWrote contracts.json (${result.contracts.length} contracts, ` +
`${result.crossLinks.length} cross-links)`,
preserved:
`\nKept the previous contracts.json — no repo in this group could be read.` +
`\n Its contracts and cross-links are unchanged; only the unreadable/missing` +
`\n repo lists were refreshed to describe THIS run. Fix the repos above and re-run.`,
superseded:
`\nDid NOT touch contracts.json — no repo in this group could be read, and another` +
`\n sync replaced the file while this one waited for the group lock. That sync's` +
`\n result stands and this run's repo lists were NOT recorded: they describe a` +
`\n group state older than what is on disk. Fix the repos above and re-run.`,
'no-prior-registry':
`\nDid NOT write contracts.json — no repo in this group could be read,` +
`\n and there is no previous contracts.json to fall back on. Fix the repos` +
`\n above and re-run.`,
// Nothing to say: the caller asked for no write.
'not-attempted': null,
};
const line = OUTCOME_LINE[result.registryOutcome];
if (line) console.log(line);
}
});
@ -281,11 +434,28 @@ export function registerGroupCommands(program: Command): void {
// repos — reporting it as crossings understates a fan-out cap the
// same way #2787's totals did.
const dropped = (raw as { truncatedRepos?: string[] })?.truncatedRepos ?? [];
console.log(
dropped.length > 0
? ` risk is a LOWER BOUND — fan-out stopped early; crossings to ${dropped.length} repo(s) not traversed: ${dropped.join(', ')}`
: ' risk is a LOWER BOUND — the local impact walk did not complete (every bridge crossing was traversed)',
);
const reason = (raw as { truncationReason?: string })?.truncationReason;
// Keyed on the REASON, not on which incidental fact happens to be
// non-empty. `truncatedRepos` is populated for a structural gap too
// — the bridge's incomplete repos are unioned into it even when ZERO
// crossings were attempted — so branching on its length first
// reported "fan-out stopped early" for a run where nothing stopped
// early, and omitted the only remedy that works. Same false-cause
// shape the contract listing was just re-gated for, one command over.
const floorReason = (): string => {
if (reason === 'suppressed-stage') {
return 'the last sync skipped a matching stage (--exact-only); re-run `gitnexus group sync` without it for the complete graph';
}
if (reason === 'incomplete-sync') {
return dropped.length > 0
? `the last sync could not account for ${dropped.join(', ')}; their contracts are absent from every query against this bridge — re-run \`gitnexus group sync\``
: 'the last sync could not say which repos it read — re-run `gitnexus group sync`';
}
return dropped.length > 0
? `fan-out stopped early; crossings to ${dropped.length} repo(s) not traversed: ${dropped.join(', ')}`
: 'the local impact walk did not complete (every bridge crossing was traversed)';
};
console.log(` risk is a LOWER BOUND — ${floorReason()}`);
}
}
} finally {
@ -370,7 +540,15 @@ export function registerGroupCommands(program: Command): void {
return;
}
const { contracts, crossLinks } = raw as {
const {
contracts,
crossLinks,
truncated,
unreadableRepos,
missingRepos,
suppressedMatchStages,
truncationReason,
} = raw as {
contracts: Array<{
role: string;
contractId: string;
@ -384,10 +562,21 @@ export function registerGroupCommands(program: Command): void {
confidence: number;
contractId: string;
}>;
truncated?: boolean;
suppressedMatchStages?: string[];
truncationReason?: string;
unreadableRepos?: string[];
missingRepos?: string[];
};
if (opts.json) {
console.log(JSON.stringify({ contracts, crossLinks }, null, 2));
// The whole payload, not a re-serialized subset. Destructuring the two
// fields this command happens to print and rebuilding an object from
// them dropped everything else the service returned — which is how the
// completeness fields were invisible here while the MCP tool carried
// them. Printing `raw` means a field added to the service reaches
// `--json` without a matching edit in this file.
console.log(JSON.stringify(raw, null, 2));
} else {
console.log(`Contracts (${contracts.length}):`);
for (const c of contracts) {
@ -399,6 +588,39 @@ export function registerGroupCommands(program: Command): void {
` ${l.from.repo} -> ${l.to.repo} [${l.matchType}, conf=${l.confidence}] ${l.contractId}`,
);
}
// Separate from `truncated` below, and deliberately so: that one means
// the sync could not read something and the remedy is to fix the repo.
// This one means the sync was ASKED to skip a stage, and the remedy is
// to re-run without the flag. A listing narrowed on purpose is still
// narrowed, and without this the human view showed nothing at all.
if (suppressedMatchStages && suppressedMatchStages.length > 0) {
console.log(
`\n⚠️ This listing is a lower bound: the last sync skipped ${suppressedMatchStages.join(', ')} matching` +
`\n (--exact-only), so cross-links that stage would have found are absent.` +
`\n Re-run \`gitnexus group sync\` without --exact-only for the complete set.`,
);
}
// Gated on the REASON, not just the flag. A suppressed stage sets
// `truncated` with both repo lists empty, which sent this block down
// its else-branch and printed "the last sync did not record which
// repos it could read" — a false statement, with the wrong remedy,
// about a sync that recorded them fine. The suppressed-stage warning
// above already said the true thing. When a repo gap co-occurs the
// reason is 'incomplete-sync' (the repo side takes precedence in
// `crossRepoCompleteness`), so this block still runs for it.
if (truncated && truncationReason !== 'suppressed-stage') {
// Counts above are a floor, not a census. Name the repos when the
// registry recorded them, and say so plainly when it did not — a
// listing that cannot say what it is missing is still incomplete.
const absent = [...(unreadableRepos ?? []), ...(missingRepos ?? [])];
console.log(
absent.length > 0
? `\n⚠️ This listing is incomplete: the last sync could not account for ${absent.join(', ')}.` +
`\n Contracts from those repos are absent, so the counts above are a lower bound.`
: `\n⚠️ This listing is incomplete: the last sync did not record which repos it could` +
`\n read, so the counts above are a lower bound. Re-run group sync.`,
);
}
}
} finally {
await backend.dispose().catch(() => {});

View file

@ -155,9 +155,7 @@ const OPTION_DESCRIPTION_KEYS = {
'embeddings install|--cuda': 'help.option.embeddings.install.cuda',
'embeddings install|--force': 'help.option.embeddings.install.force',
'group create|--force': 'help.option.group.create.force',
'group sync|--skip-embeddings': 'help.option.group.sync.skipEmbeddings',
'group sync|--exact-only': 'help.option.group.sync.exactOnly',
'group sync|--allow-stale': 'help.option.group.sync.allowStale',
'group sync|--verbose': 'help.option.group.sync.verbose',
'group sync|--json': 'help.option.json',
'group impact|--target <symbol>': 'help.option.group.impact.target',

View file

@ -293,10 +293,9 @@ export const en = {
'help.option.embeddings.install.force':
'Install into the runtime prefix even when the stack already resolves',
'help.option.group.create.force': 'Overwrite existing group',
'help.option.group.sync.skipEmbeddings': 'Exact + BM25 only (no embedding fallback)',
'help.option.group.sync.exactOnly': 'Exact match only',
'help.option.group.sync.allowStale': 'Skip stale index warnings',
'help.option.group.sync.verbose': 'Show each cross-link detail',
'help.option.group.sync.exactOnly':
'Skip wildcard service matching; cross-link on exact contract-id match only (manifest links still apply)',
'help.option.group.sync.verbose': 'Show additional sync diagnostics',
'help.option.status.json': 'Emit machine-readable index and analyzer provenance',
'help.option.json': 'JSON output',
'help.option.group.impact.target': 'Symbol or file name to analyze',

View file

@ -273,10 +273,9 @@ export const zhCN = {
'同时下载 CUDA GPU 二进制文件(运行 onnxruntime-node 的 NuGet postinstall;代理后请设置 GLOBAL_AGENT_HTTPS_PROXY)',
'help.option.embeddings.install.force': '即使嵌入组件已可解析,也强制安装到运行时目录',
'help.option.group.create.force': '覆盖现有仓库组',
'help.option.group.sync.skipEmbeddings': '仅使用 exact + BM25(不使用嵌入回退)',
'help.option.group.sync.exactOnly': '仅精确匹配',
'help.option.group.sync.allowStale': '跳过过期索引警告',
'help.option.group.sync.verbose': '显示每条跨仓库链接详情',
'help.option.group.sync.exactOnly':
'跳过通配符服务匹配,仅按契约 ID 精确匹配建立跨仓链接(清单声明的链接仍然生效)',
'help.option.group.sync.verbose': '显示额外的同步诊断信息',
'help.option.status.json': '输出机器可读的索引和分析器来源信息',
'help.option.json': 'JSON 输出',
'help.option.group.impact.target': '要分析的符号或文件名',

View file

@ -1,4 +1,5 @@
import ignore, { type Ignore } from 'ignore';
import { existsSync } from 'fs';
import fs from 'fs/promises';
import nodePath from 'path';
import type { Path } from 'path-scurry';
@ -31,12 +32,15 @@ const DEFAULT_IGNORE_LIST = new Set([
// 'packages' removed - commonly used for monorepo source code (lerna, pnpm, yarn workspaces)
'venv',
'.venv',
'env',
'.env',
// Bare `env/` can be application source or a Python virtual environment.
// Path-aware rules below prune it at the root and wherever pyvenv.cfg marks
// a virtual environment, while preserving ordinary nested source folders.
'__pycache__',
'.pytest_cache',
'.mypy_cache',
'site-packages',
'dist-packages',
'.tox',
'eggs',
'.eggs',
@ -54,13 +58,33 @@ const DEFAULT_IGNORE_LIST = new Set([
'obj',
'target', // Java/Rust
'.next',
// `.next` is Next.js's build CACHE; `_next` is the EMITTED output, and the two
// are different directories. A Capacitor/Cordova shell copies the emitted
// bundle to `<platform>/app/src/main/assets/public/_next/static/…`, where none
// of the path segments hit this list — so a mobile-wrapped Next.js app had its
// shipped bundle indexed as source, and every Route node it produced pointed at
// a webpack chunk rather than code anyone wrote (#3007).
//
// The name is deliberately unanchored. No `<web-root>/_next` form matches a
// root-level `_next/static/…`, which is the shape the reported repo has, so
// anchoring it would miss the case it was added for. The accepted cost is a
// hand-written directory literally named `_next`; recover one with a bare
// `!_next/` line in `.gitnexusignore`.
'_next',
'.nuxt',
'.output',
'.vercel',
'.netlify',
'.serverless',
'_build',
'public/build',
// `'public/build'` used to sit here. This set is tested one path SEGMENT at a
// time, and `isHardcodedIgnoredDirectory(name)` takes a bare directory name,
// so a slash-containing member could never match either — it was inert. Its
// paths were never unignored though: bare `'build'` above already prunes
// `public/build/**`, so removing the entry changes no behavior (#3007).
// `test/unit/ignore-build-output.test.ts` keeps the next slash-bearing entry
// in this set — or in IGNORED_FILES, ROOT_ARTIFACT_DIRECTORIES or
// IGNORED_EXTENSIONS — from dying the same way.
'.parcel-cache',
'.turbo',
'.svelte-kit',
@ -86,11 +110,11 @@ const DEFAULT_IGNORE_LIST = new Set([
// Generated/Compiled
'.generated',
'generated',
'auto-generated',
// Bare `generated/` can contain tracked source-of-truth code. Build output
// remains covered by .gitignore/.gitnexusignore and the unambiguous names.
'monaco-workers', // Monaco editor web-worker bundles generated for browser runtime
'.terraform',
'.serverless',
// Documentation (optional - might want to keep)
// 'docs',
@ -106,6 +130,14 @@ const DEFAULT_IGNORE_LIST = new Set([
'__snapshots__',
]);
// Ambiguous names that conventionally denote generated artifacts only at the
// repository root. Nested directories with these names are frequently source
// modules (for example apps/web/src/env or packages/api/generated).
const ROOT_ARTIFACT_DIRECTORIES = new Set(['env', 'generated']);
const isRootArtifactDirectory = (relativePath: string, name: string): boolean =>
!relativePath.includes('/') && ROOT_ARTIFACT_DIRECTORIES.has(name);
const IGNORED_EXTENSIONS = new Set([
// Images
'.png',
@ -290,6 +322,10 @@ export const shouldIgnorePath = (filePath: string): boolean => {
const fileName = parts[parts.length - 1];
const fileNameLower = fileName.toLowerCase();
if (parts.length > 0 && isRootArtifactDirectory(parts[0], parts[0])) {
return true;
}
// Laravel compiles Blade templates into generated PHP cache files under
// storage/framework/views. Source templates live in resources/views and are
// handled separately; compiled cache should not become source-of-truth. Keep
@ -329,10 +365,8 @@ export const shouldIgnorePath = (filePath: string): boolean => {
if (
fileNameLower.includes('.bundle.') ||
fileNameLower.includes('.chunk.') ||
fileNameLower.includes('.generated.') ||
fileNameLower.endsWith('.d.ts')
fileNameLower.includes('.generated.')
) {
// TypeScript declaration files
return true;
}
@ -344,6 +378,20 @@ export const isHardcodedIgnoredDirectory = (name: string): boolean => {
return DEFAULT_IGNORE_LIST.has(name);
};
/** Apply directory ignore rules that depend on repository-relative depth. */
export const isHardcodedIgnoredDirectoryAtPath = (
repoRoot: string,
directoryPath: string,
): boolean => {
const name = nodePath.basename(directoryPath);
if (isHardcodedIgnoredDirectory(name)) return true;
const relative = nodePath.relative(repoRoot, directoryPath).replace(/\\/g, '/');
if (isRootArtifactDirectory(relative, name)) return true;
return name === 'env' && existsSync(nodePath.join(directoryPath, 'pyvenv.cfg'));
};
/**
* Load .gitignore and .gitnexusignore rules from the repo root.
* Returns an `ignore` instance with all patterns, or null if no files found.
@ -496,8 +544,10 @@ export const createIgnoreFilter = async (repoPath: string, options?: IgnoreOptio
// last-match-wins: `!__tests__/` + `__tests__/generated/` still
// blocks descent into `__tests__/generated/`.
if (ig && rel && hasExplicitUnignore(ig, rel) && !ig.ignores(rel + '/')) return false;
// Hardcoded list: block descent into well-known noise directories.
if (DEFAULT_IGNORE_LIST.has(p.name)) return true;
// Hardcoded and path-aware rules prune whole trees before glob walks them.
if (rel && isHardcodedIgnoredDirectoryAtPath(repoPath, nodePath.join(repoPath, rel))) {
return true;
}
// Check against .gitignore / .gitnexusignore patterns.
// Since childrenIgnored is only called for directories, always test with
// a trailing slash. This ensures directory-only negation patterns (e.g.

View file

@ -3,14 +3,23 @@ import path from 'node:path';
import { createHash } from 'node:crypto';
import lbug from '@ladybugdb/core';
import type { LbugValue } from '@ladybugdb/core';
import type { BridgeHandle, BridgeMeta, StoredContract, CrossLink, RepoSnapshot } from './types.js';
import type {
BridgeHandle,
BridgeMeta,
StoredContract,
CrossLink,
RepoSnapshot,
MatchType,
} from './types.js';
import { BRIDGE_SCHEMA_QUERIES, BRIDGE_SCHEMA_VERSION } from './bridge-schema.js';
import { recordedMatchStages, recordedRepoList } from './completeness.js';
import {
closeLbugConnection,
openLbugConnection,
type LbugConnectionHandle,
} from '../lbug/lbug-config.js';
import { dedupeContracts, dedupeCrossLinks } from './normalization.js';
import { withGroupSyncLock } from './group-lock.js';
import { createLogger } from '../logger.js';
import { retryRename, writeFileAtomic } from '../../storage/fs-atomic.js';
@ -647,15 +656,347 @@ export async function closeBridgeDb(handle: BridgeHandle): Promise<void> {
/* ------------------------------------------------------------------ */
export async function writeBridgeMeta(groupDir: string, meta: BridgeMeta): Promise<void> {
await writeFileAtomic(path.join(groupDir, 'meta.json'), JSON.stringify(meta, null, 2));
// Strip the reader-only fields HERE rather than at each writer. `readBridgeMeta`
// sets both on what it returns, so any caller that reads-modifies-writes would
// round-trip them to disk — and `pairedWithDatabase` is the poisonous one:
// persisted, it tells every future reader the pair was verified when nothing
// verified it. That rule used to live in the body of the only such caller,
// which held exactly as long as there was one. There are now three writers and
// two of them read first. Enforced at the boundary, no writer can get it wrong.
const { repoListsUnreadable: _reader1, pairedWithDatabase: _reader2, ...persisted } = meta;
await writeFileAtomic(path.join(groupDir, 'meta.json'), JSON.stringify(persisted, null, 2));
}
/**
* Does `meta` still describe the `bridge.lbug` sitting next to it?
*
* `writeBridge` stamps the database's size and mtime into the metadata it
* writes, so a metadata file left over from an earlier sync cannot match a
* database that was replaced after it. Callers whose answer depends on the
* metadata being true of THIS database (cross-repo impact reads completeness
* from it) must not treat a mismatch as fact.
*
* When BOTH halves of the stamp are absent the metadata predates stamping, and
* it is judged on the write order of the two files instead — see
* {@link unstampedMetaPairsByWriteOrder}. Failing every unstamped metadata
* closed would mark all pre-existing bridges as incomplete until re-synced,
* trading a narrow window for a repo-wide regression; accepting them all hands
* back "verified" for the very window this pairing exists to catch.
*
* A stamp is a PAIR, so exactly one half present is rejected rather than waved
* through. That is not the legacy shape: something wrote a stamp and did not
* finish, which is the very condition stamping was added to detect. Joining the
* two `undefined` checks with `||` returned "verified" for precisely the shape
* that most deserves suspicion.
*
* Returns `false` when the database itself cannot be stat'd, on either path,
* since metadata describing a file that is not there describes nothing.
*
* The checks are ORDERED by how strong their evidence is, strongest first, and
* each later one is reached only because every earlier one had nothing to say.
* `provenanceUnknown` therefore comes first: a metadata file whose own writer
* says it cannot vouch for the database beside it has settled the question, and
* neither the stamp nor the write-order heuristic may overturn that.
*
* The marker is not decoration. `refreshPreservedBridgeMeta` rewrites this file
* atomically without touching the database, which leaves `meta.mtime` newer —
* the write order a paired write produces, and the one the unstamped branch
* ACCEPTS. Reading the marker after that branch (or not at all) hands back
* "verified" for a pair the same code path had just found broken.
*/
export async function bridgeMetaMatchesFile(groupDir: string, meta: BridgeMeta): Promise<boolean> {
if (meta.provenanceUnknown) return false;
const stampedSize = meta.bridgeSize !== undefined;
const stampedMtime = meta.bridgeMtimeMs !== undefined;
if (!stampedSize && !stampedMtime) return unstampedMetaPairsByWriteOrder(groupDir);
if (!stampedSize || !stampedMtime) return false;
try {
const stat = await fsp.stat(path.join(groupDir, 'bridge.lbug'));
return stat.size === meta.bridgeSize && stat.mtimeMs === meta.bridgeMtimeMs;
} catch {
return false;
}
}
/**
* Could the unstamped `meta.json` plausibly have been written by the sync that
* put this `bridge.lbug` beside it?
*
* `writeBridge` renames the database into place and writes the metadata AFTER,
* so `meta.mtime >= db.mtime` holds for any pair written together — including
* pairs written by builds from before the stamp existed, which is what makes
* this usable as back-compat rather than a repo-wide "re-sync everything".
* The only way to reach a database strictly NEWER than the metadata beside it
* is a swap whose metadata write did not land: the stale-meta-beside-a-new-
* database window, whose completeness `runGroupImpact` would otherwise spend as
* fact.
*
* This is a HEURISTIC ON WRITE ORDER, not proof of provenance. It answers "were
* these two written in the order a successful sync writes them?", and treats
* that as a proxy for "do these two belong together". It is wrong in two
* directions, and neither is theoretical:
* - FALSE ACCEPT, from a non-monotonic wall clock. `mtimeMs` is realtime, not
* monotonic, so an NTP step backwards, a VM snapshot restore or container
* clock skew between the database write and the metadata write can leave a
* genuinely mis-paired set reading as ordered. Anything that touches the
* stale metadata after a swap does the same — a restore from backup, an
* editor save, a copy that preserves only the database's times. The STAMP
* is what actually closes this; a pair that has one never reaches here.
*
* Coarse filesystem mtime granularity is NOT this hazard, despite looking
* like it: it collapses a pair written together to equal times, and equal
* is accepted, which is the correct verdict for that pair.
*
* - FALSE REJECT, from anything that rewrites the database's mtime after the
* metadata's — `cp -r`, `rsync` without `-t`, a machine move, a restore
* that replays files in directory order. An intact legacy pair is then
* demoted to a lower bound and stays there until the next successful sync
* re-stamps it; there is no other recovery, because nothing on the read
* path can distinguish it from the swap window it is imitating.
*
* This direction is the safe one — it degrades an answer to a floor rather
* than vouching for one — but it is a real, reachable cost, not a
* theoretical one, and it is NOT true that the rule can only ever demote
* pairs that were already broken.
*
* Equality counts as paired. On a filesystem with coarse mtime granularity both
* writes land in the same tick, and demanding a strictly newer metadata file
* would reject every legacy bridge there for a reason that is about the
* filesystem rather than about the bridge.
*
* A timestamp that cannot be measured is no match, the same convention the
* read-only handle cache applies to a bridge it could not stat: a comparison
* that could not be made is not a comparison that succeeded.
*/
async function unstampedMetaPairsByWriteOrder(groupDir: string): Promise<boolean> {
try {
const [dbStat, metaStat] = await Promise.all([
fsp.stat(path.join(groupDir, 'bridge.lbug')),
fsp.stat(path.join(groupDir, 'meta.json')),
]);
return metaStat.mtimeMs >= dbStat.mtimeMs;
} catch {
return false;
}
}
/**
* Read `meta.json`, validating the SHAPE of what it holds.
*
* The read and the parse have always been guarded — an absent or unparseable
* file answers `version: 0`, which every caller already treats as "no
* provenance". What was not guarded is a file that parses into something that
* is not this shape: `runGroupImpact` spread both repo lists directly into a
* `Set`, so a non-iterable there threw a TypeError out of the whole cross-repo
* query, from a point where the bridge lease had been taken and not yet
* released. A malformed file is a reason to answer "provenance unknown", never
* a reason to crash the question.
*/
export async function readBridgeMeta(groupDir: string): Promise<BridgeMeta> {
const unreadable: BridgeMeta = { version: 0, generatedAt: '', missingRepos: [] };
let parsed: unknown;
try {
const content = await fsp.readFile(path.join(groupDir, 'meta.json'), 'utf-8');
return JSON.parse(content) as BridgeMeta;
parsed = JSON.parse(content);
} catch {
return { version: 0, generatedAt: '', missingRepos: [] };
return unreadable;
}
// `JSON.parse` succeeds on `null`, `7` and `[]` too, and none of them are
// metadata. Reading `.version` off the first of those is a thrown TypeError;
// reading it off the others silently yields `undefined`, which passes the
// version gate as if the bridge had been vouched for.
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return unreadable;
const raw = parsed as Partial<BridgeMeta>;
const missingRepos = recordedRepoList(raw.missingRepos);
const unreadableRepos = recordedRepoList(raw.unreadableRepos);
// Each list is judged on its own: a file whose `unreadableRepos` is garbage
// can still carry a `missingRepos` that was genuinely measured, and throwing
// that away would turn one unknown into two.
const repoListsUnreadable =
(raw.missingRepos !== undefined && missingRepos === undefined) ||
(raw.unreadableRepos !== undefined && unreadableRepos === undefined);
const meta: BridgeMeta = {
...raw,
// A version that is not a number cannot be compared against
// BRIDGE_SCHEMA_VERSION; `0` is this file's existing word for "provenance
// unknown", which is exactly what such a file gives us.
// `0` is this file's word for "no provenance". A version that is not a
// positive integer is not a schema version, and letting one through splits
// the four gates that read this field: `ensureBridgeReady` and
// `openBridgeDbReadOnly` both compare `> 0 && !== CURRENT` and would open
// the bridge, `bridgeExists` compares `=== 0 || === CURRENT` and would say
// it is not there, and `bridgeProvenanceUnknown` compares `=== 0` and would
// call the answer complete. Normalizing here keeps all four agreeing
// instead of teaching each one the same new case.
version:
Number.isInteger(raw.version) && (raw.version as number) > 0 ? (raw.version as number) : 0,
generatedAt: typeof raw.generatedAt === 'string' ? raw.generatedAt : '',
missingRepos: missingRepos ?? [],
};
// Absent, not empty. `unreadableRepos` is optional and "not recorded" is a
// distinct state from "measured none", so an unusable value is dropped rather
// than carried through — `repoListsUnreadable` is what records that something
// was there and could not be read.
if (unreadableRepos) meta.unreadableRepos = unreadableRepos;
else delete meta.unreadableRepos;
// Same absent-vs-empty rule, through the one shared reader.
const suppressed = recordedMatchStages(raw.suppressedMatchStages);
if (suppressed) meta.suppressedMatchStages = suppressed;
else delete meta.suppressedMatchStages;
if (repoListsUnreadable) meta.repoListsUnreadable = true;
return meta;
}
/* ------------------------------------------------------------------ */
/* refreshPreservedBridgeMeta */
/* ------------------------------------------------------------------ */
/**
* What a refresh did to `meta.json`.
*
* - `restamped` — the pair still matched, so the lists were refreshed
* and the stamp re-taken from the database on disk.
* - `provenance-unknown` — the pair did NOT match (or there is no database to
* match), so the lists were refreshed and the metadata
* marked as unable to vouch for the file beside it.
* - `no-bridge` — neither `meta.json` nor `bridge.lbug` exists, so
* there is no pair to keep honest and nothing written.
*/
export type PreservedBridgeMetaOutcome = 'restamped' | 'provenance-unknown' | 'no-bridge';
async function fileExists(filePath: string): Promise<boolean> {
try {
await fsp.access(filePath);
return true;
} catch {
return false;
}
}
/**
* Bring `meta.json`'s diagnostic lists up to date with a sync that PRESERVED
* the bridge instead of rebuilding it, without ever making the metadata claim
* more about the database than it did before.
*
* `syncGroup`'s total-failure path keeps the previous run's contracts and
* deliberately leaves `bridge.lbug` alone — the contracts that bridge holds are
* the ones being preserved. But `runGroupImpact` reads completeness from
* `meta.json`, not from `contracts.json`, so leaving the metadata alone too left
* the two files telling different stories: the registry said "this sync could
* not read svc/users" while a cross-repo query answered "complete, nothing
* depends on this" (R4/R6).
*
* The refresh is the whole difficulty. It rewrites `meta.json` atomically, so
* the file's mtime becomes now while the database's stays old — which is the
* write order a paired write produces, and precisely what
* `unstampedMetaPairsByWriteOrder` accepts. Three rules follow, and each of them
* is load-bearing:
*
* 1. Ask `bridgeMetaMatchesFile` FIRST, on the file as it stands. After the
* write the question is unanswerable, because the write is what destroys
* the evidence.
* 2. Re-stamp only when that answer was yes. Re-stamping a pair that already
* failed would MANUFACTURE the provenance the failure just denied — the
* same metadata/database mis-pairing stamping exists to prevent (KTD6).
* 3. When it was no, record `provenanceUnknown` explicitly and carry the
* existing stamp fields through verbatim. Writing "no stamp" instead is
* worse, not better: an unstamped file is judged on the two file times,
* and this write has just put them in the accepting order.
*
* Nothing here opens, reads, or writes the database. The only `stat` of it
* happens on the branch where the pair was just verified.
*
* NOT SPLIT into locked/unlocked halves the way {@link writeBridge} is, and
* deliberately. Its one caller is `syncGroup`'s preserve branch, which is
* already inside `withGroupSyncLock` — so this write is ALREADY serialized
* against every other sync of the group, and taking the lock here would be the
* second acquisition of a non-reentrant primitive that the split exists to
* avoid. An acquiring wrapper would therefore have zero production callers,
* and no test calls this function at all: it would be dead code standing in for
* a guarantee the caller already provides. If a caller outside the critical
* section ever appears, it needs the same treatment `writeBridge` got — a
* wrapper, not a lock moved down here.
*/
export async function refreshPreservedBridgeMeta(
groupDir: string,
// Deliberately NOT `suppressedMatchStages`. This path preserves an EARLIER
// sync's database, so stamping it with this run's request would claim the
// untouched bridge was built with a flag it never saw. The registry's own
// preserve write (`{ ...prior, missingRepos, unreadableRepos }`) omits it for
// exactly this reason, and the two artifacts have to agree about which run
// they describe.
diagnostics: { missingRepos: string[]; unreadableRepos: string[] },
): Promise<PreservedBridgeMetaOutcome> {
const dbPath = path.join(groupDir, 'bridge.lbug');
const [metaOnDisk, dbOnDisk] = await Promise.all([
fileExists(path.join(groupDir, 'meta.json')),
fileExists(dbPath),
]);
// Nothing on either side of the pair. `readBridgeMeta` already answers
// `version: 0` — provenance unknown — for an absent file, so a file written
// here would say what the absence already says while inventing state for a
// bridge that has never existed.
if (!metaOnDisk && !dbOnDisk) return 'no-bridge';
const existing = await readBridgeMeta(groupDir);
const paired = await bridgeMetaMatchesFile(groupDir, existing);
const refreshed: BridgeMeta = { ...existing, ...diagnostics };
// NEVER PERSISTED (see `BridgeMeta`): both are things a READER computes ABOUT
// a file, and this is the first code in the repo that reads metadata and
// writes it back. The strip itself now lives in `writeBridgeMeta`, so every
// writer inherits it rather than each remembering.
if (paired) {
const stat = await fsp.stat(dbPath).catch(() => null);
if (stat) {
refreshed.bridgeSize = stat.size;
refreshed.bridgeMtimeMs = stat.mtimeMs;
await writeBridgeMeta(groupDir, refreshed);
return 'restamped';
}
// The database disappeared between the pairing check and this stat. There
// is nothing left to stamp, so fall through and say so rather than write a
// stamp describing a file that is gone.
}
refreshed.provenanceUnknown = true;
await writeBridgeMeta(groupDir, refreshed);
return 'provenance-unknown';
}
/**
* Withdraw the bridge's claim to be complete, without touching the database.
*
* The one path this exists for: `contracts.json` committed, then the bridge
* replacement failed. The old database is still physically usable and still
* answers queries, but it now describes an EARLIER sync than the canonical
* registry beside it — so `group_contracts` can report a narrowed or advanced
* contract set while `group_impact` traverses the old graph and calls its
* answer complete. Two public surfaces, contradictory epistemic claims, from
* one sync.
*
* Setting `provenanceUnknown` is the smallest thing that makes that safe:
* `bridgeMetaMatchesFile` gives it highest precedence and refuses to vouch for
* the pair, so every cross-repo answer downgrades to a floor until a sync
* succeeds. Deliberately NOT a re-stamp — the metadata still describes the
* database it was written for, and claiming otherwise is the mis-pairing the
* preserve path is careful to avoid. Deliberately not a delete either: the
* previous graph is better than nothing as long as nobody calls it complete.
*
* Best-effort by construction. It runs inside a failure handler, so a throw
* here would replace a reported bridge failure with an unrelated one.
*/
export async function markBridgeProvenanceUnknown(groupDir: string): Promise<boolean> {
try {
const existing = await readBridgeMeta(groupDir);
if (existing.version === 0) return false;
await writeBridgeMeta(groupDir, { ...existing, provenanceUnknown: true });
return true;
} catch {
return false;
}
}
@ -668,6 +1009,29 @@ export interface WriteBridgeInput {
crossLinks: CrossLink[];
repoSnapshots: Record<string, RepoSnapshot>;
missingRepos: string[];
/**
* Repos this sync could not extract from — see
* `ContractRegistry.unreadableRepos` for the full definition, which this
* field carries unchanged.
*
* Deliberately not restated here. The narrower wording this once had ("whose
* index could not be opened") described one of the two causes and silently
* excluded the other, an extractor that threw partway through — so the same
* field meant one thing on the registry, another on the bridge input, and a
* third on the result. One definition, referenced twice, cannot drift.
*
* Recorded in meta.json so cross-repo impact can tell "nothing depends on
* this" from "we could not look": the bridge built here is missing every
* contract those repos own.
*/
unreadableRepos?: string[];
/**
* Matching stages the sync was asked to skip. Recorded here for the same
* reason `unreadableRepos` is: a later cross-repo query reads this bridge
* with no access to the run that built it, and a graph narrowed by request
* looks exactly like a complete one.
*/
suppressedMatchStages?: MatchType[];
}
/**
@ -702,7 +1066,33 @@ function errMessage(err: unknown): string {
}
}
export async function writeBridge(
/**
* Rebuild `bridge.lbug` and its `meta.json`, ASSUMING THE CALLER ALREADY HOLDS
* THE GROUP SYNC LOCK for `groupDir` (R9).
*
* PRECONDITION — the group lock is held. There is exactly one production call
* site, `syncGroup` in sync.ts, and it is already inside
* `withGroupSyncLock(groupDir, …)` when it gets here. Enforced by this comment
* rather than by a type, matching `registerRepoUnlocked` / `withRegistryLock`
* in repo-manager.ts, which splits the same shape for the same reason.
*
* WHY THE SPLIT EXISTS AT ALL. The swap this function performs — old database
* aside, temp database into place, then `meta.json` written as a SECOND
* operation — is the write two concurrent syncs can interleave into a pairing
* that never existed: one sync's metadata beside the other's database. That
* needs mutual exclusion. But taking the lock HERE would be a second
* acquisition of a non-reentrant primitive inside a region that already holds
* it, and it would hang every single sync on the happy path, not some rare
* interleave. So the exclusion is the caller's, and this function only states
* the precondition. {@link writeBridge} is the acquiring wrapper for callers
* who are not already inside that region.
*
* SCOPE — writer-writer only. The reader-side promotion of a leftover
* `bridge.lbug.bak` runs on ordinary reads, outside anybody's critical section;
* `bridgeMetaMatchesFile` remains the reader's defense there and is not
* replaced by this lock.
*/
export async function writeBridgeUnlocked(
groupDir: string,
input: WriteBridgeInput,
): Promise<WriteBridgeReport> {
@ -962,11 +1352,42 @@ export async function writeBridge(
}
await removeLbugFile(bakPath);
// 4. Write meta.json
// 4. Write the new meta.json, STAMPED WITH THE FILE IT DESCRIBES.
//
// meta.json carries the bridge's completeness, and since #3011 that is
// load-bearing: `runGroupImpact` folds `unreadableRepos ∪ missingRepos`
// into its truncation fields. The swap above and this write are two
// operations, so a sync that stops between them leaves the previous sync's
// meta beside a new database — and reading that as fact is a confidently
// wrong answer about the one thing this channel exists to make legible.
//
// Deleting the old meta before the swap would decide which way that window
// fails, but at an unacceptable price: the rename of the old database is
// wrapped in a catch that also swallows a FAILED rename (a held read-only
// handle does this on Windows), so `writeBridge` can throw with the old,
// perfectly good database still in place — and its metadata already gone,
// unrecoverably, for as long as the swap keeps failing.
//
// So destroy nothing and pair the two instead: record the size and mtime of
// the database this metadata describes, and let readers check that the pair
// still belongs together (`bridgeMetaMatchesFile`). A stale meta cannot match
// a freshly renamed database, and a sync that fails before the swap leaves a
// matching pair untouched.
const finalStat = await fsp.stat(finalPath);
await writeBridgeMeta(groupDir, {
version: BRIDGE_SCHEMA_VERSION,
generatedAt: new Date().toISOString(),
bridgeSize: finalStat.size,
bridgeMtimeMs: finalStat.mtimeMs,
missingRepos: input.missingRepos,
// Persisted whenever the caller supplied it, `[]` included: an empty list
// is the measurement "this sync accounted for every repo", and it is a
// different claim from a bridge that never recorded the field. Omitted
// only when the caller passed nothing to record.
...(input.unreadableRepos ? { unreadableRepos: input.unreadableRepos } : {}),
...(input.suppressedMatchStages
? { suppressedMatchStages: input.suppressedMatchStages }
: {}),
});
return report;
@ -982,6 +1403,33 @@ export async function writeBridge(
}
}
/**
* Rebuild `bridge.lbug` and its `meta.json` as the only writer of `groupDir`.
*
* The acquiring half of the split described on {@link writeBridgeUnlocked}: for
* callers that are NOT already inside the group's critical section, this takes
* the group sync lock around the whole swap and releases it afterwards. Two
* concurrent calls therefore run one after the other, so the `meta.json` left
* on disk is stamped for the `bridge.lbug` left on disk instead of for the
* loser's, which is the pairing the swap-plus-metadata sequence would otherwise
* let them interleave into.
*
* NOT used by `syncGroup`, and it must not be: that path already holds this
* lock, and `acquireIndexLock` is not reentrant, so routing it here would make
* every ordinary sync wait out the full `GROUP_SYNC_LOCK_TIMEOUT_MS` ceiling
* against itself. It calls {@link writeBridgeUnlocked} directly.
*
* Fails closed exactly as `withGroupSyncLock` does: if the lock cannot be
* acquired, a `GroupSyncLockError` is thrown and NOTHING is written —
* `bridge.lbug` and `meta.json` are left as they were.
*/
export async function writeBridge(
groupDir: string,
input: WriteBridgeInput,
): Promise<WriteBridgeReport> {
return withGroupSyncLock(groupDir, () => writeBridgeUnlocked(groupDir, input));
}
/* ------------------------------------------------------------------ */
/* openBridgeDbReadOnly */
/* ------------------------------------------------------------------ */

View file

@ -0,0 +1,169 @@
/**
* The one computation of "is this cross-repo answer complete?" (KTD10), and the
* truncation vocabulary it speaks.
*
* A LEAF MODULE, deliberately, and that is the whole reason it exists apart from
* `cross-impact.ts`. Three surfaces need this fold — impact, trace, and the
* contract listing — but `cross-impact.ts` statically imports `bridge-db.ts`,
* and through it the native LadybugDB binding. `service.ts` therefore had to
* reach the fold through `await import('./cross-impact.js')`, which loaded that
* entire module graph on the first `group_contracts` of every process — 44-51ms
* and 8.4MB of RSS to run a `Set` union and a ternary, once per CLI invocation.
*
* Nothing here imports anything but types. Keep it that way: the moment this
* file gains a runtime import, every consumer pays for it again.
*/
import type { GroupImpactTruncationReason, MatchType } from './types.js';
/**
* A union rather than `Pick<GroupImpactResult, ...>` so the two states are
* distinguishable by their `truncated` discriminant: a caller that folds these
* fields into its own result (see `crossRepoCompleteness`) can then read
* `truncationReason` on the truncated branch without a fallback for a value
* that cannot be absent there.
*/
export type TruncationFields =
| { truncated: false }
| {
truncated: true;
truncationReason: GroupImpactTruncationReason;
riskEpistemic: 'lower-bound';
};
/**
* Build the truncation fields every `runGroupImpact` return path shares.
*
* `riskEpistemic` must follow `truncated` mechanically: it is the marker that
* tells a caller the `risk` value is a floor rather than a verdict, and
* `mergeRisk` can only under-report once a crossing is dropped. Attaching it at
* each return let two of the four paths set `truncated` without it, so a
* truncated result read as complete — deriving it in one place is what keeps
* the invariant from drifting again (#2787).
*/
export function truncationFields(
truncated: boolean,
// Only read on the truncated branch, so the not-truncated call sites omit it
// rather than passing a reason that is thrown away.
reasonIfTruncated: GroupImpactTruncationReason = 'partial',
): TruncationFields {
if (!truncated) return { truncated: false };
return { truncated: true, truncationReason: reasonIfTruncated, riskEpistemic: 'lower-bound' };
}
/**
* Everything a caller needs in order to say whether a cross-repo answer is
* complete — deliberately WITHOUT naming where any of it came from.
*
* `BridgeMeta` is not in this signature, and must not be: `groupContracts`
* answers the same question from `contracts.json` (via
* `loadContractRegistryResilient`) and never opens a bridge at all, so
* `version` / `repoListsUnreadable` / `pairedWithDatabase` do not exist on that
* path. Each caller computes its own `provenanceUnknown` from whatever
* provenance IT has and passes the boolean in.
*/
export interface CrossRepoCompletenessInput {
/**
* Repos the sync could not extract from, and repos it found no entry for.
* Two independent diagnostics with one consequence — none of those repos'
* contracts are in the artifact — so they are folded into one set.
*/
unreadableRepos?: readonly string[];
missingRepos?: readonly string[];
/**
* Matching stages the sync was asked to skip. Absent or empty means it
* suppressed none; a populated list makes the answer a floor for a reason
* that is neither a runtime limit nor an unreadable repo.
*/
suppressedMatchStages?: readonly string[];
/** Computed by the caller; see `bridgeProvenanceUnknown` for the bridge one. */
provenanceUnknown: boolean;
/**
* The query's DECLARED scope, not the set of repos the walk happened to
* reach: the subgroup filter for an impact query, the two endpoint repos for
* a trace, every member for a query that names none. An incomplete repo the
* caller never asked about cannot make the caller's answer a floor, and
* marking it anyway is how the marker stops meaning anything. Passing the
* predicate in — rather than a repo list, or a subgroup — is what keeps
* narrowing a scope a call-site change.
*/
inScope: (repoPath: string) => boolean;
}
/** The structured triple, plus the in-scope repos that produced it. */
export type CrossRepoCompleteness = TruncationFields & {
/**
* In-scope repos absent from the artifact, deduped, in first-seen order.
* Empty on a provenance-unknown answer: nothing was measured there, and
* inventing names out of an unreadable value is not a measurement.
*/
incompleteRepos: string[];
};
/**
* Read a persisted `suppressedMatchStages` list.
*
* Sibling of `recordedRepoList` and here for the same stated reason: it had
* lived in two files verbatim, so tightening one would silently leave the other.
* All-or-nothing like its sibling — a stale member (this repo has already
* retired `'bm25'` and `'embedding'`) makes the whole list unreadable rather
* than filtering down to `[]`, which on this field would mean "measured,
* nothing suppressed": a clean answer manufactured from a value we could not
* read.
*/
export function recordedMatchStages(value: unknown): MatchType[] | undefined {
if (!Array.isArray(value)) return undefined;
const known: MatchType[] = ['exact', 'manifest', 'wildcard'];
return value.every((v): v is MatchType => known.includes(v as MatchType)) ? value : undefined;
}
/**
* The ONE computation of "is this cross-repo answer complete?" (KTD10).
*
* Three surfaces can return a partial cross-repo answer — impact, trace, and
* the contract listing — and each used to decide for itself, in its own
* vocabulary, which is how two of them ended up saying it in prose only. The
* answer is the same structured triple `GroupImpactResult` already carries, so
* an agent reading any of them learns "complete" vs "floor" the same way.
*
* `truncationFields` derives `riskEpistemic` from `truncated` mechanically, and
* is reused here rather than re-implemented for the same reason it exists: the
* marker that says "this is a floor, not a verdict" may never drift away from
* the flag that says the answer was cut short (#2787).
*/
export function crossRepoCompleteness(input: CrossRepoCompletenessInput): CrossRepoCompleteness {
const incompleteRepos = [
...new Set([...(input.unreadableRepos ?? []), ...(input.missingRepos ?? [])]),
].filter((repoPath) => input.inScope(repoPath));
// An unreadable or unaccounted repo outranks a suppressed stage: it is the
// more serious structural gap and its remedy (repair the repo, re-sync) has
// to be the one reported. A suppressed stage only decides the reason when
// the repo side is otherwise clean.
const suppressed = (input.suppressedMatchStages ?? []).length > 0;
const repoSideIncomplete = input.provenanceUnknown || incompleteRepos.length > 0;
return {
...truncationFields(
repoSideIncomplete || suppressed,
repoSideIncomplete ? 'incomplete-sync' : 'suppressed-stage',
),
incompleteRepos,
};
}
/**
* A recorded repo list is an array of strings. Anything else — a bare string, an
* object, an array of objects — is a value we could not read, which is "not
* recorded", not "none".
*
* ONE definition, deliberately. This gate is the predicate the whole
* absent-vs-empty-vs-populated distinction rests on, and it applies to the same
* two lists on both the registry and the bridge metadata. It lived in two files
* verbatim, which meant tightening it — say, to reject blank strings — would
* have fixed one surface and silently left the other.
*
* `Array.isArray` alone is not enough: only an array of strings survives
* `cli/group.ts`'s `.join(', ')` as repo paths rather than as `[object Object]`.
*/
export function recordedRepoList(value: unknown): string[] | undefined {
if (!Array.isArray(value)) return undefined;
return value.every((entry) => typeof entry === 'string') ? (value as string[]) : undefined;
}

View file

@ -29,16 +29,11 @@ const DEFAULT_DETECT = {
grpc: true,
thrift: true,
topics: true,
shared_libs: true,
embedding_fallback: true,
includes: false,
workspace_deps: false,
};
const DEFAULT_MATCHING = {
bm25_threshold: 0.7,
embedding_threshold: 0.65,
max_candidates_per_step: 3,
exclude_links_paths: [] as string[],
exclude_links_param_only_paths: false,
};

View file

@ -7,11 +7,11 @@ import fsp from 'node:fs/promises';
import path from 'node:path';
import type {
BridgeHandle,
BridgeMeta,
ContractType,
CrossRepoImpact,
GroupConfig,
GroupImpactResult,
GroupImpactTruncationReason,
MatchType,
OutOfScopeLink,
} from './types.js';
@ -24,12 +24,23 @@ import {
} from './group-path-utils.js';
import { getGroupDir } from './storage.js';
import {
bridgeMetaMatchesFile,
closeBridgeDb,
getCachedBridgeReadOnly,
queryBridge,
readBridgeMeta,
} from './bridge-db.js';
import { BRIDGE_SCHEMA_VERSION } from './bridge-schema.js';
// Re-exported so the three surfaces keep one import site for the vocabulary,
// while the fold itself stays in a leaf module no native binding reaches.
export {
truncationFields,
crossRepoCompleteness,
type TruncationFields,
type CrossRepoCompleteness,
type CrossRepoCompletenessInput,
} from './completeness.js';
import { truncationFields, crossRepoCompleteness } from './completeness.js';
import { compareCodeUnits } from '../../lib/utils.js';
// High limit for the local phase of group impact so collectImpactSymbolUids
@ -381,23 +392,30 @@ export function mergeRisk(localRisk: string, cross: CrossRepoImpact[]): string {
}
/**
* Build the truncation fields every `runGroupImpact` return path shares.
* Is this bridge's metadata unable to say where its contents came from?
*
* `riskEpistemic` must follow `truncated` mechanically: it is the marker that
* tells a caller the `risk` value is a floor rather than a verdict, and
* `mergeRisk` can only under-report once a crossing is dropped. Attaching it at
* each return let two of the four paths set `truncated` without it, so a
* truncated result read as complete — deriving it in one place is what keeps
* the invariant from drifting again (#2787).
* The three reads are all about a `BridgeMeta` and stay OUT of
* `crossRepoCompleteness` on purpose (see its doc): they are how a caller that
* opened a bridge computes `provenanceUnknown`, not how every caller does.
*
* - `version === 0` — no readable meta.json at all (`readBridgeMeta` answers
* that for both "absent" and "unparseable");
* - `repoListsUnreadable` — a meta.json that parsed but whose repo lists are
* not repo lists. A value we could not read is not a measurement of zero,
* so it may not be spent as one;
* - `pairedWithDatabase === false` — a meta.json that does not describe the
* database sitting beside it, which is what a sync interrupted between the
* swap and the metadata write leaves behind. Measured by
* `ensureBridgeReady` BEFORE the database is opened and carried on the
* meta; this only reads the answer (#3012).
*
* Treating any of them as complete is the fail-open the completeness channel
* exists to close.
*/
function truncationFields(
truncated: boolean,
// Only read on the truncated branch, so the not-truncated call sites omit it
// rather than passing a reason that is thrown away.
reasonIfTruncated: GroupImpactTruncationReason = 'partial',
): Pick<GroupImpactResult, 'truncated' | 'truncationReason' | 'riskEpistemic'> {
if (!truncated) return { truncated: false };
return { truncated: true, truncationReason: reasonIfTruncated, riskEpistemic: 'lower-bound' };
export function bridgeProvenanceUnknown(meta: BridgeMeta): boolean {
return (
meta.version === 0 || meta.repoListsUnreadable === true || meta.pairedWithDatabase === false
);
}
function addCrossImpact(cross: CrossRepoImpact[], candidate: CrossRepoImpact): void {
@ -418,7 +436,7 @@ function addCrossImpact(cross: CrossRepoImpact[], candidate: CrossRepoImpact): v
export async function ensureBridgeReady(
groupDir: string,
): Promise<{ handle: BridgeHandle } | { error: string }> {
): Promise<{ handle: BridgeHandle; meta: BridgeMeta } | { error: string }> {
const meta = await readBridgeMeta(groupDir);
if (meta.version > 0 && meta.version !== BRIDGE_SCHEMA_VERSION) {
return {
@ -433,6 +451,13 @@ export async function ensureBridgeReady(
error: `No bridge.lbug in this group directory. Run gitnexus group sync (schema ${BRIDGE_SCHEMA_VERSION}).`,
};
}
// Pair the metadata to the database BEFORE opening it, and carry the answer.
// An unstamped pair is judged on the two files' write order, so any open that
// touched `bridge.lbug`'s mtime would silently convert "legacy but intact"
// into "provenance unknown" for every pre-stamp bridge on that platform. This
// ordering removes the question rather than betting on the answer.
meta.pairedWithDatabase = await bridgeMetaMatchesFile(groupDir, meta);
// Use the cached read-only handle if available — avoids reopening the same
// bridge.lbug in a long-lived MCP server, which fails on Windows because
// the OS handle isn't fully released before the next open races in.
@ -442,7 +467,7 @@ export async function ensureBridgeReady(
error: `Could not open bridge.lbug read-only (schema ${BRIDGE_SCHEMA_VERSION}). Run gitnexus group sync.`,
};
}
return { handle };
return { handle, meta };
}
function rowToNeighbor(r: Record<string, unknown>): BridgeNeighborRow | null {
@ -641,6 +666,25 @@ export async function runGroupImpact(
if ('error' in bridgePrep) return { error: bridgePrep.error };
const handle = bridgePrep.handle;
// Repos the sync that built this bridge could not account for. Their
// contracts — and every cross-link touching them — are simply absent from
// bridge.lbug, and nothing else in this walk can notice that: the only
// incompleteness channel on the result is `truncationFields`, driven by
// fan-out state. Without folding these in, a query about a symbol whose one
// downstream consumer lives in an unreadable repo returns
// `{ cross: [], truncated: false }` — "complete: nothing depends on this" —
// which is a wrong answer, not an empty one, for a tool an agent uses to
// license a delete or a rename.
//
// The metadata read that answers it (`bridgeProvenanceUnknown`) happens
// INSIDE the `try` below, and the flag is initialized fail-closed here only
// because it outlives that block. The lease taken by `ensureBridgeReady` is
// released by the `finally` and nowhere else, so work done between the lease
// and the `try` is work whose every throw leaks a refcount the cached handle
// can never get back — which is how a malformed meta.json used to wedge the
// handle as well as crash the query. (The repo lists are folded in after the
// `finally`, where a throw can no longer strand the lease.)
let provenanceUnknown = true;
const cross: CrossRepoImpact[] = [];
const outOfScope: OutOfScopeLink[] = [];
const truncatedRepos: string[] = [];
@ -650,6 +694,8 @@ export async function runGroupImpact(
let fanoutTimedOut = false;
try {
provenanceUnknown = bridgeProvenanceUnknown(bridgePrep.meta);
const neighbors = await resolveBridgeNeighbors(handle, {
localRepo: repoPath,
uids,
@ -782,7 +828,46 @@ export async function runGroupImpact(
const localSum = (local as { summary?: Record<string, number> })?.summary || {};
const localRisk = String((local as { risk?: string }).risk ?? 'LOW');
const localPartial = Boolean((local as { partial?: boolean }).partial);
const truncated = truncatedRepos.length > 0 || localPartial;
// The bridge's own incompleteness, in the shared vocabulary, read through
// what this query DECLARED. The fan-out above already drops every neighbour
// outside `subgroup`, so an incomplete repo the query excluded could not have
// contributed a crossing to this answer — marking the answer a floor because
// of it makes the marker fire on results it does not describe, which is how a
// caller learns to ignore it. An unscoped query passes `subgroup: undefined`,
// which `repoInSubgroup` answers true for, so the intersection is the whole
// set and that path is byte-for-byte the old behaviour.
//
// The declared scope is the subgroup PLUS the query's own repo (`exact`
// reuses the one membership helper for the equality, rather than growing a
// second notion of it): the walk starts from `repoPath`'s contracts in the
// bridge, so if THAT is the repo the sync could not read there are no
// crossings to find for any scope, and a subgroup excluding it must not turn
// that vacuum into a confident "complete".
//
// Declared scope, not traversed scope: an incomplete repo's contracts are
// absent from the bridge by definition, so it is never in the set the walk
// reached — filtering on what was traversed would empty the intersection on
// every query and silently restore the fail-open.
//
// Sound only while `MAX_SUPPORTED_CROSS_DEPTH` is 1. At depth 2+ an
// out-of-scope repo can sit BETWEEN two in-scope ones, so dropping it would
// convert a genuine lower bound into a confident complete answer; widen this
// predicate in the same change that raises the depth.
const bridge = crossRepoCompleteness({
unreadableRepos: bridgePrep.meta.unreadableRepos,
missingRepos: bridgePrep.meta.missingRepos,
suppressedMatchStages: bridgePrep.meta.suppressedMatchStages,
provenanceUnknown,
inScope: (candidate) =>
repoInSubgroup(candidate, subgroup) || repoInSubgroup(candidate, repoPath, true),
});
// One predicate, read twice below. Written out at both sites, a third runtime
// cause added to the flag and forgotten at the reason would label a
// retry-able answer `incomplete-sync` — telling the operator to re-sync for
// something a retry fixes. That reason-vs-flag drift is what `truncationFields`
// exists to prevent.
const runtimeTruncated = truncatedRepos.length > 0 || localPartial;
const truncated = runtimeTruncated || bridge.truncated;
const result: GroupImpactResult = {
local,
@ -794,8 +879,25 @@ export async function runGroupImpact(
// and under-reporting a blast radius is the unsafe direction (an agent told
// LOW proceeds; told CRITICAL it stops). Marking the floor keeps the
// warning intact while making the incompleteness legible.
...truncationFields(truncated, fanoutTimedOut ? 'timeout' : 'partial'),
truncatedRepos: [...new Set(truncatedRepos)],
// Runtime limits first — they are what the caller can retry. Past those, the
// BRIDGE's own reason wins: it already distinguished an unreadable repo
// ('incomplete-sync', remedy: re-sync) from a stage the sync was asked to
// skip ('suppressed-stage', remedy: re-sync WITHOUT the flag). Hardcoding
// the fallback here overrode that and told every caller to repair a repo
// that read fine — and made the second value unreachable from this surface
// while the tool description promised it. `cross-trace.ts` re-spreads the
// bridge's fields for the same reason.
...truncationFields(
truncated,
fanoutTimedOut
? 'timeout'
: runtimeTruncated
? 'partial'
: bridge.truncated
? bridge.truncationReason
: 'incomplete-sync',
),
truncatedRepos: [...new Set([...truncatedRepos, ...bridge.incompleteRepos])],
summary: {
direct: localSum.direct ?? 0,
processes_affected: localSum.processes_affected ?? 0,

View file

@ -25,16 +25,29 @@
import { GroupNotFoundError, loadGroupConfig } from './config-parser.js';
import { getGroupDir } from './storage.js';
import { ensureBridgeReady, MAX_SUPPORTED_CROSS_DEPTH } from './cross-impact.js';
import {
bridgeProvenanceUnknown,
crossRepoCompleteness,
ensureBridgeReady,
MAX_SUPPORTED_CROSS_DEPTH,
} from './cross-impact.js';
import type { CrossRepoCompleteness } from './completeness.js';
import { truncationFields } from './completeness.js';
import { compareCodeUnits } from '../../lib/utils.js';
import { closeBridgeDb, queryBridge } from './bridge-db.js';
import { repoInSubgroup } from './group-path-utils.js';
import type {
GroupPdgFlowHop,
GroupRepoHandle,
GroupSymbolResolution,
GroupToolPort,
} from './service.js';
import type { BridgeHandle, GroupConfig } from './types.js';
import type {
BridgeHandle,
BridgeMeta,
GroupConfig,
GroupImpactTruncationReason,
} from './types.js';
// ── Result types (discriminated on `status`) ─────────────────────────────
@ -77,7 +90,29 @@ export interface GroupTraceEndpoint {
repo: string;
}
export interface GroupTraceOkResult {
/**
* The incompleteness vocabulary, verbatim from `GroupImpactResult` (KTD10).
*
* A cross-repo trace and a cross-repo impact can both be cut short by the same
* two kinds of cause — a runtime limit inside this walk, or a bridge that never
* held part of the group — and an agent must not have to learn a second
* vocabulary (or parse a `notes` string) to tell "no path exists" from "we
* could not have seen the path". Every field here means exactly what it means
* on `GroupImpactResult`; `notes` stays a human-readable ADDITION to them,
* never the machine-readable channel.
*/
export interface GroupTraceCompleteness {
/** True when this answer is a floor rather than a verdict. */
truncated?: boolean;
/** Why, when `truncated` — runtime limit ('partial'/'timeout') before structure. */
truncationReason?: GroupImpactTruncationReason;
/** Set with `truncated`: the answer under-reports, it never over-reports. */
riskEpistemic?: 'lower-bound';
/** In-scope repos absent from the bridge; omitted when none were measured. */
truncatedRepos?: string[];
}
export interface GroupTraceOkResult extends GroupTraceCompleteness {
status: 'ok';
group: string;
from: GroupTraceEndpoint;
@ -89,7 +124,6 @@ export interface GroupTraceOkResult {
edges: TraceEdge[];
/** Present only when PDG enrichment ran for at least one segment. */
dataFlow?: SegmentDataFlow[];
truncated?: boolean;
notes: string[];
}
@ -101,23 +135,23 @@ export interface GroupTraceCandidate {
startLine: number;
}
export interface GroupTraceNotFoundResult {
/**
* `truncated: true` here means the answer is NOT authoritative — either the
* crossing cap (`MAX_CROSSINGS_TO_TRY`) was hit so a connecting ContractLink
* ranked beyond it may have been skipped, or the bridge itself never held part
* of the group. Both read as "unknown", not as "no path exists";
* `truncationReason` says which.
*/
export interface GroupTraceNotFoundResult extends GroupTraceCompleteness {
status: 'not_found';
group: string;
role?: 'from' | 'to';
query?: string;
/**
* True when the answer is NOT authoritative: the crossing cap
* (`MAX_CROSSINGS_TO_TRY`) was hit, so a connecting ContractLink ranked beyond
* the cap may have been skipped. A consumer should treat this as "unknown",
* not "no path exists".
*/
truncated?: boolean;
notes: string[];
suggestion?: string;
}
export interface GroupTraceAmbiguousResult {
export interface GroupTraceAmbiguousResult extends GroupTraceCompleteness {
status: 'ambiguous';
group: string;
role: 'from' | 'to';
@ -187,6 +221,55 @@ export const TRACE_NOTES = {
'The candidates are listed; trace from the exact calling function or pass `to_uid`.',
} as const;
/**
* Fold this bridge's completeness into the runtime-truncation flag a trace call
* site already computed, and answer in the shared vocabulary.
*
* Precedence mirrors `runGroupImpact`: a runtime limit wins the reason, because
* it is the cause the caller can act on (narrow the query, raise maxDepth),
* while `'incomplete-sync'` needs a different remedy — `gitnexus group sync` —
* and would otherwise mask it.
*
* Returns `{}` — not `{ truncated: false }` — when the answer is complete, so a
* clean trace result keeps the exact shape it has always had.
*/
function traceCompleteness(
bridge: CrossRepoCompleteness,
runtimeTruncated: boolean,
): GroupTraceCompleteness {
const repos = bridge.incompleteRepos.length > 0 ? { truncatedRepos: bridge.incompleteRepos } : {};
// Through `truncationFields`, not hand-written: `riskEpistemic` must follow
// `truncated` mechanically, and a third writer of that pair is how the
// invariant drifts (#2787). The bridge branch re-spreads the helper's own
// output rather than naming its fields.
if (runtimeTruncated) return { ...truncationFields(true, 'partial'), ...repos };
if (!bridge.truncated) return {};
const { incompleteRepos: _incompleteRepos, ...fields } = bridge;
return { ...fields, ...repos };
}
/**
* The trace's declared scope for `crossRepoCompleteness`.
*
* A symbol-to-symbol trace asks about exactly two repos, so an unreadable third
* member cannot make its answer a floor. A DESTINATION trace declares no `to`
* at all — the call may land in any member — so every repo is in scope there,
* which is why the predicate is built per call site rather than derived from
* the endpoints inside the helper.
*/
function bridgeCompletenessFor(
meta: BridgeMeta,
inScope: (repoPath: string) => boolean,
): CrossRepoCompleteness {
return crossRepoCompleteness({
unreadableRepos: meta.unreadableRepos,
missingRepos: meta.missingRepos,
suppressedMatchStages: meta.suppressedMatchStages,
provenanceUnknown: bridgeProvenanceUnknown(meta),
inScope,
});
}
/** Repo-relative path equality, tolerant of a leading "./" / "/" or a repo prefix. */
function sameFile(a: string, b: string): boolean {
if (!a || !b) return false;
@ -873,6 +956,23 @@ async function stitchCrossRepo(
if (p.pdg) notes.push(TRACE_NOTES.pdgRequested);
try {
// Inside the `try`, like `runGroupImpact`'s equivalent: the lease taken by
// `ensureBridgeReady` is released by this block's `finally` and nowhere
// else, so anything computed between the lease and the `try` is work whose
// every throw would strand a refcount the cached handle never gets back.
//
// Declared scope = the two endpoint repos. Whether either of them is a repo
// this bridge could not read decides whether "no ContractLink connects
// them" is a verdict or a floor.
const bridge = bridgeCompletenessFor(
bridgePrep.meta,
// `repoInSubgroup(..., exact)` rather than `===`: it normalizes separators
// and strips trailing slashes, which bare equality does not, so the same
// group.yaml spelling cannot be in scope for impact and out of scope here.
(repoPath) =>
repoInSubgroup(repoPath, fromEp.member.repoPath, true) ||
repoInSubgroup(repoPath, toEp.member.repoPath, true),
);
const { crossings, truncated: crossingsTruncated } = await listCrossingsBetween(
handle,
fromEp.member.repoPath,
@ -883,6 +983,10 @@ async function stitchCrossRepo(
return {
status: 'not_found',
group: p.name,
// No crossings at all is exactly the answer a bridge that never held an
// endpoint's repo produces, so it is the one that most needs the floor
// marker. (Nothing was capped: there were zero rows to cap.)
...traceCompleteness(bridge, false),
notes,
suggestion:
'The endpoints live in different repos with no ContractLink between them. ' +
@ -1016,6 +1120,13 @@ async function stitchCrossRepo(
hopCount: edges.length,
hops: [...hopsA, ...hopsB],
edges,
// A found path is still an answer from this bridge: if its provenance is
// unknown, or an endpoint's repo never made it in, the path may be stale
// and it is certainly not the only one. An incompleteness channel that
// fires only on the empty answer teaches an agent that a non-empty one
// is always complete. The crossing cap is NOT folded in here — a path
// that connected is not a capped search — so this site passes `false`.
...traceCompleteness(bridge, false),
notes,
...(dataFlow.length > 0 ? { dataFlow } : {}),
};
@ -1028,7 +1139,7 @@ async function stitchCrossRepo(
return {
status: 'not_found',
group: p.name,
...(crossingsTruncated ? { truncated: true } : {}),
...traceCompleteness(bridge, crossingsTruncated),
notes,
suggestion: crossingsTruncated
? `No connecting crossing among the ${MAX_CROSSINGS_TO_TRY} highest-confidence ` +
@ -1099,6 +1210,12 @@ async function stitchToDestination(
if (p.crossDepthClamped) notes.push(TRACE_NOTES.crossDepthClamped);
try {
// Inside the `try` for the lease reason above `stitchCrossRepo`'s copy. A
// destination trace declares NO `to`: the call may land in any member, so
// every repo is in the query's scope and no incomplete one can be filtered
// out. An unreadable provider repo is precisely how "no outgoing
// ContractLink leaves this repo" becomes a wrong answer, not an empty one.
const bridge = bridgeCompletenessFor(bridgePrep.meta, () => true);
const { crossings, truncated } = await listCrossingsFrom(handle, fromEp.member.repoPath);
if (crossings.length === 0) {
notes.push(TRACE_NOTES.destinationNoLink);
@ -1107,6 +1224,8 @@ async function stitchToDestination(
group: p.name,
role: 'to',
query: p.from_uid ?? p.from,
// Zero rows to cap, so only the bridge's own completeness can speak.
...traceCompleteness(bridge, false),
notes,
suggestion: 'Pass a `to` symbol for a symbol-to-symbol trace, or run group_sync.',
};
@ -1224,7 +1343,9 @@ async function stitchToDestination(
hopCount: edgesA.length + 1,
hops: [...hopsA, providerHop],
edges: [...edgesA, boundaryEdge],
...(truncated ? { truncated: true } : {}),
// The cap already marked this result; the bridge's completeness folds
// into the same fields rather than beside them.
...traceCompleteness(bridge, truncated),
notes: resultNotes,
};
};
@ -1240,6 +1361,8 @@ async function stitchToDestination(
group: p.name,
role: 'to',
candidates: candidatesFrom(precise),
// The candidate LIST is what an incomplete bridge shortens here.
...traceCompleteness(bridge, truncated),
notes: [...notes, TRACE_NOTES.destinationMultiple],
};
}
@ -1255,6 +1378,7 @@ async function stitchToDestination(
group: p.name,
role: 'to',
candidates: candidatesFrom(fileLevel),
...traceCompleteness(bridge, truncated),
notes: [...notes, TRACE_NOTES.destinationAmbiguousFile],
};
}
@ -1265,7 +1389,7 @@ async function stitchToDestination(
group: p.name,
role: 'to',
query: p.from_uid ?? p.from,
...(truncated ? { truncated: true } : {}),
...traceCompleteness(bridge, truncated),
notes,
suggestion: 'Trace from the function that issues the HTTP request, or pass a `to` symbol.',
};

View file

@ -18,6 +18,19 @@ import {
joinPath,
type SharedSpringType,
} from '../../../ingestion/route-extractors/spring-shared.js';
import {
buildKotlinConstantIndex,
extractKotlinModuleConstants,
foldKotlinOperands,
isKotlinConstantFile,
overlayKotlinConstantIndex,
parseKotlinConstOperands,
unfoldableDeclarationsOf,
unquoteKotlinIdentifier,
type KotlinConstantIndex,
type ModuleConstants,
type RepoConstants,
} from '../../../ingestion/route-extractors/kotlin-const-resolver.js';
import {
REST_TEMPLATE_TO_HTTP,
WEB_CLIENT_SHORT_TO_HTTP,
@ -42,6 +55,24 @@ import {
* named annotation arguments (`@GetMapping(value = "/x")` and
* `@GetMapping(path = "/x")`) are supported.
*
* A method path that is a CONSTANT rather than a literal —
* `@GetMapping(ApiPaths.ORDERS)`, `@PostMapping(value = ApiPaths.BASE + "/create")` —
* is folded against a repo-wide Kotlin constant map built once per `extract()`
* run by `prepareRepo`, mirroring what the Java plugin does for the same shape
* in `java.ts`. An unresolvable fold skips the route (never a guessed path), and
* a class prefix that resolves to NO literal at all suppresses every method
* route under that class — the rule `java.ts` applies too, because emitting
* those routes unprefixed would publish paths the application does not serve.
* A prefix that resolves only PARTLY (Kotlin's vararg spelling
* `@RequestMapping("/lit", ApiPaths.BASE)`) still publishes its resolvable arm:
* suppression exists to avoid wrong routes, not to discard right ones. An EMPTY
* path array (`@RequestMapping(arrayOf())`) is not a prefix at all and
* suppresses nothing — see `classifyPathArgument`. On a
* `@FeignClient` the same rule is applied to whichever prefix GOVERNS, in the
* "path wins" order the URL is assembled in — `@FeignClient(path)` first, then
* the interface's `@RequestMapping` — and to both consumer lanes, `@(Get|...)Mapping`
* and `@RequestLine`.
*
* **Consumers** — four call-site patterns common in Kotlin
* Spring projects:
*
@ -131,6 +162,180 @@ const arrayOfArg = (cap: string): string => `(call_expression
(simple_identifier) @arrayOf (#eq? @arrayOf "arrayOf")
(call_suffix (value_arguments (value_argument (string_literal) ${cap}))))`;
/**
* Expression node types a METHOD route path can be FOLDED from. A
* `string_literal` is deliberately absent: literal paths are already captured by
* the dedicated literal patterns, so admitting one here would emit the same
* route twice.
*
* This is an allow-list on purpose, and only safe because it gates FOLDING: a
* shape missing from it yields no route, which is the skip floor. The
* unfoldable-CLASS-PREFIX analysis must not be written this way — there a shape
* missing from the list means "emit unprefixed", a wrong route — so it inverts
* the test instead (see `classifyPathArgument`).
*/
const FOLDABLE_PATH_EXPRESSIONS: ReadonlySet<string> = new Set([
'simple_identifier',
'navigation_expression',
'additive_expression',
]);
/**
* Repo-relative path in the POSIX form the Kotlin constant map is keyed by.
*
* The orchestrator's file list comes from glob v13, which has no `posix: true`
* option and joins with the platform separator, so on Windows `prepareRepo`
* receives `src\main\kotlin\com\example\ApiPaths.kt` and `scan` receives the
* same for `fileRel`. `resolveKotlinImport` turns an import specifier into
* `com/example/ApiPaths.kt` and asks whether a key ENDS WITH it — a test no
* backslashed key can pass. Left unnormalized, every cross-file constant fold
* returns null on Windows and on Windows only: the pre-pass still runs, the
* context is still built, and the feature is simply, silently absent. The unit
* fixtures build POSIX keys by hand, so CI cannot see it.
*
* Normalizing at this boundary — write side (the map keys below) and read side
* (`fileRel`) — is the same fix `node.ts` (`normalizeRel`) and `python.ts`
* (`fileShortKey` / `fileLongKey`) already apply for the same reason, and it is
* the only coherent place: the resolver returns the key it matched, so
* normalizing inside it would hand back a value that misses in a map nobody
* normalized. `readFile` still receives the ORIGINAL `rel`, since the filesystem
* wants the platform's own spelling.
*/
function normalizeRel(rel: string): string {
return rel.replace(/\\/g, '/').replace(/^\.\//, '');
}
/**
* The path expression carried by one route-annotation argument, or null when the
* argument does not designate a path.
*
* tree-sitter-kotlin gives positional and named arguments the same
* `value_argument` node, distinguished only by a leading `simple_identifier` and
* an `=` token — so the key must be read here rather than constrained in the
* query. Non-route keys (`produces`, `consumes`, `headers`, …) return null,
* matching the `#match? @key "^(path|value)$"` guard the literal patterns use.
*/
function kotlinRouteArgumentExpression(arg: Parser.SyntaxNode): Parser.SyntaxNode | null {
const first = arg.namedChild(0);
if (!first) return null;
if (!arg.children.some((c) => c.type === '=')) return first; // positional
if (first.type !== 'simple_identifier') return null;
if (first.text !== 'path' && first.text !== 'value') return null;
return arg.namedChild(1);
}
/**
* The `path = …` expression of one `@FeignClient` argument, or null.
*
* Deliberately narrower than {@link kotlinRouteArgumentExpression}: on a Feign
* client the positional argument and `value =` name a SERVICE, not a path, so
* only the explicit `path` key contributes a URL prefix. This mirrors the
* `#eq? @key "path"` guard the literal `@FeignClient` patterns use, and the
* `keyNode.text !== 'path'` guard `java.ts` applies to the same annotation.
*/
function kotlinFeignPathArgumentExpression(arg: Parser.SyntaxNode): Parser.SyntaxNode | null {
const first = arg.namedChild(0);
if (!first || first.type !== 'simple_identifier') return null;
if (!arg.children.some((c) => c.type === '=')) return null;
if (first.text !== 'path') return null;
return arg.namedChild(1);
}
/**
* Is `node` a string literal whose value is fully known at parse time — that is,
* a literal carrying no interpolation?
*
* tree-sitter-kotlin models `"$base/x"` and `"${base}/x"` as a `string_literal`
* whose named children INTERLEAVE `string_content` runs with interpolation nodes
* — `interpolation_identifier_start`/`interpolated_identifier` for the `$name`
* form, `interpolation_expression_start`/`interpolated_expression`/
* `interpolation_expression_end` for `${…}` — so the test has to be `every`, not
* `some`: `"pre${A.B}post"` carries `string_content` too. The route layer
* unquotes the RAW TEXT, so treating one as a literal publishes the source
* spelling — `/${ApiPaths.BASE}/orders` — as though the application served it.
* Escape sequences are NOT separate nodes in this grammar (`"/a\nb"` is one
* `string_content`), so this accepts exactly what it accepted before; a future
* grammar that split them would floor to "unknown" rather than to a de-escaped
* guess. Same test the constant resolver's `stringLiteralValue` applies, so a
* path is either literal on both sides or folded on neither.
*/
function isPlainStringLiteral(node: Parser.SyntaxNode): boolean {
if (node.type !== 'string_literal') return false;
return node.namedChildren.every((child) => child.type === 'string_content');
}
/**
* Element expressions of a Kotlin `arrayOf(...)` call, or null when `node` is
* not one. The JS mirror of the {@link arrayOfArg} query fragment, so the
* unfoldable-prefix analysis inspects exactly the elements the literal prefix
* patterns harvest.
*/
function kotlinArrayOfElements(node: Parser.SyntaxNode): Parser.SyntaxNode[] | null {
if (node.type !== 'call_expression') return null;
const callee = node.namedChild(0);
if (callee?.type !== 'simple_identifier' || callee.text !== 'arrayOf') return null;
const suffix = node.namedChildren.find((c) => c.type === 'call_suffix');
const args = suffix?.namedChildren.find((c) => c.type === 'value_arguments');
if (!args) return null;
return args.namedChildren
.filter((c) => c.type === 'value_argument')
.map((c) => c.namedChild(0))
.filter((c): c is Parser.SyntaxNode => c !== null);
}
/**
* What a route-annotation path argument says about the prefix it designates.
* Only `'unresolvable'` may suppress a route:
*
* - `'literal'` — at least one element is a plain literal, already harvested by
* the literal prefix patterns, so there is nothing to suppress.
* - `'none'` — no prefix. Empty `arrayOf()` or `[]` is Spring's "map at the root".
* Kept distinct from `'unresolvable'` because conflating them suppressed even
* plain literal routes below such a class, which no constant fold was ever
* involved in. tree-sitter-kotlin (fwcd) represents empty `[]` with a
* zero-width recovery child; filtering it is required for route interfaces,
* which do parse as `class_declaration`.
* - `'unresolvable'` — a non-empty argument with no literal element
* (`ApiPaths.BASE`, `buildPath()`, a template). Served path is unknowable.
*/
type PathArgumentPrefix = 'literal' | 'none' | 'unresolvable';
function classifyPathArgument(expr: Parser.SyntaxNode): PathArgumentPrefix {
if (isPlainStringLiteral(expr)) return 'literal';
if (expr.type === 'collection_literal') {
const elements = expr.namedChildren.filter((child) => child.text.length > 0);
if (elements.length === 0) return 'none';
return elements.some(isPlainStringLiteral) ? 'literal' : 'unresolvable';
}
const elements = kotlinArrayOfElements(expr);
if (elements) {
if (elements.length === 0) return 'none';
return elements.some(isPlainStringLiteral) ? 'literal' : 'unresolvable';
}
return 'unresolvable';
}
/**
* Type declarations enclosing `node`, innermost first, by qualified type path.
*
* The scope a bare constant in a route annotation is resolved against; passed to
* `foldKotlinOperands`, which applies it. Collects `class_declaration` (including
* interfaces) and `object_declaration`. A `companion_object` adds no link of
* its own — members are keyed under the enclosing class one hop up. For a node
* inside `Outer.Inner`, returns `['Outer.Inner', 'Outer']`, matching the keys
* produced by `extractKotlinModuleConstants`. Skips unnamed types rather than
* guessing.
*/
function kotlinEnclosingTypeNames(node: Parser.SyntaxNode): string[] {
const simpleNames: string[] = [];
for (let cur = node.parent; cur; cur = cur.parent) {
if (cur.type !== 'class_declaration' && cur.type !== 'object_declaration') continue;
const ident = cur.children.find((c) => c.type === 'type_identifier');
if (ident) simpleNames.push(unquoteKotlinIdentifier(ident.text));
}
return simpleNames.map((_, index) => simpleNames.slice(index).reverse().join('.'));
}
// ─── Kotlin OkHttp builder verb-walk (parity with java-static-path.ts) ──
// Mirrors `inferOkHttpMethod`, adapted to the Kotlin grammar: a call `X.name(args)`
// is a `call_expression` whose callee is a `navigation_expression` (receiver +
@ -399,6 +604,151 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
],
} satisfies LanguagePatterns<Record<string, never>>);
// ─── Provider: constant-valued @RequestMapping / @(Get|...)Mapping ────
// The literal patterns above pin the path node itself (`(string_literal) @path`),
// which structurally cannot match `@GetMapping(ApiPaths.ORDERS)`. These two
// capture the whole `value_argument` instead and let
// `kotlinRouteArgumentExpression` sort out positional vs `path =`/`value =`
// in JS — a query-level split is not available here, because tree-sitter-kotlin
// uses one `value_argument` node for both forms and 0.21.x has no negation to
// test the `=` token with.
//
// These deliberately match LITERAL arguments too (any `value_argument` does).
// The method-route loop drops those via `FOLDABLE_PATH_EXPRESSIONS` so a
// literal route is emitted once, by the literal patterns; the class-prefix
// collector instead KEEPS them and tests them for literalness, which is how a
// prefix that no literal pattern could resolve gets noticed at all.
const SPRING_CONST_CLASS_PREFIX_PATTERNS = compilePatterns({
name: 'kotlin-spring-const-class-prefix',
language,
patterns: [
{
meta: {},
query: `
(class_declaration
(modifiers
(annotation
(constructor_invocation
(user_type (type_identifier) @ann (#eq? @ann "RequestMapping"))
(value_arguments (value_argument) @arg))))
(type_identifier) @cls) @class
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
const SPRING_CONST_METHOD_ROUTE_PATTERNS = compilePatterns({
name: 'kotlin-spring-const-method-route',
language,
patterns: [
{
meta: {},
query: `
(function_declaration
(modifiers
(annotation
(constructor_invocation
(user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$"))
(value_arguments (value_argument) @arg))))
(simple_identifier) @method_name) @method
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
const SPRING_CONST_FEIGN_PATH_PATTERNS = compilePatterns({
name: 'kotlin-spring-const-feign-path',
language,
patterns: [
{
meta: {},
query: `
(class_declaration
(modifiers
(annotation
(constructor_invocation
(user_type (type_identifier) @ann (#eq? @ann "FeignClient"))
(value_arguments (value_argument) @arg))))) @class
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
/**
* Ids of classes whose `@RequestMapping` prefix cannot be resolved to any
* literal, so no route under them can be published at a path the application
* actually serves.
*
* The predicate is INVERTED rather than an allow-list of non-literal node
* types: a class is marked unless its `path`/`value` argument is provably
* literal (recursing into `[…]` and `arrayOf(…)` elements, and refusing an
* interpolated `string_literal`). An allow-list has to enumerate every
* non-literal spelling and silently passes the ones it forgot —
* `[ApiPaths.BASE]`, `arrayOf(ApiPaths.BASE)`, `buildPath()`,
* `if (…) "/a" else "/b"` — each of which then publishes its methods at their
* UNPREFIXED path, a route the application does not serve. `java.ts` gates on
* the ABSENCE of a literal (`if (!valueNode)`) for the same reason.
*
* `resolvedPrefixes` is the literal prefix map built by the pass ABOVE, and a
* class holding an entry there is deliberately NOT marked: Kotlin's vararg
* spelling `@RequestMapping("/lit", ApiPaths.BASE)` leaves a resolvable `/lit`
* behind, and suppressing it would drop a route that IS derivable — trading a
* wrong route for a missing one, which is not the bargain this suppression
* exists to make. The prefix set is then partial (the constant arm is absent)
* exactly as it was before constant folding existed.
*
* The prefix is never folded here: it also feeds the cross-file
* interface-inheritance pass, which has no repo context, so folding it in
* `scan` alone would make the two views disagree. Same rule `java.ts` applies
* (`typesWithUnfoldablePrefix`); folding class prefixes cross-file is a
* follow-up on both sides. Used by BOTH `scan` and the inheritance-view
* collector — with the prefix map each has already built — so the two cannot
* drift apart.
*/
const collectUnfoldablePrefixClassIds = (
tree: Parser.Tree,
resolvedPrefixes: ReadonlyMap<number, string[]>,
): Set<number> => {
const ids = new Set<number>();
for (const match of runCompiledPatterns(SPRING_CONST_CLASS_PREFIX_PATTERNS, tree)) {
const argNode = match.captures.arg;
const classNode = match.captures.class;
if (!argNode || !classNode) continue;
if ((resolvedPrefixes.get(classNode.id) ?? []).length > 0) continue;
const expr = kotlinRouteArgumentExpression(argNode);
if (!expr || classifyPathArgument(expr) !== 'unresolvable') continue;
ids.add(classNode.id);
}
return ids;
};
/**
* Ids of `@FeignClient` interfaces whose `path` argument is present but not
* resolvable to a literal.
*
* `collectUnfoldablePrefixClassIds` cannot see these: it matches
* `@RequestMapping` only, so `@FeignClient(path = ApiPaths.BASE)` fell through
* to the `['']` prefix fallback and published the consumer at its unprefixed
* path — a call the service never makes. Kept as its own set rather than
* merged into the `@RequestMapping` one because `path` OUTRANKS
* `@RequestMapping` on a Feign client: an unresolvable `path` is fatal
* whatever the `@RequestMapping` says, and a resolvable `path` rescues a route
* whose `@RequestMapping` is a constant. The consumer lanes therefore consult
* the two in that same "path wins" order.
*/
const collectFeignUnfoldablePathClassIds = (tree: Parser.Tree): Set<number> => {
const ids = new Set<number>();
for (const match of runCompiledPatterns(SPRING_CONST_FEIGN_PATH_PATTERNS, tree)) {
const argNode = match.captures.arg;
const classNode = match.captures.class;
if (!argNode || !classNode) continue;
const expr = kotlinFeignPathArgumentExpression(argNode);
if (!expr || classifyPathArgument(expr) !== 'unresolvable') continue;
ids.add(classNode.id);
}
return ids;
};
// ─── Consumer: Spring RestTemplate ────────────────────────────────────
// Kotlin call-site shape mirrors the Java plugin's
// `REST_TEMPLATE_PATTERNS`, but goes through tree-sitter-kotlin's
@ -875,11 +1225,24 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
const prefixNode = match.captures.prefix;
const classNode = match.captures.class;
if (!prefixNode || !classNode) continue;
// An INTERPOLATED literal (`"${ApiPaths.BASE}"`) is not a path — unquoting
// its raw text would carry the source spelling into the shared type view
// as a served prefix. Refusing it here is also what lets the unfoldable
// analysis below mark such a class (it skips classes with a resolved
// prefix), so the two stay one decision rather than two.
if (!isPlainStringLiteral(prefixNode)) continue;
const prefix = unquoteLiteral(prefixNode.text);
if (prefix !== null) pushPrefix(prefixByClassId, classNode.id, prefix);
}
// Method @(Get|...)Mapping routes keyed by the function_declaration node id.
//
// Only LITERAL paths land here. A constant-valued path is folded in `scan`
// against the repo constant map, which this inheritance-view collector has
// no access to; publishing it as an empty path would put `POST /`-shaped
// noise into the shared type view, so it is left out — the same skip floor
// `java.ts`'s `collectSpringTypes` keeps.
const routesByMethodId = new Map<number, Array<{ method: string; path: string }>>();
const unfoldablePrefixClassIds = collectUnfoldablePrefixClassIds(tree, prefixByClassId);
for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) {
const annNode = match.captures.ann;
const pathNode = match.captures.path;
@ -889,6 +1252,10 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
if (!httpMethod) continue;
const rawPath = unquoteLiteral(pathNode.text);
if (rawPath === null) continue;
// A constant class prefix leaves no single prefix string for the
// inheritance view to carry, so this route would be published unprefixed.
const owner = findEnclosingClass(methodNode);
if (owner && unfoldablePrefixClassIds.has(owner.id)) continue;
const arr = routesByMethodId.get(methodNode.id) ?? [];
arr.push({ method: httpMethod, path: rawPath });
routesByMethodId.set(methodNode.id, arr);
@ -931,8 +1298,90 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
return {
name: 'kotlin-http',
language,
scan(tree) {
prepareRepo(args) {
// Build the repo-wide Kotlin string-constant map and import index once per
// extract() run. The orchestrator hands over a bare Parser with no language
// bound; bind Kotlin explicitly or `parseSource` spins to its whole time
// budget on every file.
try {
args.parser.setLanguage(language);
} catch {
// A parser that rejects binding cannot produce a constant map; the
// per-file try/catch below then skips everything harmlessly.
}
const constants = new Map<string, ModuleConstants>();
for (const rel of args.files) {
if (!rel.endsWith('.kt') && !rel.endsWith('.kts')) continue;
try {
const src = args.readFile(rel);
// Cheap content gate: only constant-DEFINITION candidates are parsed
// here. Import-only files (every controller) are deliberately NOT
// parsed in this pass — `scan` extracts the importing file's own
// import table from the tree it already holds, on demand, for the
// rare file that actually references a constant. A gate that also
// matched `import …` would parse the entire repository here.
if (!src || !isKotlinConstantFile(src)) continue;
const tree = args.parseSource(args.parser, src);
if (!tree) continue;
const mc = extractKotlinModuleConstants(tree);
if (
mc.literals.size > 0 ||
mc.exprs.size > 0 ||
mc.imports.size > 0 ||
unfoldableDeclarationsOf(mc).size > 0
) {
// POSIX key (see `normalizeRel`); `readFile` above got the raw `rel`.
constants.set(normalizeRel(rel), mc);
}
} catch {
// Per-file resilience: one unreadable/oversized/ill-formed file must
// not forfeit the whole repo's constant map.
continue;
}
}
return { constants, index: buildKotlinConstantIndex(constants) };
},
scan(tree, repoContext, fileRel) {
const out: HttpDetection[] = [];
const kotlinCtx = repoContext as
| { constants: RepoConstants; index: KotlinConstantIndex }
| undefined;
// Read side of the POSIX keying (see `normalizeRel`): the map `prepareRepo`
// built is keyed by normalized path, so every lookup and every fold entry
// point below uses `fileKey`, never the raw `fileRel`.
const fileKey = fileRel === undefined ? undefined : normalizeRel(fileRel);
// Lazy per-file constants/index view. `prepareRepo` only indexes constant-
// DEFINING files, so an importing controller is absent from that map. When
// a route references a constant, extract THIS file's import table from the
// tree `scan` already holds and overlay it. Import-only overlays reuse the
// prepared package projections; files whose routes are all literal never
// pay this cost.
let foldIndex: KotlinConstantIndex | undefined;
const getFoldIndex = (): KotlinConstantIndex | undefined => {
if (foldIndex !== undefined) return foldIndex;
foldIndex = kotlinCtx?.index;
if (!kotlinCtx || !fileKey) return foldIndex;
if (kotlinCtx.constants.has(fileKey)) return foldIndex;
try {
const mc = extractKotlinModuleConstants(tree);
// Same admission test the pre-pass applies above. Keeping the complete
// test here also makes this overlay correct if a future gate safely
// excludes another declaration shape.
if (
mc.literals.size > 0 ||
mc.exprs.size > 0 ||
mc.imports.size > 0 ||
unfoldableDeclarationsOf(mc).size > 0
) {
foldIndex = overlayKotlinConstantIndex(kotlinCtx.index, fileKey, mc);
}
} catch {
// fold falls back to the repo-wide map (imports stay unresolved)
}
return foldIndex;
};
// ─── Class prefixes ─────────────────────────────────────────────
const prefixByClassId = new Map<number, string[]>();
@ -940,10 +1389,17 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
const prefixNode = match.captures.prefix;
const classNode = match.captures.class;
if (!prefixNode || !classNode) continue;
// An INTERPOLATED literal (`"${ApiPaths.BASE}"`) is not a path — see
// `isPlainStringLiteral`. Refusing it here also lets the unfoldable
// analysis below mark such a class, since that skips classes whose
// prefix already resolved.
if (!isPlainStringLiteral(prefixNode)) continue;
const prefix = unquoteLiteral(prefixNode.text);
if (prefix !== null) pushPrefix(prefixByClassId, classNode.id, prefix);
}
const classesWithUnfoldablePrefix = collectUnfoldablePrefixClassIds(tree, prefixByClassId);
// ─── OpenFeign client interfaces + HTTP Interface type prefixes ──
// In tree-sitter-kotlin an `interface` is a `class_declaration`, so a
// `@FeignClient` interface's @(Get|...)Mapping methods would otherwise be
@ -956,11 +1412,12 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
if (!classNode) continue;
feignClassIds.add(classNode.id);
const prefixNode = match.captures.prefix;
if (prefixNode) {
if (prefixNode && isPlainStringLiteral(prefixNode)) {
const prefix = unquoteLiteral(prefixNode.text);
if (prefix !== null) pushPrefix(feignPrefixByClassId, classNode.id, prefix);
}
}
const feignClassesWithUnfoldablePath = collectFeignUnfoldablePathClassIds(tree);
const httpExchangePrefixByClassId = new Map<number, string[]>();
for (const match of runCompiledPatterns(SPRING_HTTP_EXCHANGE_CLASS_PATTERNS, tree)) {
const classNode = match.captures.class;
@ -971,24 +1428,91 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
}
// ─── Method routes (Spring providers) + OpenFeign consumers ─────
// Literal and constant-valued paths are normalized into one candidate list
// so both reach the same Feign/interface/prefix classification below.
const methodRoutes: Array<{
httpMethod: string;
rawPath: string;
nameNode: Parser.SyntaxNode | undefined;
methodNode: Parser.SyntaxNode;
}> = [];
for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) {
const annNode = match.captures.ann;
const pathNode = match.captures.path;
const nameNode = match.captures.method_name;
const methodNode = match.captures.method;
if (!annNode || !pathNode || !methodNode) continue;
const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text];
if (!httpMethod) continue;
const rawPath = unquoteLiteral(pathNode.text);
if (rawPath === null) continue;
methodRoutes.push({
httpMethod,
rawPath,
nameNode: match.captures.method_name,
methodNode,
});
}
for (const match of runCompiledPatterns(SPRING_CONST_METHOD_ROUTE_PATTERNS, tree)) {
const annNode = match.captures.ann;
const argNode = match.captures.arg;
const methodNode = match.captures.method;
if (!annNode || !argNode || !methodNode) continue;
const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text];
if (!httpMethod) continue;
const expr = kotlinRouteArgumentExpression(argNode);
if (!expr || !FOLDABLE_PATH_EXPRESSIONS.has(expr.type)) continue;
// No repo context (context-less fallback scanning) means no constant map
// and therefore no honest answer — skip rather than guess a path.
if (!fileKey) continue;
const index = getFoldIndex();
if (!index) continue;
const operands = parseKotlinConstOperands(expr);
if (operands === null) continue;
// A bare reference means whatever the ENCLOSING types bind it to before
// it means anything at file level — Kotlin's rule for a companion
// member, which is in scope unqualified only inside its own class body.
const rawPath = foldKotlinOperands(
fileKey,
operands,
index.repo,
kotlinEnclosingTypeNames(methodNode),
index,
);
if (rawPath === null) continue;
methodRoutes.push({
httpMethod,
rawPath,
nameNode: match.captures.method_name,
methodNode,
});
}
for (const { httpMethod, rawPath, nameNode, methodNode } of methodRoutes) {
const enclosingClass = findEnclosingClass(methodNode);
// A @(Get|...)Mapping inside a @FeignClient interface is an OpenFeign
// consumer (a remote call), not a route this service serves.
if (enclosingClass && feignClassIds.has(enclosingClass.id)) {
// Whichever prefix GOVERNS must be resolvable, or the remote URL is
// unknowable and an unprefixed consumer would be a call this service
// never makes. Checked in the same "path wins" order the fallback
// below resolves in, so an unresolvable `@RequestMapping` does not
// suppress a client whose literal `@FeignClient(path)` outranks it,
// and an unresolvable `path` is fatal even when `@RequestMapping` is
// a literal.
//
// This reaches a Feign INTERFACE at all because tree-sitter-kotlin
// models `interface` as a `class_declaration`, and it should: Spring
// Cloud prepends the governing prefix to every method of the client.
// Java diverges only by accident of its grammar — `findEnclosingClass`
// skips `interface_declaration`, so `java.ts` still emits such a
// consumer at its unprefixed path. Aligning Java is a change to Java's
// behavior and belongs in its own follow-up, not in the Kotlin binding.
if (feignClassesWithUnfoldablePath.has(enclosingClass.id)) continue;
const feignPrefixes = feignPrefixByClassId.get(enclosingClass.id);
if (!feignPrefixes && classesWithUnfoldablePrefix.has(enclosingClass.id)) continue;
// @FeignClient(path) wins over @RequestMapping; a multi-element prefix
// yields one consumer per (prefix × this route).
const prefixes = feignPrefixByClassId.get(enclosingClass.id) ??
prefixByClassId.get(enclosingClass.id) ?? [''];
const prefixes = feignPrefixes ?? prefixByClassId.get(enclosingClass.id) ?? [''];
for (const prefix of prefixes) {
out.push({
role: 'consumer',
@ -1002,6 +1526,10 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
}
continue;
}
// An unresolvable class prefix leaves no path this service serves, so
// every route under such a class is dropped rather than emitted at a
// wrong (unprefixed) one — the rule `java.ts` applies for Java.
if (enclosingClass && classesWithUnfoldablePrefix.has(enclosingClass.id)) continue;
// A @(Get|...)Mapping on a (non-Feign) interface declares a route
// *contract*, not a route this service serves — the implementing
// @RestController is the provider, emitted via scanProject's interface
@ -1171,13 +1699,21 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
if (!parsed) continue;
const enclosingClass = findEnclosingClass(methodNode);
if (!enclosingClass || !isKotlinInterface(enclosingClass)) continue;
// The same governing-prefix resolvability guard the @(Get|...)Mapping-in-Feign
// lane applies, in the same "path wins" order — this loop resolves through
// the identical fallback chain, so an unresolvable governing prefix leaves
// the remote URL just as unknowable here. Without it a single interface
// could suppress its @(Get|...)Mapping routes and publish its @RequestLine
// routes under the very same unresolvable prefix.
if (feignClassesWithUnfoldablePath.has(enclosingClass.id)) continue;
const feignPrefixes = feignPrefixByClassId.get(enclosingClass.id);
if (!feignPrefixes && classesWithUnfoldablePrefix.has(enclosingClass.id)) continue;
// Mirror java.ts (which pre-merges the @RequestMapping fallback into
// feignPrefixByInterfaceId, "path wins"): @FeignClient(path) wins, else
// the interface's class-level @RequestMapping prefix, else none. Without
// the prefixByClassId fallback Kotlin dropped the class prefix that Java
// applies — the same fallback chain the @GetMapping-in-Feign path uses above.
const prefixes = feignPrefixByClassId.get(enclosingClass.id) ??
prefixByClassId.get(enclosingClass.id) ?? [''];
const prefixes = feignPrefixes ?? prefixByClassId.get(enclosingClass.id) ?? [''];
for (const prefix of prefixes) {
out.push({
role: 'consumer',

View file

@ -15,6 +15,8 @@ import {
DATA_ROUTE_TABLE_SOURCE,
scanDataRouteTables,
} from '../../../ingestion/route-extractors/data-route-table.js';
import { extractNestRoutes } from '../../../ingestion/route-extractors/nest.js';
import { normalizeExtractedRoutePath } from '../../../ingestion/route-extractors/route-path.js';
import {
buildJsRepoFacts,
extractJsModuleFacts,
@ -27,7 +29,8 @@ import {
/**
* Node.js / TypeScript HTTP plugin family. Handles:
* - NestJS `@Controller('prefix')` classes with `@Get(':id')` methods
* - NestJS `@Controller('prefix')` classes with `@Get(':id')` methods,
* delegated wholesale to the indexer's `extractNestRoutes`
* - Express `router.get(...)` / `app.post(...)` providers
* - `fetch(url)` / `fetch(url, { method: 'POST' })` consumers
* - `axios.get(url)` / `axios.delete(url)` consumers
@ -42,34 +45,8 @@ import {
* same `scan` function but bind to different grammars.
*/
// ─── Provider: NestJS — class-level @Controller('prefix') ────────────
// In tree-sitter-typescript decorators are NOT children of
// class_declaration / method_definition — they're siblings in the
// surrounding class_body / program node. We therefore match the
// decorator standalone and walk to its related class/method in JS.
const NEST_CONTROLLER_SPEC: PatternSpec<Record<string, never>> = {
meta: {},
query: `
(decorator
(call_expression
function: (identifier) @dec (#eq? @dec "Controller")
arguments: (arguments . [(string) (template_string)] @prefix))) @ctrl_decorator
`,
};
// ─── Provider: NestJS — method-level @Get/@Post/... decorators ───────
// Matches either `@Get('path')` or `@Get()`. The `@path` capture is
// optional — when the first argument isn't a string, the plugin falls
// back to '/' for the method-level path.
const NEST_METHOD_SPEC: PatternSpec<Record<string, never>> = {
meta: {},
query: `
(decorator
(call_expression
function: (identifier) @dec (#match? @dec "^(Get|Post|Put|Delete|Patch)$")
arguments: (arguments) @args)) @method_decorator
`,
};
// NestJS providers are not queried here at all — see the `extractNestRoutes`
// call in `scanBundle`.
// ─── Provider: Express — router.get/app.post/... ─────────────────────
const EXPRESS_SPEC: PatternSpec<Record<string, never>> = {
@ -176,8 +153,6 @@ const AXIOS_OBJECT_SPEC: PatternSpec<Record<string, never>> = {
};
interface NodePatternBundle {
controller: CompiledPatterns<Record<string, never>>;
methodDecorator: CompiledPatterns<Record<string, never>>;
express: CompiledPatterns<Record<string, never>>;
fetchNoOptions: CompiledPatterns<Record<string, never>>;
fetchWithOptions: CompiledPatterns<Record<string, never>>;
@ -195,8 +170,6 @@ function compileBundle(language: unknown, name: string): NodePatternBundle {
patterns: [spec],
} satisfies LanguagePatterns<Record<string, never>>);
return {
controller: mk(NEST_CONTROLLER_SPEC, 'nest-controller'),
methodDecorator: mk(NEST_METHOD_SPEC, 'nest-method-decorator'),
express: mk(EXPRESS_SPEC, 'express'),
fetchNoOptions: mk(FETCH_NO_OPTIONS_SPEC, 'fetch-no-options'),
fetchWithOptions: mk(FETCH_WITH_OPTIONS_SPEC, 'fetch-with-options'),
@ -211,33 +184,6 @@ const JAVASCRIPT_BUNDLE = compileBundle(JavaScript, 'javascript-http');
const TYPESCRIPT_BUNDLE = compileBundle(TypeScript.typescript, 'typescript-http');
const TSX_BUNDLE = compileBundle(TypeScript.tsx, 'tsx-http');
const NEST_DECORATOR_TO_HTTP: Record<string, string> = {
Get: 'GET',
Post: 'POST',
Put: 'PUT',
Delete: 'DELETE',
Patch: 'PATCH',
};
/**
* Find the nearest enclosing class_declaration for a node, or null.
*/
function findEnclosingClass(node: Parser.SyntaxNode): Parser.SyntaxNode | null {
let cur: Parser.SyntaxNode | null = node.parent;
while (cur) {
if (cur.type === 'class_declaration') return cur;
cur = cur.parent;
}
return null;
}
function joinPath(prefix: string, sub: string): string {
const cleanPrefix = prefix.replace(/^\/+/, '').replace(/\/+$/, '');
const cleanSub = sub.replace(/^\/+/, '');
if (!cleanPrefix) return `/${cleanSub}`;
return `/${cleanPrefix}/${cleanSub}`;
}
/**
* Walk `pair` children of an `object` literal and return the unquoted
* string/template_string value for the first pair whose key matches one
@ -260,68 +206,6 @@ function readStringProp(objectNode: Parser.SyntaxNode, keyNames: readonly string
return null;
}
/**
* For a standalone `decorator` node (child of class_body / program),
* find the related `class_declaration` node that it decorates. In
* tree-sitter-typescript the decorator is placed before the class
* declaration as a sibling (when decorating a class) or inside the
* class_body before a method_definition (when decorating a method);
* we walk the parent chain until we find the enclosing class.
*/
function findDecoratedClass(decoratorNode: Parser.SyntaxNode): Parser.SyntaxNode | null {
const parent = decoratorNode.parent;
if (!parent) return null;
// Case 1: decorator is a sibling of the class_declaration at program /
// export_statement level. Walk forward through siblings until we find
// the class_declaration this decorator belongs to.
for (let i = 0; i < parent.namedChildCount; i++) {
const child = parent.namedChild(i);
if (child && child.id === decoratorNode.id) {
for (let j = i + 1; j < parent.namedChildCount; j++) {
const next = parent.namedChild(j);
if (!next) continue;
if (next.type === 'decorator') continue; // adjacent decorators stack
if (next.type === 'class_declaration') return next;
if (next.type === 'export_statement') {
// `export class Foo { ... }` wraps the declaration.
for (let k = 0; k < next.namedChildCount; k++) {
const inner = next.namedChild(k);
if (inner?.type === 'class_declaration') return inner;
}
}
break;
}
break;
}
}
// Case 2: decorator is inside a class_body (decorating a method) —
// walk up to the enclosing class_declaration.
return findEnclosingClass(decoratorNode);
}
/**
* For a method-level decorator node (child of class_body before a
* method_definition), find the method_definition it decorates.
*/
function findDecoratedMethod(decoratorNode: Parser.SyntaxNode): Parser.SyntaxNode | null {
const parent = decoratorNode.parent;
if (!parent || parent.type !== 'class_body') return null;
for (let i = 0; i < parent.namedChildCount; i++) {
const child = parent.namedChild(i);
if (child && child.id === decoratorNode.id) {
for (let j = i + 1; j < parent.namedChildCount; j++) {
const next = parent.namedChild(j);
if (!next) continue;
if (next.type === 'decorator') continue;
if (next.type === 'method_definition') return next;
return null;
}
return null;
}
}
return null;
}
/**
* Map each named import's LOCAL binding to its DECLARED export name and source
* module, by walking the file's `import { x as y } from 'm'` statements. Lets
@ -589,57 +473,33 @@ function scanBundle(
// symbol resolves to the real definition rather than its local alias text.
const importMap = buildImportMap(tree);
// NestJS: collect `@Controller('prefix')` class decorators, keyed by
// the `class_declaration` they decorate.
const prefixByClassId = new Map<number, string>();
for (const match of runCompiledPatterns(bundle.controller, tree)) {
const prefixNode = match.captures.prefix;
const decoratorNode = match.captures.ctrl_decorator;
if (!prefixNode || !decoratorNode) continue;
const prefix = unquoteLiteral(prefixNode.text);
if (prefix === null) continue;
const classNode = findDecoratedClass(decoratorNode);
if (!classNode) continue;
prefixByClassId.set(classNode.id, prefix);
}
// NestJS: method-level @Get/@Post/... decorators. The decorator's
// arguments list may be empty (`@Get()`), a string (`@Get('path')`),
// or something else (which we skip).
for (const match of runCompiledPatterns(bundle.methodDecorator, tree)) {
const decNode = match.captures.dec;
const argsNode = match.captures.args;
const decoratorNode = match.captures.method_decorator;
if (!decNode || !argsNode || !decoratorNode) continue;
const httpMethod = NEST_DECORATOR_TO_HTTP[decNode.text];
if (!httpMethod) continue;
const methodNode = findDecoratedMethod(decoratorNode);
if (!methodNode) continue;
const enclosingClass = findEnclosingClass(methodNode);
// Only emit NestJS detections when the class actually has a
// @Controller decorator — without it, the match is almost certainly
// something else (e.g. an unrelated library using similar names).
if (!enclosingClass || !prefixByClassId.has(enclosingClass.id)) continue;
const prefix = prefixByClassId.get(enclosingClass.id) ?? '';
let rawPath = '/';
const firstArg = argsNode.namedChild(0);
if (firstArg && (firstArg.type === 'string' || firstArg.type === 'template_string')) {
const unquoted = unquoteLiteral(firstArg.text);
if (unquoted !== null) rawPath = unquoted;
}
// Get the method name from the decorated method_definition.
const methodNameNode = methodNode.childForFieldName('name');
const name = methodNameNode?.text ?? null;
// NestJS: delegated to the indexer's extractor rather than re-queried here.
// Two independent readings of the same decorators is how the layers drift:
// the local scan saw only `class_declaration` (never `abstract class`), only
// five of the nine verbs, only a positional string `@Controller('x')`, and
// — worst — INVENTED `/` for a method path it could not read, so
// `@Get(ROUTES.SEARCH)` became a `GET /venues` contract that the graph, which
// correctly drops it, has no Route node for. "A missing route is a coverage
// limit; an invented one is a lie" (ARCHITECTURE.md). Calling the extractor
// makes that divergence structurally impossible, exactly as the
// `scanDataRouteTables` call below already does for static route tables.
//
// `filePath` rides only on the returned struct and never reaches the
// `HttpDetection`, so a bare `scan(tree)` with no `fileRel` passes '' rather
// than losing the routes. `lineOffset` is 0: the group scanner parses whole
// files, so `lineNumber` is already the absolute 1-based line this
// `HttpDetection.line` wants.
for (const route of extractNestRoutes(tree, fileRel ?? '', 0)) {
out.push({
role: 'provider',
framework: 'nest',
method: httpMethod,
path: joinPath(prefix, rawPath),
name,
line: methodNode.startPosition.row + 1,
method: route.httpMethod,
// The prefix travels separately at the ingestion layer, so the join is
// ours to do — with ingestion's own joiner, so the two layers cannot
// disagree about the URL either.
path: normalizeExtractedRoutePath(route.routePath, route.prefix ?? null),
name: route.handlerName ?? null,
line: route.lineNumber,
confidence: 0.8,
});
}

View file

@ -2,7 +2,11 @@ import fs from 'node:fs/promises';
import path from 'node:path';
import type { CypherExecutor } from '../contract-extractor.js';
import type { GroupManifestLink, ContractRole } from '../types.js';
import { shouldIgnorePath, loadIgnoreRules } from '../../../config/ignore-service.js';
import {
shouldIgnorePath,
loadIgnoreRules,
isHardcodedIgnoredDirectoryAtPath,
} from '../../../config/ignore-service.js';
import { logger } from '../../logger.js';
interface PythonPackageMeta {
@ -161,9 +165,11 @@ async function findPythonFiles(repoPath: string): Promise<string[]> {
for (const entry of entries) {
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
const childPath = path.join(dir, entry.name);
if (shouldIgnorePath(childRel)) continue;
if (isHardcodedIgnoredDirectoryAtPath(repoPath, childPath)) continue;
if (ig && ig.ignores(childRel + '/')) continue;
await walk(path.join(dir, entry.name), childRel);
await walk(childPath, childRel);
} else if (entry.name.endsWith('.py')) {
if (shouldIgnorePath(childRel)) continue;
if (ig && ig.ignores(childRel)) continue;

View file

@ -0,0 +1,201 @@
/**
* Cross-process single-writer lock for one group's persisted state (R9).
*
* A group sync ends by REPLACING `contracts.json` and rebuilding `bridge.lbug`
* from a snapshot it computed minutes earlier. Two syncs of the same group that
* overlap therefore do not merge — the second one's write simply overwrites the
* first one's, and whichever finishes last wins with a registry assembled from
* repo state the other run never saw. Nothing detects it afterwards: both runs
* report success, and the group's contracts silently describe a mixture that was
* never true at any instant. This module serializes that section so one sync at
* a time can be inside it.
*
* WHERE THE LOCK LIVES. On a dedicated `sync-lock` directory INSIDE the group
* directory — mirroring `withRegistryLock`, which locks a `registry-lock`
* directory beside the registry rather than the registry's own directory
* (repo-manager.ts). {@link acquireIndexLock} is NOT reentrant and its file
* backend writes `analyze.lock` into the directory it is handed, so pointing it
* at a directory that some other code path might also lock — or that already
* holds a per-repo index slot — reintroduces exactly the collision the registry
* lock's own comment warns about. `<groupDir>/sync-lock` is a namespace nothing
* else claims: group directories live under `~/.gitnexus/groups/<name>` (or
* `$GITNEXUS_HOME`), never under a repo's `.gitnexus[/branches/<slug>]`.
*
* WHY IT FAILS CLOSED, unlike the registry lock. `withRegistryLock` degrades to
* running UNLOCKED on timeout, and that is right for it: it guards a sub-second
* JSON read/merge/write on a latency-critical path (`augment` runs on every
* editor tool call), and running unlocked is merely the pre-lock status quo. A
* group sync is the opposite on every axis — it is long, expensive, operator-
* initiated, and its lost update destroys contracts rather than a registry field.
* A sync that cannot be protected must not run at all, and there are three
* distinct ways it can fail to be protected; all three throw
* {@link GroupSyncLockError}:
*
* 1. TIMEOUT — the holder is still alive when the ceiling elapses.
* 2. LOCK-FREE DEGRADATION — `acquireIndexLock` answers a read-only or
* permission-denied filesystem with a no-op handle that is byte-identical
* to a real one at the API boundary. That is a deliberate tolerance for
* `analyze` (an unwritable index dir rejects every write anyway, so the
* lock is moot), but here it would hand back a handle that protects
* nothing while the sync went on to attempt its writes. The handle now
* carries {@link IndexLockHandle.lockFree}, so we can see it and refuse.
* 3. ANY OTHER ACQUIRE FAILURE — e.g. `sync-lock` cannot be created because a
* regular file already occupies the path. Silently proceeding on an error
* we did not anticipate is the same unprotected run under another name.
*
* WHY THE CEILING IS PASSED EXPLICITLY. The magnitude is not the point — 10
* minutes deliberately matches `acquireIndexLock`'s own default, because a group
* sync is analyze-shaped and a legitimately queued second sync must be able to
* wait out a full first one (the registry lock's 5s is sized for a sub-second
* merge and is the wrong model here). The reason to pass it is
* `resolveTimeoutMs`: it prefers an explicit argument over
* `GITNEXUS_INDEX_LOCK_TIMEOUT_MS`, and that variable's `<= 0` case resolves to
* `Number.POSITIVE_INFINITY`. Inheriting it would let an environment turn this
* lock's fail-closed timeout into an unbounded hang.
*
* ACQUIRED EXACTLY ONCE, by `syncGroup`, around its whole persist section.
* Nothing it calls beneath that point — `writeContractRegistry`,
* `refreshPreservedBridgeMeta`, `writeBridgeUnlocked` — takes this lock; a
* second acquisition would deadlock a non-reentrant primitive on the HAPPY
* path, not on some edge case. `bridge-db.ts` exports the swap in both forms
* for exactly that reason: `writeBridgeUnlocked` for the held-lock caller
* (`syncGroup`), and the `writeBridge` wrapper, which acquires here, for direct
* callers that are outside the region. The same split `repo-manager.ts` uses
* for `registerRepoUnlocked` / `registerRepo`.
*
* SCOPE CAVEAT (recorded, not solved): the default socket backend uses Linux
* abstract sockets, which are network-namespace-scoped. Two containers that
* share a bind-mounted group directory but sit in separate netns will NOT
* contend, exactly as documented for the index lock itself; forcing
* `GITNEXUS_INDEX_LOCK_BACKEND=file` is what covers that deployment.
*/
import path from 'node:path';
import {
acquireIndexLock,
IndexLockTimeoutError,
type IndexLockHandle,
} from '../../storage/index-lock.js';
import { logger } from '../logger.js';
/** Lock-directory name inside the group directory. Never the group dir itself. */
export const GROUP_SYNC_LOCK_DIRNAME = 'sync-lock';
/** The dedicated lock namespace for one group: `<groupDir>/sync-lock`. */
export const getGroupSyncLockDir = (groupDir: string): string =>
path.join(groupDir, GROUP_SYNC_LOCK_DIRNAME);
/**
* Wait ceiling for the group sync lock (10 min). See the module header: the
* magnitude matches `acquireIndexLock`'s analyze-sized default on purpose; the
* reason it is passed EXPLICITLY is to keep `GITNEXUS_INDEX_LOCK_TIMEOUT_MS`
* (whose `<= 0` case means unbounded) from turning fail-closed into a hang.
*/
export const GROUP_SYNC_LOCK_TIMEOUT_MS = 600_000;
/** Which of the three fail-closed exits produced a {@link GroupSyncLockError}. */
export type GroupSyncLockFailure = 'timeout' | 'lock-free' | 'unavailable';
/**
* A group sync could not be protected, so it did not run. One class for all
* three exits so both callers — the CLI command and the MCP service — have a
* single thing to catch and report.
*/
export class GroupSyncLockError extends Error {
readonly reason: GroupSyncLockFailure;
readonly groupDir: string;
constructor(reason: GroupSyncLockFailure, groupDir: string, message: string, cause?: unknown) {
super(message, cause === undefined ? undefined : { cause });
this.name = 'GroupSyncLockError';
this.reason = reason;
this.groupDir = groupDir;
}
}
/**
* Run `operation` as the only group sync touching `groupDir`, or throw
* {@link GroupSyncLockError} without running it at all.
*
* The lock is released in a `finally`, so it is dropped whether the operation
* succeeds or throws.
*/
export const withGroupSyncLock = async <T>(
groupDir: string,
operation: () => Promise<T>,
): Promise<T> => {
let handle: IndexLockHandle;
// The wrapper times the acquisition itself. `IndexLockTimeoutError` carries
// `holder` and `holderKnown` and nothing else — the elapsed wait exists only
// inside its inherited message string, so the figure has to be measured here
// to be reported without that message. `Date.now()` matches how the primitive
// measures its own wait.
const acquireStartedAt = Date.now();
try {
handle = await acquireIndexLock(getGroupSyncLockDir(groupDir), {
timeoutMs: GROUP_SYNC_LOCK_TIMEOUT_MS,
// `acquireIndexLock`'s own `log` texts name an "analyze" holder, which
// misattributes a group-sync wait — the same reason `withRegistryLock`
// supplies its own line instead of passing `log` through.
onWaitStart: () =>
logger.info(
{ groupDir },
'Waiting for another GitNexus process to finish syncing this group…',
),
});
} catch (err) {
// The inherited message names "another gitnexus analyze" as the holder —
// a cause this detection path cannot establish. Nothing but a group sync
// ever locks `<groupDir>/sync-lock` (see the module header), and on the
// socket backend the holder is not identifiable at all. Re-word it around
// what IS known: which group, which operation, and how long we waited.
if (err instanceof IndexLockTimeoutError) {
throw new GroupSyncLockError(
'timeout',
groupDir,
`Timed out after ${Date.now() - acquireStartedAt}ms waiting for the sync lock on ` +
`group "${path.basename(groupDir)}" (${getGroupSyncLockDir(groupDir)}). ` +
// `holderKnown` is false on the socket backend and on the file
// backend's malformed/vanished-lock timeouts, where `holder` is a
// placeholder (`pid -1`). Presenting that as a real owner would be the
// same unestablished claim in a new form.
(err.holderKnown
? `Held by pid ${err.holder.pid} on ${err.holder.hostname} ` +
`(invocation ${err.holder.invocationId}). `
: `The lock stayed held for the whole wait, but this lock backend ` +
`cannot identify the holder. `) +
`Nothing was written and this group was not synced. ` +
`Re-run once the other sync of this group has finished.`,
err,
);
}
throw new GroupSyncLockError(
'unavailable',
groupDir,
`Could not acquire the sync lock for this group (${getGroupSyncLockDir(groupDir)}): ` +
`${err instanceof Error ? err.message : String(err)}. Nothing was written.`,
err,
);
}
if (handle.lockFree) {
// A handle that owns nothing. Release it anyway (it is a no-op, but the
// contract is that every handle is released) and refuse to run: this sync
// would otherwise write `contracts.json` and `bridge.lbug` with no
// protection at all against a concurrent sync doing the same.
handle.release();
throw new GroupSyncLockError(
'lock-free',
groupDir,
`The sync lock for this group could not be created at ` +
`${getGroupSyncLockDir(groupDir)} (read-only or permission-denied filesystem), ` +
`so this sync cannot be protected against a concurrent one. Nothing was written. ` +
`Make the group directory writable and re-run.`,
undefined,
);
}
try {
return await operation();
} finally {
handle.release();
}
};

View file

@ -6,7 +6,16 @@
import fsp from 'node:fs/promises';
import path from 'node:path';
import { checkStaleness } from '../git-staleness.js';
import { loadMeta, type RepoMeta } from '../../storage/repo-manager.js';
import {
canonicalizePath,
loadMeta,
readRegistryStrict,
registryPathEquals,
type RegistryEntry,
type RepoMeta,
} from '../../storage/repo-manager.js';
import { crossRepoCompleteness } from './completeness.js';
import { recordedMatchStages, recordedRepoList } from './completeness.js';
import { GroupNotFoundError, loadGroupConfig } from './config-parser.js';
import {
fileMatchesServicePrefix,
@ -222,10 +231,39 @@ function isCrossLink(raw: unknown): raw is CrossLink {
return typeof o.contractId === 'string' && typeof o.type === 'string';
}
/**
* Does the global registry hold a row for this configured group member?
*
* Consulted only once resolution has ALREADY failed, to choose which of the
* two failures `group status` reports. It mirrors the two tiers
* `LocalBackend.resolveRepo` matches a bare group-config value on — the
* registry `name`, case-insensitively, and the repo `path` — and deliberately
* stops short of its hashed-id and partial-name tiers: those exist to be
* generous about what an operator typed, while this predicate only decides
* between two labels, and a looser match here would relabel a genuine registry
* miss as an unresolvable row. That is the same conflation this reporting
* exists to remove, pointed the other way.
*/
function registryIdentifies(entries: RegistryEntry[], registryName: string): boolean {
const wantedName = registryName.toLowerCase();
// Path equality goes through the registry's own rule rather than a local
// `resolve` + platform-case compare. `canonicalizePath` also follows symlinks,
// so a row registered through one and looked up through the other still
// matches — and there is one definition of registry path identity instead of
// a third, weaker copy of it living in a group module nobody would grep.
const wantedPath = canonicalizePath(registryName);
return entries.some((entry) => {
if (typeof entry.name === 'string' && entry.name.toLowerCase() === wantedName) return true;
if (typeof entry.path !== 'string') return false;
return registryPathEquals(canonicalizePath(entry.path), wantedPath);
});
}
async function loadContractRegistryResilient(
groupDir: string,
): Promise<
{ ok: true; registry: ContractRegistry; skippedCorrupt: number } | { ok: false; error: string }
| { ok: true; registry: ContractRegistry; skippedCorrupt: number; suppressionUnreadable: boolean }
| { ok: false; error: string }
> {
const filePath = path.join(groupDir, 'contracts.json');
let raw: string;
@ -288,6 +326,17 @@ async function loadContractRegistryResilient(
}
}
// Bound once: the gate is a full array scan and the ternary below used it twice.
const recordedUnreadable = recordedRepoList(base.unreadableRepos);
const recordedSuppressed = recordedMatchStages(base.suppressedMatchStages);
// Present-but-unreadable is NOT the same as absent. `recordedMatchStages` is
// all-or-nothing, so garbage collapses to `undefined` — and a consumer that
// reads `undefined` as "nothing was suppressed" would throw that safety away
// and report a registry it could not parse as complete. Absent stays
// legitimate (a registry predating the field); only a value that was there
// and unreadable forces the answer to a floor.
const suppressionUnreadable =
base.suppressedMatchStages !== undefined && recordedSuppressed === undefined;
const registry: ContractRegistry = {
version: typeof base.version === 'number' ? base.version : 0,
generatedAt: typeof base.generatedAt === 'string' ? base.generatedAt : '',
@ -295,12 +344,89 @@ async function loadContractRegistryResilient(
base.repoSnapshots && typeof base.repoSnapshots === 'object' && base.repoSnapshots !== null
? (base.repoSnapshots as Record<string, { indexedAt: string; lastCommit: string }>)
: {},
missingRepos: Array.isArray(base.missingRepos) ? (base.missingRepos as string[]) : [],
// Same gate as `groupStatus` uses on the same field, for the same reason:
// `Array.isArray` alone waves through `[{repo:'x'}]`, and `groupContracts`
// now returns this list AND folds it into its completeness answer, so a
// value we could not read would be reported as a repo name. `missingRepos`
// has always been required, so — unlike `unreadableRepos` below — there is
// no "not recorded" state to preserve: an unreadable value degrades to empty.
missingRepos: recordedRepoList(base.missingRepos) ?? [],
// Spread, not `?? []`. `ContractRegistry.unreadableRepos` documents absence
// as "not recorded", and a registry written before the field existed has no
// opinion about which indexes were readable. Normalizing that to `[]` hands
// the caller "the last sync found none unreadable" — an unmeasured state
// rendered as a clean result, which is the same conflation this whole
// change removes.
...(recordedUnreadable ? { unreadableRepos: recordedUnreadable } : {}),
// Same omit-when-unrecorded rule. This reader rebuilds the envelope field
// by field with no spread of `base`, so a new on-disk field is dropped
// unless it is named here.
...(recordedSuppressed ? { suppressedMatchStages: recordedSuppressed } : {}),
contracts,
crossLinks,
};
return { ok: true, registry, skippedCorrupt };
return { ok: true, registry, skippedCorrupt, suppressionUnreadable };
}
/**
* Validate a boolean MCP parameter — reject, never coerce.
*
* `Boolean(params.x)` is the trap this exists to close: the string `"false"`
* is truthy, and an LLM caller emitting JSON produces that shape routinely.
* While `exactOnly` was inert the coercion was harmless; now that it gates a
* matching stage, a coerced `"false"` suppresses that stage and persists a
* registry with fewer cross-links than the caller asked for.
*
* Absent stays absent-as-false (the unchanged default). Anything that is not
* a real boolean returns a structured `{ error }`, mirroring
* `validateImpactMode` — the established shape for this boundary, and the one
* `groupSync`'s other guards already use.
*/
function validateBooleanParam(name: string, raw: unknown): { value: boolean } | { error: string } {
if (raw === undefined) return { value: false };
if (typeof raw === 'boolean') return { value: raw };
return { error: `Invalid "${name}": expected true or false, got ${describeValue(raw)}.` };
}
/**
* Render an untrusted value for an error message, without throwing.
*
* `JSON.stringify` is the right shape here — it distinguishes the string
* `"false"` from the boolean, which is the whole point of the message — but it
* throws on a BigInt and on a cyclic object. A validator whose ERROR path can
* throw does not return the structured `{ error }` it promises: the caller gets
* a rejected promise instead of feedback it can act on, and `callTool` is
* reachable directly, so neither input is hypothetical.
*/
function describeValue(raw: unknown): string {
try {
const rendered = JSON.stringify(raw);
// `undefined`, a function, or a symbol serialize to `undefined`.
return rendered ?? String(raw);
} catch {
return typeof raw === 'bigint' ? `${raw}n` : Object.prototype.toString.call(raw);
}
}
/**
* Refuse parameters this tool used to accept and no longer does.
*
* The CLI rejects a removed flag outright because commander errors on an
* unknown option. The MCP path had no equivalent, so an agent working from a
* cached tool schema kept sending a retired key and was told nothing — the
* removal took away discoverability, not acceptance. Naming the parameter is
* what lets the caller correct itself on the next call.
*/
function rejectRetiredSyncParams(params: Record<string, unknown>): { error: string } | null {
for (const retired of ['skipEmbeddings', 'allowStale']) {
if (params[retired] !== undefined) {
return {
error: `"${retired}" was removed and is no longer accepted. Drop it from the call.`,
};
}
}
return null;
}
export class GroupService {
@ -332,6 +458,13 @@ export class GroupService {
async groupSync(params: Record<string, unknown>): Promise<unknown> {
const name = String(params.name ?? '').trim();
if (!name) return { error: 'name is required' };
// Before anything reads the group off disk: the MCP SDK does not enforce a
// tool's advertised `inputSchema` and `callTool` is reachable directly, so
// this method is the real validation boundary.
const exactOnly = validateBooleanParam('exactOnly', params.exactOnly);
if ('error' in exactOnly) return exactOnly;
const retired = rejectRetiredSyncParams(params);
if (retired) return retired;
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
let config: GroupConfig;
try {
@ -347,18 +480,39 @@ export class GroupService {
// MCP server startup entirely and off every non-sync group call. The CLI
// already does exactly this at `cli/group.ts`'s sync command.
const { syncGroup } = await import('./sync.js');
const result = await syncGroup(config, {
groupDir,
exactOnly: Boolean(params.exactOnly),
skipEmbeddings: Boolean(params.skipEmbeddings),
allowStale: Boolean(params.allowStale),
verbose: Boolean(params.verbose),
});
const { GroupSyncLockError } = await import('./group-lock.js');
let result: Awaited<ReturnType<typeof syncGroup>>;
try {
result = await syncGroup(config, {
groupDir,
exactOnly: exactOnly.value,
// `verbose` is deliberately NOT accepted here. It gates diagnostics on
// the server's logger, which an MCP caller cannot observe — advertising
// it would be exactly the kind of knob that does not do what the caller
// expects. `SyncOptions.verbose` stays for the CLI, which can see them.
});
} catch (err) {
// Fails closed (R9): this sync could not be protected against a concurrent
// one, so it did not run and wrote nothing. Return it through the same
// error channel a missing group uses — NEVER as a success payload of zeroes,
// which an agent would read as "the group genuinely has no contracts".
if (!(err instanceof GroupSyncLockError)) throw err;
return { error: err.message };
}
return {
contracts: result.contracts.length,
crossLinks: result.crossLinks.length,
unmatched: result.unmatched.length,
missingRepos: result.missingRepos,
unreadableRepos: result.unreadableRepos,
// The agent-facing half of the skipped-stage signal. A human sees it in
// the CLI summary; without this an agent would have to issue a second
// `group_contracts` call to discover its own sync was narrowed.
suppressedMatchStages: result.suppressedMatchStages,
// An agent that calls group_sync and then group_contracts a moment later
// can otherwise see contract counts that disagree with this payload, with
// nothing here explaining why the write was skipped.
registryOutcome: result.registryOutcome,
};
}
@ -386,7 +540,50 @@ export class GroupService {
);
contracts = contracts.filter((c) => !matchedIds.has(`${c.repo}::${c.contractId}`));
}
const out: Record<string, unknown> = { contracts, crossLinks: registry.crossLinks };
// `loadContractRegistryResilient` already applied `recordedRepoList` to
// both: `undefined` here is "the last sync recorded no opinion" (a registry
// written before the field existed, or a value we could not read), which is
// NOT the same answer as the measured empty list.
const { unreadableRepos, missingRepos } = registry;
// `incompleteRepos` is dropped on this surface only because the two lists it
// is derived from are returned verbatim right below; the truncation triple is
// the part that has no other channel here.
const { incompleteRepos: _incompleteRepos, ...truncation } = crossRepoCompleteness({
unreadableRepos,
missingRepos,
suppressedMatchStages: registry.suppressedMatchStages,
// An unrecorded `unreadableRepos` means this listing cannot say which
// repos the sync failed to read — so it cannot claim to be complete.
// Either kind of unreadable provenance forces the floor: a sync that
// could not say which repos it read, or a suppression record that was
// present and could not be parsed. Reading the second as "nothing was
// suppressed" would report an unparseable registry as complete.
provenanceUnknown: unreadableRepos === undefined || loaded.suppressionUnreadable,
// A contract LISTING declares no scope to intersect with: it is the whole
// registry, so every configured repo is in scope by construction. The
// `type`/`repo`/`unmatchedOnly` filters above narrow which rows are shown,
// not which repos the sync had to read to produce them.
inScope: () => true,
});
const out: Record<string, unknown> = {
contracts,
crossLinks: registry.crossLinks,
missingRepos,
// Omitted rather than `[]` when the registry never recorded it — the same
// convention `skippedCorrupt` follows below, and the difference between
// "the sync measured zero unreadable repos" and "the sync never said".
...(unreadableRepos ? { unreadableRepos } : {}),
// Same omit-when-unrecorded rule, and deliberately NOT folded into the
// truncation triple below: that triple reports limits a run hit by
// accident, whose remedy is to fix the repo. A suppressed stage was
// asked for, and its remedy is to re-sync without that flag.
...(registry.suppressedMatchStages
? { suppressedMatchStages: registry.suppressedMatchStages }
: {}),
// The structured triple, verbatim from the impact surface (KTD10):
// `truncated` always, `truncationReason` + `riskEpistemic` with it.
...truncation,
};
if (skippedCorrupt > 0) out.skippedCorrupt = skippedCorrupt;
return out;
}
@ -573,17 +770,80 @@ export class GroupService {
}
const registry = await readContractRegistry(groupDir);
/**
* The STRICT global-registry read, deliberately — this is the one caller
* that has to tell "the registry says nothing about this repo" apart from
* "the registry could not be read at all", and only the strict mode can.
* `readRegistry`'s `catch { return [] }` collapses a malformed registry
* into an empty one, which is indistinguishable from a genuine absence and
* would report every configured repo as having no entry — the exact
* conflation the two labels below exist to remove.
*
* The consequence is accepted knowingly: the strict read rejects the WHOLE
* registry when any single row fails to identify a repo, so one malformed
* row renders every member of the group unresolvable, including members
* whose own rows are fine. That is the honest verdict — a registry the
* resolver cannot trust row-wise cannot be trusted about any row — and it
* is reported as an unresolved state, never as a clean one.
*
* ENOENT is not a failure in either mode: no registry file genuinely means
* nothing has been registered yet, so every repo is legitimately missing.
*/
let registryEntries: RegistryEntry[] | null = null;
let registryReadError: string | null = null;
try {
registryEntries = await readRegistryStrict();
} catch (err) {
registryReadError = err instanceof Error ? err.message : String(err);
}
const repoStatuses: Record<
string,
{
indexStale: boolean;
contractsStale: boolean;
/**
* Unchanged meaning: this repo has no usable status. It stays `true`
* for BOTH failures below, so a consumer written before the split
* still sees every unusable repo flagged. Reporting an unresolvable
* repo as `missing: false` would hand that consumer `indexStale:
* false` for a repo nothing was ever read from — a false all-clear.
*/
missing: boolean;
/**
* Which failure `missing` means: `false` is a genuine registry miss,
* `true` is an entry the resolver could not turn into a repo. Additive
* — always present on every row, so an agent can branch on it without
* having to treat an absent key as either answer.
*/
unresolvable: boolean;
/** Set only when `unresolvable`; says what could not be resolved. */
unresolvableReason?: string;
commitsBehind?: number;
}
> = {};
for (const [repoPath, registryName] of Object.entries(config.repos)) {
if (registryEntries === null) {
repoStatuses[repoPath] = {
indexStale: false,
contractsStale: false,
missing: true,
unresolvable: true,
unresolvableReason: `the global registry could not be read: ${registryReadError}`,
};
continue;
}
// Only `resolveRepo` is inside the try that produces the
// "did not resolve" label, so the label is earned rather than assumed.
// `loadMeta` and `checkStaleness` cannot throw — the first returns null on
// every error, the second catches everything — but the reading below them
// can, and did: `registry.repoSnapshots` is read off a bare
// `JSON.parse(...) as ContractRegistry` with no shape check, so a
// contracts.json missing that field threw a TypeError into this catch and
// reported every repo as an unresolvable GLOBAL-registry entry. That sent
// the operator to repair the wrong file. The optional chain below closes
// the crash; this split stops the next one being mislabelled the same way.
try {
const repoObj = await this.port.resolveRepo(registryName);
const meta: Partial<Pick<RepoMeta, 'lastCommit' | 'indexedAt'>> =
@ -593,7 +853,7 @@ export class GroupService {
? checkStaleness(repoObj.repoPath, meta.lastCommit)
: { isStale: true, commitsBehind: -1 };
const snapshot = registry?.repoSnapshots[repoPath];
const snapshot = registry?.repoSnapshots?.[repoPath];
const contractsStale =
snapshot && meta.indexedAt ? snapshot.indexedAt !== meta.indexedAt : !snapshot;
@ -601,17 +861,49 @@ export class GroupService {
indexStale: staleness.isStale,
contractsStale: Boolean(contractsStale),
missing: false,
unresolvable: false,
commitsBehind: staleness.commitsBehind,
};
} catch {
repoStatuses[repoPath] = { indexStale: false, contractsStale: false, missing: true };
} catch (err) {
// The registry read succeeded, so its answer about this row is
// trustworthy: a row that is there and still would not resolve is a
// different fact from a row that was never there, and the operator's
// next move differs (repair the entry vs. index the repo).
const known = registryIdentifies(registryEntries, registryName);
const reason = err instanceof Error ? err.message : String(err);
repoStatuses[repoPath] = {
indexStale: false,
contractsStale: false,
missing: true,
unresolvable: known,
...(known
? { unresolvableReason: `registry entry "${registryName}" did not resolve: ${reason}` }
: {}),
};
}
}
return {
group: name,
lastSync: registry?.generatedAt || null,
missingRepos: registry?.missingRepos || [],
// `readContractRegistry` is a bare `JSON.parse(...) as ContractRegistry`,
// so both of these are whatever the file happened to hold — the
// validation in `loadContractRegistryResilient` never runs on this path.
// A `contracts.json` carrying a string here reached `cli/group.ts` and
// died in `.join(', ')`, i.e. an unreadable registry crashing the command
// whose job is to explain unreadable things.
//
// `missingRepos` has always been required, so there is no "not recorded"
// state to preserve for it — an unreadable value degrades to empty.
missingRepos: recordedRepoList(registry?.missingRepos) ?? [],
// `unreadableRepos` does have one: absent means "not recorded", not
// "none" (see ContractRegistry), and a value we could not read is equally
// unrecorded. Reporting either as an empty list is the same conflation.
unreadableRepos: recordedRepoList(registry?.unreadableRepos),
// Same tri-state, same reason: `group status` is where an operator goes
// to ask "is this group's answer trustworthy right now", and a registry
// narrowed on purpose is a different answer from a complete one.
suppressedMatchStages: recordedMatchStages(registry?.suppressedMatchStages),
repos: repoStatuses,
};
}

View file

@ -5,7 +5,7 @@ import * as os from 'node:os';
import type { ContractRegistry } from './types.js';
import { writeFileAtomic } from '../../storage/fs-atomic.js';
const CONTRACTS_FILE = 'contracts.json';
export const CONTRACTS_FILE = 'contracts.json';
export function getDefaultGitnexusDir(): string {
return process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus');
@ -30,6 +30,11 @@ export function getGroupDir(gitnexusDir: string, groupName: string): string {
return path.join(gitnexusDir, 'groups', groupName);
}
/** The registry path, so callers that stat or watch the file do not respell its name. */
export function getContractRegistryPath(groupDir: string): string {
return path.join(groupDir, CONTRACTS_FILE);
}
export async function writeContractRegistry(
groupDir: string,
registry: ContractRegistry,
@ -93,13 +98,8 @@ detect:
http: true
grpc: true
topics: true
shared_libs: true
embedding_fallback: true
matching:
bm25_threshold: 0.7
embedding_threshold: 0.65
max_candidates_per_step: 3
# exclude_links_paths: [/ping, /health, /healthcheck]
# exclude_links_param_only_paths: false
`;

Binary file not shown.

View file

@ -1,5 +1,5 @@
export type ContractType = 'http' | 'grpc' | 'thrift' | 'topic' | 'lib' | 'custom' | 'include';
export type MatchType = 'exact' | 'manifest' | 'wildcard' | 'bm25' | 'embedding';
export type MatchType = 'exact' | 'manifest' | 'wildcard';
export type ContractRole = 'provider' | 'consumer';
export interface GroupConfig {
@ -26,16 +26,11 @@ export interface DetectConfig {
grpc: boolean;
thrift: boolean;
topics: boolean;
shared_libs: boolean;
embedding_fallback: boolean;
includes: boolean;
workspace_deps: boolean;
}
export interface MatchingConfig {
bm25_threshold: number;
embedding_threshold: number;
max_candidates_per_step: number;
/**
* HTTP paths to exclude from cross-link matching. Contracts at these paths
* are still extracted and visible in the registry, but they don't produce
@ -100,7 +95,34 @@ export interface ContractRegistry {
version: number;
generatedAt: string;
repoSnapshots: Record<string, RepoSnapshot>;
/** Configured repos with no entry in the registry. */
missingRepos: string[];
/**
* Configured repos that ARE registered but that this sync could not extract
* from — the index would not open (version skew, lock, corruption), or an
* extractor threw partway through. The two are one bucket because the
* consequence is one thing: NONE of that repo's contracts are in this
* registry. Distinct from `missingRepos`, which is "no entry in the
* registry at all" and needs a different answer from the operator.
*
* Optional so a registry written before this field existed still parses —
* absent means "not recorded", not "none".
*/
unreadableRepos?: string[];
/**
* Matching stages this sync was ASKED to skip, so a later reader can tell a
* short cross-link list from a complete one. `--exact-only` / `exactOnly`
* suppresses the wildcard stage, and the registry it writes is otherwise
* indistinguishable from one where that stage ran and matched nothing.
*
* Same tri-state as `unreadableRepos` and for the same reason: absent means
* "not recorded" (written before this field existed), `[]` means "measured,
* nothing was suppressed", and a populated list names the stages. Distinct
* from `truncated` / `truncationReason`, which report limits this run hit by
* accident — a suppressed stage is a deliberate request, and its remedy is
* "re-sync without exactOnly", not "fix the unreadable repo".
*/
suppressedMatchStages?: MatchType[];
contracts: StoredContract[];
crossLinks: CrossLink[];
}
@ -117,8 +139,36 @@ export interface RepoHandle {
storagePath: string;
}
/** Why local impact or fan-out stopped early (e.g. wall-clock budget exhausted). */
export type GroupImpactTruncationReason = 'timeout' | 'partial';
/**
* Why local impact or fan-out stopped early (e.g. wall-clock budget exhausted).
*
* `'timeout'` and `'partial'` are runtime limits — the same query can succeed on
* a retry. `'incomplete-sync'` is structural: the bridge itself was built from a
* sync that could not read every configured repo, so those repos' contracts are
* absent from every query against it until `gitnexus group sync` succeeds.
* `'suppressed-stage'` is structural too but has its own remedy: the sync was
* ASKED to skip a matching stage (`--exact-only`), so cross-links that stage
* would have found are absent by request. Retrying returns the same floor, and
* so does re-running the sync — the fix is to re-run it WITHOUT the flag. Kept a
* separate member rather than folded into `'incomplete-sync'` precisely because
* that remedy differs; telling an agent to repair a repo it read fine is the
* failure this distinction exists to prevent.
*
* A runtime array rather than a bare type union: every value here has to be
* explained on the agent-facing surface that returns it, and only an enumerable
* list lets a guard test assert that. A test that hand-lists the members passes
* forever once a fourth is added — which is the exact drift the guard exists to
* catch, so the list an agent is promised and the list the code can emit have
* to come from the same place.
*/
export const GROUP_IMPACT_TRUNCATION_REASONS = [
'timeout',
'partial',
'incomplete-sync',
'suppressed-stage',
] as const;
export type GroupImpactTruncationReason = (typeof GROUP_IMPACT_TRUNCATION_REASONS)[number];
export interface GroupImpactResult {
local: unknown;
@ -222,5 +272,118 @@ export interface BridgeHandle {
export interface BridgeMeta {
version: number;
generatedAt: string;
/**
* Size and mtime of the `bridge.lbug` this metadata was written for, so a
* reader can tell whether the two still belong together.
*
* `writeBridge` replaces the database and writes this file as two operations;
* a sync that stops between them leaves the PREVIOUS sync's metadata beside a
* new database, and `runGroupImpact` reads completeness from that metadata.
* Stamping the pair is what lets `bridgeMetaMatchesFile` reject the mismatch
* without anything having to be deleted — deleting the old metadata up front
* would lose it permanently on a swap that fails with the old database still
* in place, which is a normal Windows outcome when a read-only handle is held.
*
* Optional: metadata written before this existed carries no stamp. Such a
* file is not waved through — `bridgeMetaMatchesFile` falls back to comparing
* the two files' modification times, since a successful write orders the
* database rename before the metadata write and a database NEWER than the
* metadata beside it therefore cannot be the one it describes.
*
* That fallback proves WRITE ORDER, not provenance, and is wrong in both
* directions — a non-monotonic clock can make a mis-paired set read as
* ordered, and any copy or restore that rewrites the database's times after
* the metadata's demotes an intact legacy pair to a lower bound until the
* next sync re-stamps it. A stamped pair never reaches that fallback, which
* is the reason to prefer stamping over widening the heuristic. Both
* directions are spelled out at `bridgeMetaMatchesFile`.
*/
bridgeSize?: number;
bridgeMtimeMs?: number;
/**
* Reader-side only: true when `meta.json` parsed but one of its repo lists
* held a value that was not a list of repo paths.
*
* NEVER PERSISTED. `readBridgeMeta` sets it to describe what it found in the
* file; `writeBridgeMeta`'s only caller builds a fresh literal, so it cannot
* round-trip back to disk. It lives on this interface rather than on a
* reader-only subtype so that `readBridgeMeta` keeps the exact signature
* every caller already compiles against.
*
* The unusable value is dropped rather than normalized, so `missingRepos: []`
* on such a result is inert filler — this flag, not the empty list, is what
* says the bridge's provenance is unknown.
*/
repoListsUnreadable?: boolean;
/**
* Reader-side only: did this metadata pair with the `bridge.lbug` beside it,
* measured BEFORE anything opened that database?
*
* NEVER PERSISTED, for the same reason as `repoListsUnreadable`.
*
* The measurement has to happen before the open, and the answer has to be
* carried rather than recomputed. `runGroupImpact` and `runGroupTrace` open
* the bridge and only then ask about provenance, so a platform where a
* read-only open advances the database's mtime would fail every unstamped
* pair the moment it was read — turning back-compat for pre-stamp bridges
* into a repo-wide "everything is a lower bound". Whether any given
* LadybugDB build and OS does that is not something a reader should have to
* know, and it cannot be observed on Windows, where the in-process
* write→read reopen this would need is a documented limitation. Ordering the
* check ahead of the open makes the question moot on every platform instead
* of true on the ones that happen to be testable.
*/
pairedWithDatabase?: boolean;
/**
* PERSISTED, unlike the two fields above: the writer of this metadata could
* not establish that it describes the `bridge.lbug` beside it, and no reader
* may conclude otherwise from the files alone.
*
* Written by `refreshPreservedBridgeMeta` — the preserve path in `syncGroup`,
* which refreshes the diagnostic lists of a bridge it deliberately does NOT
* rebuild. That refresh rewrites `meta.json` ATOMICALLY, so this file's mtime
* becomes now while the database's stays old; and "metadata newer than the
* database beside it" is exactly the write order that
* `unstampedMetaPairsByWriteOrder` accepts. A refresh that simply carried the
* old fields forward would therefore convert a pair that check had been
* REJECTING into one it waves through — laundering unknown provenance into
* verified provenance, which is the fail-open this whole channel exists to
* close.
*
* "Just don't write a stamp" is not a substitute, and is worse: an unstamped
* metadata file is judged on the two file times, and the refresh has already
* moved them into the accepting order. The verdict has to be recorded IN the
* file, because the write that records it is itself what destroys the
* evidence a reader would otherwise use.
*
* `bridgeMetaMatchesFile` rejects on this ahead of both the stamp and the
* write-order heuristic, so `ensureBridgeReady` answers
* `pairedWithDatabase: false` and `bridgeProvenanceUnknown` reports the
* cross-repo answer as a lower bound. That is the ONE enforcement point; do
* not add a second reader for this field.
*
* Self-clearing: a successful `writeBridge` builds fresh metadata from a
* literal and never sets it, so the next good sync retires the marker without
* anything having to delete it.
*/
provenanceUnknown?: boolean;
missingRepos: string[];
/**
* Configured repos the sync that produced this bridge could not extract from
* (see `ContractRegistry.unreadableRepos`). Their contracts and every
* cross-link touching them are absent from `bridge.lbug`, so a cross-repo
* impact query against this bridge is a lower bound, not a verdict —
* `runGroupImpact` folds a non-empty value into its truncation fields for
* exactly that reason.
* Optional: a bridge written before this field existed does not record it.
*/
unreadableRepos?: string[];
/**
* Matching stages the sync that built this bridge was asked to skip.
* PERSISTED, like `unreadableRepos` and unlike `repoListsUnreadable` — a
* later `group_impact` or `trace` reads this bridge with no access to the run
* that produced it, and a narrowed graph is otherwise indistinguishable from
* a complete one. Same tri-state: absent is "not recorded".
*/
suppressedMatchStages?: MatchType[];
}

View file

@ -22,6 +22,27 @@ export interface FilePath {
const READ_CONCURRENCY = 32;
const ANALYZE_PROGRESS_ACTIVE_ENV = 'GITNEXUS_ANALYZE_PROGRESS_ACTIVE';
const DECLARATION_COMPANION_SUFFIXES = [
{ declaration: '.d.ts', implementations: ['.ts', '.tsx'] },
{ declaration: '.d.mts', implementations: ['.mts'] },
{ declaration: '.d.cts', implementations: ['.cts'] },
] as const;
const hasImplementationSibling = (
declarationPath: string,
scannedPaths: ReadonlySet<string>,
): boolean => {
const companion = DECLARATION_COMPANION_SUFFIXES.find(({ declaration }) =>
declarationPath.endsWith(declaration),
);
if (!companion) return false;
// Keep standalone declarations. Only suppress declaration output that sits
// beside an implementation with the corresponding module suffix.
const stem = declarationPath.slice(0, -companion.declaration.length);
return companion.implementations.some((suffix) => scannedPaths.has(`${stem}${suffix}`));
};
const warnLargeFileSkip = (message: string): void => {
if (process.env[ANALYZE_PROGRESS_ACTIVE_ENV] === '1') {
// analyze.ts routes console.warn through the progress bar logger while
@ -84,10 +105,17 @@ export const walkRepositoryPaths = async (
}
}
const scannedPaths = new Set(entries.map((entry) => entry.path));
const deduplicatedEntries = entries.filter(
(entry) => !hasImplementationSibling(entry.path, scannedPaths),
);
// Filesystem/glob traversal order is not stable across filesystems or repeated
// scans. Canonicalize once at the scan boundary so every downstream phase sees
// the same repository order.
entries.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0));
deduplicatedEntries.sort((left, right) =>
left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
);
if (skippedLarge > 0) {
const isDefault = maxFileSizeBytes === DEFAULT_MAX_FILE_SIZE_BYTES;
@ -123,7 +151,7 @@ export const walkRepositoryPaths = async (
}
}
return entries;
return deduplicatedEntries;
};
/**

View file

@ -19,7 +19,7 @@ import fs from 'fs/promises';
import path from 'path';
import { createRequire } from 'node:module';
import { isHardcodedIgnoredDirectory } from '../../../config/ignore-service.js';
import { isHardcodedIgnoredDirectoryAtPath } from '../../../config/ignore-service.js';
import { logger } from '../../logger.js';
import { resolveFile } from '../languages/typescript/file-candidates.js';
@ -361,9 +361,10 @@ export async function loadNodeWorkspacePackages(
for (const entry of entries) {
if (entry.isDirectory()) {
if (isHardcodedIgnoredDirectory(entry.name)) continue;
const childDir = path.join(dir, entry.name);
if (isHardcodedIgnoredDirectoryAtPath(repoRoot, childDir)) continue;
if (depth < SCAN_MAX_DEPTH) {
queue.push({ dir: path.join(dir, entry.name), depth: depth + 1 });
queue.push({ dir: childDir, depth: depth + 1 });
}
continue;
}

View file

@ -51,6 +51,44 @@ import type { ExtractedDecoratorRoute } from './workers/parse-worker.js';
/** Tree-sitter query captures: capture name → AST node (or undefined if not captured). */
export type CaptureMap = Record<string, SyntaxNode | undefined>;
export interface DefinitionPropertiesContext {
readonly nodeLabel: NodeLabel;
readonly nodeName: string;
readonly definitionNode: SyntaxNode;
readonly parsedImports: readonly ParsedImport[];
readonly isExported: boolean;
}
export type DefinitionPropertiesExtractor = (
context: DefinitionPropertiesContext,
) => Readonly<Record<string, unknown>> | undefined;
/** Run optional provider enrichment without allowing one hook failure to drop
* the rest of the worker's language batch. */
export function runDefinitionPropertiesExtractor(
extractor: DefinitionPropertiesExtractor,
context: DefinitionPropertiesContext,
onError: (error: unknown) => void,
): Readonly<Record<string, unknown>> | undefined {
try {
return extractor(context);
} catch (error) {
onError(error);
return undefined;
}
}
/** Provider metadata is additive; graph identity and source-location fields
* supplied by the worker remain authoritative. */
export function mergeCanonicalDefinitionProperties<
TCanonical extends Readonly<Record<string, unknown>>,
>(
providerProperties: Readonly<Record<string, unknown>>,
canonicalProperties: TCanonical,
): Record<string, unknown> & TCanonical {
return { ...providerProperties, ...canonicalProperties } as Record<string, unknown> & TCanonical;
}
// ── Strategy tag types ─────────────────────────────────────────────────────
// NOTE: `MroStrategy` is defined in `gitnexus-shared` and re-exported above
// so `core/ingestion/model/resolve.ts` can consume it without importing from
@ -276,6 +314,10 @@ interface LanguageProviderConfig {
* constant, and static declarations. Produces VariableInfo with type, visibility,
* isConst, isStatic, isMutable metadata. Default: undefined (no variable extraction). */
readonly variableExtractor?: VariableExtractor;
/** Add language-owned, structured properties to a definition node. Values
* cross the worker boundary and must therefore be structured-clone-safe.
* Shared ingestion code treats these properties as opaque. */
readonly definitionPropertiesExtractor?: DefinitionPropertiesExtractor;
/** Class/type extractor for deriving canonical qualified names for class-like symbols.
* Uses the same provider-driven strategy pattern as method/field extraction so
* namespace/package/module rules stay language-specific. */

View file

@ -126,10 +126,13 @@ import {
} from './javascript/index.js';
import { extractDispatchGuardRoutes } from '../route-extractors/dispatch-guard.js';
import { extractDataRouteTableRoutes } from '../route-extractors/data-route-table.js';
import { extractNestRoutes } from '../route-extractors/nest.js';
import { extractConvexEndpointProperties } from './typescript/convex-endpoint-metadata.js';
const extractJsTsRoutes = (...args: Parameters<typeof extractDispatchGuardRoutes>) => [
...extractDispatchGuardRoutes(...args),
...extractDataRouteTableRoutes(...args),
...extractNestRoutes(...args),
];
/**
@ -418,6 +421,7 @@ export const typescriptProvider = defineLanguage({
extractFunctionName: tsExtractFunctionName,
}),
variableExtractor: createVariableExtractor(typescriptVariableConfig),
definitionPropertiesExtractor: extractConvexEndpointProperties,
classExtractor: createClassExtractor(typescriptClassConfig),
// ── JSDoc → description (issue #2270). An exported decl is captured as the
// inner declaration; its JSDoc precedes the wrapping `export_statement`. ──
@ -505,6 +509,7 @@ export const javascriptProvider = defineLanguage({
extractFunctionName: tsExtractFunctionName,
}),
variableExtractor: createVariableExtractor(javascriptVariableConfig),
definitionPropertiesExtractor: extractConvexEndpointProperties,
classExtractor: createClassExtractor(javascriptClassConfig),
// ── JSDoc → description (issue #2270). An exported decl is captured as the
// inner declaration; its JSDoc precedes the wrapping `export_statement`. ──

View file

@ -0,0 +1,113 @@
import type { ParsedImport } from 'gitnexus-shared';
import type { DefinitionPropertiesContext } from '../../language-provider.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { assertCloneable } from '../../workers/clone-safety.js';
const GENERATED_ENDPOINT_FACTORIES: ReadonlySet<string> = new Set([
'query',
'mutation',
'action',
'internalQuery',
'internalMutation',
'internalAction',
'httpAction',
]);
const GENERIC_ENDPOINT_FACTORIES: ReadonlyMap<string, string> = new Map(
[...GENERATED_ENDPOINT_FACTORIES].map((factory) => [`${factory}Generic`, factory]),
);
const normalizeModuleTarget = (targetRaw: string): string =>
targetRaw.replace(/\\/g, '/').replace(/\.(?:[cm]?[jt]s)$/, '');
const isGeneratedServerModule = (targetRaw: string): boolean =>
/(?:^|\/)_generated\/server$/.test(normalizeModuleTarget(targetRaw));
function importedConvexFactory(
imports: readonly ParsedImport[],
localName: string,
): string | undefined {
for (const parsedImport of imports) {
if (parsedImport.kind !== 'named' && parsedImport.kind !== 'alias') continue;
if (parsedImport.localName !== localName) continue;
const target = normalizeModuleTarget(parsedImport.targetRaw);
if (target === 'convex/server') {
return GENERIC_ENDPOINT_FACTORIES.get(parsedImport.importedName);
}
if (isGeneratedServerModule(target)) {
return GENERATED_ENDPOINT_FACTORIES.has(parsedImport.importedName)
? parsedImport.importedName
: undefined;
}
}
return undefined;
}
function matchingDeclarator(node: SyntaxNode, nodeName: string): SyntaxNode | undefined {
if (node.type === 'variable_declarator' && node.childForFieldName('name')?.text === nodeName) {
return node;
}
if (node.type === 'export_statement') {
const declaration = node.childForFieldName('declaration');
return declaration ? matchingDeclarator(declaration, nodeName) : undefined;
}
if (node.type !== 'lexical_declaration' && node.type !== 'variable_declaration') {
return undefined;
}
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (
child?.type === 'variable_declarator' &&
child.childForFieldName('name')?.text === nodeName
) {
return child;
}
}
return undefined;
}
function findDeclarator(node: SyntaxNode, nodeName: string): SyntaxNode | undefined {
let current: SyntaxNode | null = node;
while (current) {
const declarator = matchingDeclarator(current, nodeName);
if (declarator) return declarator;
if (current.type === 'program' || current.type === 'statement_block') break;
current = current.parent;
}
return undefined;
}
/**
* Stamp Convex runtime-dispatch metadata only when both the declaration shape
* and the factory import provenance are known. The MCP layer consumes the
* resulting property without reparsing lossy FTS text.
*/
export function extractConvexEndpointProperties(
context: DefinitionPropertiesContext,
): Readonly<Record<string, unknown>> | undefined {
if ((context.nodeLabel !== 'Const' && context.nodeLabel !== 'Function') || !context.isExported) {
return undefined;
}
const declarator = findDeclarator(context.definitionNode, context.nodeName);
const value = declarator?.childForFieldName('value');
if (!value || value.type !== 'call_expression') return undefined;
const callee = value.childForFieldName('function');
if (!callee || callee.type !== 'identifier') return undefined;
const factory = importedConvexFactory(context.parsedImports, callee.text);
if (factory === undefined) return undefined;
const args = value.childForFieldName('arguments');
if (!args || args.namedChildCount !== 1) return undefined;
const endpointDefinition = args.namedChild(0);
if (
!endpointDefinition ||
!['object', 'arrow_function', 'function_expression'].includes(endpointDefinition.type)
) {
return undefined;
}
return assertCloneable({ convexEndpointFactory: factory });
}

View file

@ -23,7 +23,7 @@
import fs from 'fs/promises';
import path from 'path';
import { isHardcodedIgnoredDirectory } from '../../../../config/ignore-service.js';
import { isHardcodedIgnoredDirectoryAtPath } from '../../../../config/ignore-service.js';
import { logger } from '../../../logger.js';
/** One `paths` entry, pattern and targets kept in declaration order. */
@ -291,9 +291,9 @@ async function findTsconfigFiles(repoRoot: string): Promise<string[]> {
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (isHardcodedIgnoredDirectory(entry.name)) continue;
if (depth < SCAN_MAX_DEPTH)
queue.push({ dir: path.join(dir, entry.name), depth: depth + 1 });
const childDir = path.join(dir, entry.name);
if (isHardcodedIgnoredDirectoryAtPath(repoRoot, childDir)) continue;
if (depth < SCAN_MAX_DEPTH) queue.push({ dir: childDir, depth: depth + 1 });
continue;
}
if (!entry.isFile()) continue;

View file

@ -195,6 +195,45 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
const allFetchCalls = [...parseFetchCalls];
const routeRegistry = new Map<string, RouteEntry>();
/**
* Registry keys written straight from the file list below, never through
* `addRoute`. `resolveRouteHandlerSymbols` walks only `extractedRoutes` and
* `decoratorRoutes`, so it never sees these URLs and its `claimed` set never
* contains them — which means a handler stamped on one of these keys was
* resolved for a DIFFERENT route.
*
* That is reachable, and it fabricates rather than omits (#3049). A
* method-agnostic route (`@All`, a Django function view, a verb-less
* dispatch guard) keys by URL alone via `routeNodeKey`, so it collides with
* a file-convention route at the same URL. It claims the key unopposed in
* `claim()`, then loses first-writer-wins here in `addRoute` and is dropped
* as a duplicate — and without this guard the surviving file-convention node
* would read that handler and present another application's controller
* method as its own. `api_impact` is documented to be run BEFORE editing a
* route handler, so it would answer with a handler from the wrong app.
*
* Dropping the losing route is a separate and deliberate consequence of
* URL-only identity; this only stops the false attribution.
*
* Membership is recorded AT the pre-seeding `set`, mirroring `claim()` in
* call-processor.ts, which writes `claimed` and its result map together
* rather than re-deriving either by rescanning. Identifying pre-seeded
* entries by matching `entry.source` against a list of source strings would
* spell them a second time, away from the sites that produce them — and a
* fourth pre-seeded source added later would then reopen #3049 in silence.
* `addRoute` deliberately does NOT record here: its routes ARE
* handler-resolved, so suppressing them would widen the guard into a bug of
* its own.
*
* Each `add` below sits inside its own `!routeRegistry.has(key)` gate, as
* every `routeRegistry.set` in this phase does: the map is write-once per
* key, so a losing candidate cannot record a key it did not claim and no
* later writer can take a recorded key away. Key-membership is therefore
* equivalent to source-matching by construction — pre-seeded routes carry no
* verb and `routeNodeKey(undefined, url) === url` — which is why the
* two-candidates-one-URL case needs no fixture to settle it.
*/
const preSeededKeys = new Set<string>();
// Detect Expo Router app/ roots vs Next.js app/ roots (monorepo-safe)
const expoAppRoots = new Set<string>();
@ -217,32 +256,33 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
}
}
// One writer for every pre-seeded route, so recording membership cannot be
// forgotten. Inlining `has` / `set` / `add` at each site made the invariant
// a convention three call sites had to remember — and a fourth source that
// forgot the `add` would reopen #3049 exactly as silently as the source-set
// it replaced. This is the shape `claim()` in call-processor.ts uses for the
// same reason: one helper writes the collection and its key set together.
const preSeed = (url: string, entry: Omit<RouteEntry, 'url'>): boolean => {
if (routeRegistry.has(url)) return false;
routeRegistry.set(url, { ...entry, url });
preSeededKeys.add(url);
return true;
};
for (const p of allPaths) {
if (expoAppPaths.has(p)) {
const expoURL = expoFileToRouteURL(p);
if (expoURL && !routeRegistry.has(expoURL)) {
routeRegistry.set(expoURL, {
filePath: p,
source: 'expo-filesystem-route',
url: expoURL,
});
if (expoURL && preSeed(expoURL, { filePath: p, source: 'expo-filesystem-route' })) {
continue;
}
}
const nextjsURL = nextjsFileToRouteURL(p);
if (nextjsURL && !routeRegistry.has(nextjsURL)) {
routeRegistry.set(nextjsURL, {
filePath: p,
source: 'nextjs-filesystem-route',
url: nextjsURL,
});
if (nextjsURL && preSeed(nextjsURL, { filePath: p, source: 'nextjs-filesystem-route' })) {
continue;
}
if (p.endsWith('.php')) {
const phpURL = phpFileToRouteURL(p);
if (phpURL && !routeRegistry.has(phpURL)) {
routeRegistry.set(phpURL, { filePath: p, source: 'php-file-route', url: phpURL });
}
if (phpURL) preSeed(phpURL, { filePath: p, source: 'php-file-route' });
}
}
@ -311,7 +351,11 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
const { source: routeSource, method: routeMethod, url } = entry;
const handlerPath = handlerPathFor(routeKey, entry);
const content = handlerContents.get(handlerPath);
const handlerSymbolId = routeHandlerSymbols.get(routeKey);
// A pre-seeded route can never legitimately appear in
// `routeHandlerSymbols`, so a key that does is a route that LOST (#3049).
const handlerSymbolId = preSeededKeys.has(routeKey)
? undefined
: routeHandlerSymbols.get(routeKey);
const analysisContent =
entry.source === DATA_ROUTE_TABLE_SOURCE && content
? handlerSymbolContent(

View file

@ -116,7 +116,14 @@ function decodeJavaScriptStringLiteral(raw: string): string | null {
return decoded;
}
function plainString(node: SyntaxNode): string | null {
/**
* A `string`/`template_string` node's decoded value, or `null` when it is not a
* readable literal (an interpolated template, an unterminated escape). Shared
* with the NestJS extractor so both agree on what a readable literal is —
* notably that escapes must be DECODED, not dropped, because tree-sitter splits
* a literal around every `escape_sequence`.
*/
export function plainString(node: SyntaxNode): string | null {
if (node.type === 'string') return decodeJavaScriptStringLiteral(node.text);
if (
node.type === 'template_string' &&
@ -129,7 +136,12 @@ function plainString(node: SyntaxNode): string | null {
return null;
}
function propertyName(node: SyntaxNode): string | null {
/**
* A property key's name, for the spellings that carry one — `{ path: … }` and
* `{ 'path': … }`. A computed key (`{ [KEY]: … }`) has none. Shared with the
* NestJS extractor, which reads `@Controller({ path: … })` the same way.
*/
export function propertyName(node: SyntaxNode): string | null {
if (node.type === 'identifier' || node.type === 'property_identifier') return node.text;
if (node.type === 'string') return plainString(node);
return null;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,548 @@
/**
* NestJS decorator routes for the indexer.
*
* A NestJS endpoint is declared across two decorators: `@Controller('venues')`
* on the class supplies the prefix, and `@Get('search')` on a method supplies
* the verb and the remainder. Neither half is a route on its own, which is why
* a pattern that only looks at one of them finds nothing.
*
* Until this existed, TypeScript's `extractDecoratorRoutes` hook was dispatch
* guards plus static data route tables only, so a NestJS repo produced
* essentially no `Route` nodes. That is not a quiet gap: `route_map`,
* `api_impact` and `shape_check` all read `Route` nodes and answer "no routes
* matching …" when there are none — so `api_impact`, whose documented job is to
* be run BEFORE modifying a route handler, reported every live endpoint as
* non-existent, and a not-found reads as a safe change (#3009).
*
* The extraction mirrors `spring.ts`, which solves the identical shape for
* `@RequestMapping` + `@GetMapping`: collect class-level prefixes keyed by class
* node id, then walk method decorators and attach the prefix of their enclosing
* class. As there, the prefix travels on `ExtractedDecoratorRoute.prefix` and
* the routes phase performs the join via `normalizeExtractedRoutePath`, so
* NestJS routes are keyed identically to every other framework's.
*
* The multi-path form `@Get(['a', 'b'])` mounts the handler at BOTH paths, so
* it emits both routes: N paths is N elements of the returned
* `ExtractedDecoratorRoute[]`, which is already how this layer spells N routes
* — the same representation `spring.ts` reaches for `@GetMapping({"/a","/b"})`,
* and the reason neither needs a special case downstream. The CLASS-level array
* (`@Controller(['a', 'b'])`) is DECLINED rather than cross-multiplied over the
* class's methods, again matching `spring.ts`: there an array-form class prefix
* only ever suppresses the class, with the cross-product tracked in #2280.
*
* Known limitation: the URLs produced here are CONTROLLER-RELATIVE. A global
* prefix (`app.setGlobalPrefix('api')`) and URI versioning are applied by the
* bootstrap file, not by any decorator this file can see, so neither is
* reflected — a route served at `/api/v1/venues/search` is stored as
* `/venues/search`. The module's "drop rather than guess" floor is unavailable
* for it: the evidence lives in a different file, so honouring it would mean
* dropping every Nest route in every repo. `spring.ts` has the same hole for
* `server.servlet.context-path`; `ExtractedDecoratorRoute.prefix` is the
* channel a cross-file follow-up would use, the way FastAPI resolves its mount.
*/
import type Parser from 'tree-sitter';
import type { ExtractedDecoratorRoute } from '../workers/parse-worker.js';
import { plainString, propertyName } from './data-route-table.js';
import { isDev } from '../utils/env.js';
import { logger } from '../../logger.js';
/**
* NestJS method decorators → HTTP verb. A Map rather than an object literal
* because the lookup key is an arbitrary decorator name read out of source: a
* plain object answers `@toString()` with `Object.prototype.toString`, which is
* truthy and would be emitted verbatim as the route's httpMethod.
*/
const NEST_METHOD_DECORATORS: ReadonlyMap<string, string> = new Map([
['Get', 'GET'],
['Post', 'POST'],
['Put', 'PUT'],
['Patch', 'PATCH'],
['Delete', 'DELETE'],
['Head', 'HEAD'],
['Options', 'OPTIONS'],
['All', '*'],
// `@Sse` mounts a real GET endpoint that streams; it is as much a route as
// `@Get`. `@Search` is deliberately absent — `normalizeRouteMethod` rejects
// SEARCH as non-standard and would key the route by URL alone, colliding
// with every other verb on that path.
['Sse', 'GET'],
]);
/**
* Class node types that can carry a `@Controller`. `export abstract class C`
* parses as `abstract_class_declaration`, a DIFFERENT node type — and a
* decorated abstract base sharing CRUD routes with its subclasses is ordinary
* Nest, so matching `class_declaration` alone silently drops the whole
* controller rather than one route.
*/
const CLASS_DECLARATION_TYPES: ReadonlySet<string> = new Set([
'class_declaration',
'abstract_class_declaration',
]);
/**
* Cheap parse-free gate. Every JS/TS file in every repo reaches this hook, so
* skip the walk unless the file could plausibly declare a controller. A file
* without the substring cannot produce a route here, because a `@Controller`
* decorator is REQUIRED before any method decorator is believed (see below).
*/
const CONTROLLER_HINT = '@Controller';
/** The decorator's name — `Controller` for `@Controller('x')`, `Get` for `@Get()`. */
function decoratorName(decorator: Parser.SyntaxNode): string | null {
const inner = decorator.namedChild(0);
if (!inner) return null;
// `@Get()` is a call_expression; a bare `@Injectable` is a plain identifier.
if (inner.type === 'identifier') return inner.text;
if (inner.type === 'call_expression') {
const fn = inner.childForFieldName('function');
return fn?.type === 'identifier' ? fn.text : null;
}
return null;
}
/**
* The literal path(s) a decorator call mounts, one entry per path — or `['']`
* when the decorator takes no argument (`@Controller()` / `@Get()` — both legal
* and both meaning "no path segment of my own").
*
* A list rather than a single string because `@Get(['a', 'b'])` mounts the
* handler at two URLs, and two routes is what the caller's output contract
* already says that in: `ExtractedDecoratorRoute[]`. No new field, and no
* special case at the emit site — the same shape `spring.ts` gets for free from
* a query that matches one element at a time.
*
* Returns `null` when an argument IS present but is not a readable literal.
* That is deliberately distinct from `['']`: a computed prefix
* (`@Controller(ROUTES.VENUES)`) whose value we cannot read must drop the route
* rather than silently mount it at the wrong URL. `route_map` presents its
* output as fact, and a wrong path is worse than a missing one. `[]` is a third
* answer and means neither of those: `@Get([])` is legal, knowably mounts
* nothing, and so emits nothing — it must never be read as the unknowable case,
* which is the one that suppresses a whole controller.
*
* Reading one literal is delegated to `plainString`, the same judge the
* data-route-table extractor uses, so both agree on what is readable. Filtering
* `string_fragment` children and joining them looks equivalent and is not:
* tree-sitter SPLITS a literal around each `escape_sequence`, and the join then
* DELETES the escape rather than decoding it. `@Get(':id(\\d+)')` — the ordinary
* spelling of a Nest regex param, whose value is `:id(\d+)` — came out as
* `:id(d+)`, and `@Get('/v\u0069ews')` came out as `/vews`. Both are paths the
* app never serves, i.e. the wrong-URL outcome the paragraph above forbids.
*/
function decoratorLiteralPaths(decorator: Parser.SyntaxNode): readonly string[] | null {
const call = decorator.namedChild(0);
// A Nest route decorator is a FACTORY: `@Get()` invokes it and returns the
// decorator that registers the route. A BARE `@Get` is the factory itself,
// never applied, so Nest registers nothing — emitting a route for it would
// publish a URL the app does not serve. The same holds one level up for a
// bare `@Controller`, which registers no controller.
//
// `@Get()` with no ARGUMENT is different and still a real pathless route:
// what distinguishes them is the call, not the argument list. That case falls
// through to the `!first` branch below.
if (call?.type !== 'call_expression') return null;
const first = call.childForFieldName('arguments')?.namedChild(0);
if (!first) return [''];
// The object form belongs to `@Controller` alone — a verb decorator takes
// `string | string[]`, so Nest mounts nothing for `@Get({ path: 'a' })`.
// Reading it as a route would mint a URL the app never serves, which is the
// invented fact this module refuses; an unreadable shape drops instead.
if (first.type === 'object' && decoratorName(decorator) !== 'Controller') return null;
return literalPaths(first);
}
/**
* The paths carried by one decorator ARGUMENT node, split out from
* {@link decoratorLiteralPaths} only so the object form can re-enter it: Nest
* accepts an array inside `{ path: … }` as well, and reusing the same judge is
* what keeps `@Controller({ path: ['a', 'b'] })` from being read by a second,
* laxer set of rules that has drifted from this one.
*/
function literalPaths(node: Parser.SyntaxNode): readonly string[] | null {
// `@Controller({ path: 'cats', version: '1' })` is the documented form for
// URI/header versioning, and its path is a plain literal sitting right there.
// Worth reading rather than dropping, because the asymmetry is severe: an
// unreadable METHOD path costs one route, an unreadable PREFIX costs every
// route on the class.
if (node.type === 'object') {
// But a `path` pair only PROVES the mount when nothing else in the object
// can replace it, and the first match proves nothing on its own:
// `{ path: 'cats', ...options }` mounts wherever `options.path` says, and
// `{ path: 'cats', path: 'dogs' }` mounts at `dogs` — last write wins in
// both. Either one publishes `/cats`, a URL the app never serves, and it
// looks exactly like a correct one, which is the wrong-answer-dressed-as-
// fact this module refuses. So the object is read only when EVERY member is
// a named, non-repeated pair. That whole-entry fail-closed walk is the
// shape `routeFromObject` uses in `data-route-table.ts`.
const values = new Map<string, Parser.SyntaxNode>();
for (const child of node.namedChildren) {
// Skipped FIRST. A comment between two pairs is ordinary formatting; run
// through the not-a-pair test below it would refuse the object and cost
// the class every route it has, over a comment.
if (child.type === 'comment') continue;
// `spread_element` (`{ ...options }`), `shorthand_property_identifier`
// (`{ path }`) and `method_definition` (`{ getFoo() {} }`) all land here
// — probed and identical across the three grammars this extractor runs
// under. None offers a key/value this file can read, and the first can
// introduce or overwrite `path` from a value declared elsewhere.
if (child.type !== 'pair') return null;
const key = child.childForFieldName('key');
const value = child.childForFieldName('value');
if (key === null || value === null) return null;
// Compared through `propertyName`, the same judge used to READ the key —
// so `{ path: … }` and `{ 'path': … }` are one key and collide as
// duplicates. Comparing raw key text instead makes them two distinct
// keys, and `{ path: 'cats', 'path': 'dogs' }` silently mounts the loser.
const name = propertyName(key);
// No readable name means a computed key (`{ [dynamicKey]: 'b' }`), which
// could evaluate to `path` and take the mount with it — refused, not
// ignored. A repeated key is refused wherever it appears, not only on
// `path`: a duplicate anywhere is evidence the object is not the fixed
// literal it reads as, and cost is one controller against a wrong URL.
if (name === null || values.has(name)) return null;
values.set(name, value);
}
// Deliberately NOT `containsExecutingExpression` (data-route-table.ts): that
// guards whole-entry declarativeness for a static route table, a different
// invariant. Here only `path` has to be provable, so a non-literal value on
// an unrelated key — `{ path: 'a', scope: Scope.REQUEST }`, ordinary Nest —
// stays benign and keeps its controller.
const path = values.get('path');
// A missing `path` keeps the existing drop and must never read as `''`:
// `@Controller({ version: '1' })` mounts at a prefix this decorator does
// not state, and `''` would publish every one of its methods at the root.
return path === undefined ? null : literalPaths(path);
}
// `array` is the node type in all three grammars this extractor runs under —
// tree-sitter-typescript's `typescript` and `tsx`, and tree-sitter-javascript
// — probed rather than assumed, because a name that differs in one of them
// would silently restore the old drop for that grammar alone.
if (node.type === 'array') {
const paths: string[] = [];
for (const element of node.namedChildren) {
const value = plainString(element);
// One unreadable element poisons the whole array. Emitting the readable
// ones would present a partial mapping as a complete one — the endpoint
// behind `ROUTES.ADMIN` would be missing from a controller that otherwise
// looks fully covered, which is the same wrong-answer-dressed-as-fact this
// module refuses above, only harder to notice.
if (value === null) return null;
paths.push(value);
}
return paths;
}
const value = plainString(node);
return value === null ? null : [value];
}
/**
* Decorators that immediately precede `node` among its parent's named children.
* In tree-sitter-typescript a decorator is a SIBLING placed before the thing it
* decorates — at `export_statement`/`program` level for a class — and
* decorators stack.
*
* Walks the sibling chain rather than indexing into `parent.namedChildren`,
* which is the same uncached-getter trap {@link collectClassRoutes} documents:
* a class's parent is usually `program`, so reading the list marshals every
* top-level statement in the file, once per class. That is quadratic in
* top-level statements — measured 200ms for a file of 800 classes, against
* 0.9ms for this form.
*/
function precedingDecorators(node: Parser.SyntaxNode): Parser.SyntaxNode[] {
const out: Parser.SyntaxNode[] = [];
for (let sibling = node.previousNamedSibling; sibling; sibling = sibling.previousNamedSibling) {
// A comment between the decorators and the thing they decorate is ordinary
// (`@Post('x')` then a JSDoc block then the method) and must not terminate
// the stack — doing so makes the whole decorated route invisible.
if (sibling.type === 'comment') continue;
if (sibling.type !== 'decorator') break;
out.push(sibling);
}
return out;
}
/**
* Leading `decorator` children of a node, stopping at the first child that is
* neither a decorator nor a comment. Comments are skipped for the same reason
* as in {@link precedingDecorators}: a doc block sitting between `@Controller`
* and the class must not hide the decorator.
*/
function leadingDecorators(node: Parser.SyntaxNode): Parser.SyntaxNode[] {
const out: Parser.SyntaxNode[] = [];
for (const child of node.namedChildren) {
if (child.type === 'comment') continue;
if (child.type !== 'decorator') break;
out.push(child);
}
return out;
}
/**
* Cap on the decorator text quoted in the dropped-controller log. Long enough
* to identify the shape, short enough not to dump a wrapped multi-line
* decorator into the operator's terminal.
*/
const DROPPED_CONTROLLER_LOG_LIMIT = 160;
/**
* Every decorator attached to a class, across the two shapes the grammar
* produces — which differ by whether the class is exported:
*
* `@Controller('a') class A {}` → decorator is a CHILD of class_declaration
* `@Controller('a') export class A {}` → decorator is a child of export_statement,
* i.e. a SIBLING of the class_declaration
*
* Checking only one of them silently drops half of all controllers, so collect
* from both, plus the sibling position for the class itself. There is no fourth
* source: both grammars fold a class's decorators INTO the `export_statement`
* production, so an `export_statement` never has one as a preceding sibling.
*/
function classDecorators(classNode: Parser.SyntaxNode): Parser.SyntaxNode[] {
const out = [...leadingDecorators(classNode), ...precedingDecorators(classNode)];
const wrapper = classNode.parent;
if (wrapper?.type === 'export_statement') out.push(...leadingDecorators(wrapper));
return out;
}
/**
* The `@Controller(...)` prefix for a class, or undefined when it has none.
* One string, not a list: a class-level array (`@Controller(['a', 'b'])`) is
* DECLINED here, exactly as `spring.ts` declines an array-form
* `@RequestMapping` — it detects the shape only to suppress the class, leaving
* the prefix × method cross-product to #2280. Collapsing to `null` is that
* suppression, and this parity is deliberate, not an oversight: the two
* extractors solve the same shape and should not disagree about which half of
* it is supported.
*/
function controllerPrefix(
classNode: Parser.SyntaxNode,
filePath: string,
): string | null | undefined {
for (const decorator of classDecorators(classNode)) {
if (decoratorName(decorator) !== 'Controller') continue;
const paths = decoratorLiteralPaths(decorator);
// `@Controller([])` lands here too and needs no answer of its own: a
// controller mounted at no path serves no route, so "emit nothing for this
// class" is what both readings of it come to.
if (paths === null || paths.length !== 1) {
// The single funnel for EVERY whole-controller drop — an unreadable
// constant (`@Controller(ROUTES.VENUES)`), a multi-path array, an
// unreadable array element, and an options object whose `path` another
// member could override all return null here. Reporting at the refusal
// sites instead would make the rarest cause the loudest, and leave the
// motivating one from this module's own header silent.
//
// `isDev` at `info`, not `debug`: the logger's base level IS `info`, so
// an isDev-gated `debug` is gated twice and stays silent in exactly the
// dev run it exists for. Same shape the routes phase uses.
if (isDev) {
const shape = decorator.text.replace(/\s+/g, ' ');
logger.info(
`🗺️ NestJS: dropped @Controller in ${filePath} — its prefix is not provable: ${
shape.length > DROPPED_CONTROLLER_LOG_LIMIT
? `${shape.slice(0, DROPPED_CONTROLLER_LOG_LIMIT)}…`
: shape
}`,
);
}
return null;
}
return paths[0];
}
return undefined;
}
/**
* Extract NestJS routes from one parsed TypeScript/JavaScript file.
*
* A method decorator is only believed when its enclosing class carries a
* `@Controller`. `@Get`/`@Post`/`@Delete` are common identifiers, and without
* that requirement any unrelated library using the same decorator names would
* mint phantom endpoints.
*/
export function extractNestRoutes(
tree: Parser.Tree,
filePath: string,
lineOffset = 0,
): ExtractedDecoratorRoute[] {
if (!tree.rootNode.text.includes(CONTROLLER_HINT)) return [];
const out: ExtractedDecoratorRoute[] = [];
const visit = (node: Parser.SyntaxNode): void => {
if (CLASS_DECLARATION_TYPES.has(node.type)) {
const prefix = controllerPrefix(node, filePath);
// `undefined` — not a controller at all. `null` — a controller whose
// prefix could not be read, so its routes' URLs are unknowable.
if (prefix !== undefined) {
if (prefix !== null) collectClassRoutes(node, prefix, filePath, lineOffset, out);
return; // a controller's methods are handled here; don't re-walk them
}
}
for (const child of node.namedChildren) visit(child);
};
visit(tree.rootNode);
return out;
}
/**
* Modifiers that take a `method_definition` out of Nest's handler set.
*
* Nest's `RequestMapping` writes the handler onto the class PROTOTYPE's
* `descriptor.value`, and `RouterExplorer` scans prototype instance methods for
* that metadata. A `static` method lives on the constructor and is never
* scanned; an accessor's descriptor carries `get`/`set` and no `value` to
* register. A verb decorator on any of the three therefore mounts NOTHING, so a
* route minted from one is a URL the app does not serve — the invented fact
* this module refuses everywhere else.
*/
const NON_HANDLER_MODIFIERS: ReadonlySet<string> = new Set(['static', 'get', 'set']);
/** Longest entry above — the cheap gate that keeps `.trim()` off a method body. */
const LONGEST_NON_HANDLER_MODIFIER = 6;
/**
* Whether Nest could register this `method_definition` as a request handler.
*
* Reads `children`, NOT `namedChildren`, and that is the whole difficulty:
* `static`, `get` and `set` are ANONYMOUS tokens in all three grammars this
* extractor runs under, so they never appear among named children. A static
* method, a getter, a setter and a plain method expose the IDENTICAL
* `namedChildren` (`property_identifier`, `formal_parameters`,
* `statement_block`) — probed, not assumed — so the module's usual
* `namedChildren` idiom cannot see the modifier at all and every one of the
* three reads as an ordinary handler.
*
* Matches on child TEXT, not node type, and skips the `name` field — the same
* two rules `hasKeyword` in `field-extractors/configs/helpers.ts` applies, and
* that the TS/JS captures and method extractor already use for this question.
* The text rule is load-bearing: `static` reaches the tree as an anonymous
* token in some grammar versions and a keyword node in others, so a
* `child.type === 'static'` test silently stops firing on a grammar bump — here
* that would readmit exactly the phantom routes this function removes, with the
* suite still green. Skipping `name` is what keeps a method literally called
* `get()` or `static()` from reading as a modifier.
*
* Open-coded rather than calling `hasKeyword` three times, which was measured
* at 13.96us per method against 4.30us here: that helper takes ONE keyword, so
* three keywords is three full passes, and it calls `.text.trim()` on every
* child including `statement_block` — the whole method body. `.some()` does not
* rescue it, since a real handler matches nothing and pays all three. The
* length guard keeps `.trim()` off a multi-KB body; no modifier exceeds it.
*
* The scan is bounded by one method's own children (a handful), so it is not
* the uncached-getter trap {@link collectClassRoutes} documents — that one bites
* when a PARENT's child list is re-marshalled once per member.
*/
function isRequestHandler(member: Parser.SyntaxNode): boolean {
const nameNode = member.childForFieldName('name');
for (const child of member.children) {
if (child === nameNode) continue;
const text = child.text;
if (text.length <= LONGEST_NON_HANDLER_MODIFIER && NON_HANDLER_MODIFIERS.has(text.trim())) {
return false;
}
}
return true;
}
function collectClassRoutes(
classNode: Parser.SyntaxNode,
prefix: string,
filePath: string,
lineOffset: number,
out: ExtractedDecoratorRoute[],
): void {
const body = classNode.childForFieldName('body');
if (!body) return;
// ONE forward pass over the body, accumulating the decorator run and flushing
// it at each method. Calling `precedingDecorators` per method instead is
// quadratic in methods-per-controller for a reason that is invisible in the
// source: `namedChildren` is an UNCACHED getter in node-tree-sitter, so every
// call re-marshals the entire class body into fresh JS objects before the
// `findIndex`. Measured here, 800 methods cost 362ms (450us/method, up from
// 42us/method at 50); a single pass is flat. `spring.ts` never had this
// because a Java annotation is a child of the declaration it annotates.
const pending: Parser.SyntaxNode[] = [];
for (const member of body.namedChildren) {
if (member.type === 'decorator') {
pending.push(member);
continue;
}
// Same reason as in `precedingDecorators`: a JSDoc block between a
// decorator stack and its method must not hide the route (a real
// controller shape, pinned by the suite). Known limitation of that skip: a
// decorator ORPHANED by a commented-out handler is then absorbed onto the
// NEXT method, minting a phantom route with the wrong handler. There is no
// AST fix — an orphan followed by a comment is indistinguishable from a
// stack whose method happens to be documented — and losing every
// documented route is the worse trade, so it is made deliberately.
if (member.type === 'comment') continue;
// tree-sitter-javascript makes a method decorator a CHILD of the
// `method_definition`, not a preceding sibling as in tree-sitter-typescript
// — and this extractor is registered on the JavaScript provider too, which
// already advertises `framework: 'nestjs'`. Reading only siblings meant
// every `.js` Nest controller emitted nothing. On TypeScript the first
// named child is the method name, so `leadingDecorators` contributes
// nothing there and no route is collected twice.
//
// A static member, a getter and a setter are decorated exactly like a
// handler and registered as none, so they contribute no decorators (see
// `isRequestHandler`). They still fall THROUGH to the `pending.length = 0`
// below rather than `continue` past it: skipping the clear would hand their
// decorator run to the next method, trading a phantom route for a
// misattributed one — the strictly worse of the two, since it corrupts a
// route that is otherwise correct.
const decorators =
member.type === 'method_definition' && isRequestHandler(member)
? [...pending, ...leadingDecorators(member)]
: [];
for (const decorator of decorators) {
const name = decoratorName(decorator);
if (name === null) continue;
const httpMethod = NEST_METHOD_DECORATORS.get(name);
if (httpMethod === undefined) continue;
const routePaths = decoratorLiteralPaths(decorator);
if (routePaths === null) continue; // unreadable → skip
const handlerName = member.childForFieldName('name')?.text;
// One route per path. `@Get(['a', 'b'])` mounts the handler at both, and
// everything else about the two is identical — same verb, same handler,
// same line — so the loop is the whole of the multi-path support. An
// empty array falls out as zero iterations without a special case.
for (const routePath of routePaths) {
out.push({
filePath,
// A pathless `@Get()` is the controller's index route and carries no
// segment of its own. Emit '/' rather than '': `claim()` in
// call-processor short-circuits on a falsy routePath, so an empty
// string would still produce the Route node but silently lose its
// handler symbol — the route would exist with nothing attached to it.
// Both spellings normalize to the same URL against the prefix.
routePath: routePath === '' ? '/' : routePath,
httpMethod,
decoratorName: name,
lineNumber: member.startPosition.row + 1 + lineOffset,
prefix: prefix === '' ? null : prefix,
...(handlerName === undefined ? {} : { handlerName }),
});
}
}
// Anything that is not a decorator or a comment ends the run — including
// the method that just consumed it, so a decorated FIELD's stack is never
// absorbed onto the method after it.
pending.length = 0;
}
}

View file

@ -22,6 +22,7 @@ import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexe
import { generateId } from '../../../../lib/utils.js';
import {
AMBIGUOUS_POSITION,
exactPositionKey,
localNameKey,
positionKey,
qualifiedKey,
@ -168,14 +169,6 @@ function pickCallerCallableDef(
* resolution working for languages that don't yet synthesize
* qualifiers).
*/
/**
* Extract the 1-based declaration line from a scope-resolution def id.
* Shape: `def:<filePath>#<line>:<col>:<...>`; `undefined` when it doesn't match.
*/
function defStartLine(nodeId: string | undefined, filePath: string): number | undefined {
return definitionIdPosition(nodeId, filePath)?.line;
}
/**
* Trailing segment of a dotted qualified name (`Outer.inner` -> `inner`),
* with any function-local `@line:col` identity suffix stripped
@ -256,9 +249,34 @@ export function resolveDefGraphId(
// AST nodes (outer wrapper vs inner callable), but the graph node's
// `startLine` follows the initializer (#2735) so this join matches even
// when the binding is split across lines.
const line = defStartLine(def.nodeId, filePath);
const definitionPosition = definitionIdPosition(def.nodeId, filePath);
const line = definitionPosition?.line;
if (line !== undefined && isPositionQualifiedLocalLabel(def.type)) {
const simple = simpleNameOf(qn);
if (definitionPosition !== undefined) {
const exactHit = nodeLookup.get(
exactPositionKey(
filePath,
def.type,
definitionPosition.line - 1,
definitionPosition.column,
),
);
if (exactHit !== undefined && exactHit !== AMBIGUOUS_POSITION) return exactHit;
if (exactHit === undefined && siblingLabel !== undefined) {
const siblingExactHit = nodeLookup.get(
exactPositionKey(
filePath,
siblingLabel,
definitionPosition.line - 1,
definitionPosition.column,
),
);
if (siblingExactHit !== undefined && siblingExactHit !== AMBIGUOUS_POSITION) {
return siblingExactHit;
}
}
}
const posHit = nodeLookup.get(positionKey(filePath, def.type, line - 1, simple));
if (posHit !== undefined && posHit !== AMBIGUOUS_POSITION) return posHit;
// Retry under the sibling callable label when the def's OWN label

View file

@ -96,6 +96,16 @@ export function positionKey(
return `<p>:${filePath}::${label}::${startLine}::${name}`;
}
/** Exact source-position key used before the legacy line/name join. */
export function exactPositionKey(
filePath: string,
label: NodeLabel,
startLine: number,
startColumn: number,
): string {
return `<pc>:${filePath}::${label}::${startLine}:${startColumn}`;
}
/**
* Key recording that a FUNCTION-LOCAL callable with this simple name exists in the
* file (#2699 follow-up).
@ -131,6 +141,7 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup {
name?: string;
qualifiedName?: string;
templateArguments?: readonly string[];
startColumn?: number;
};
if (props.filePath === undefined || props.name === undefined) continue;
if (!isLinkableLabel(node.label)) continue;
@ -139,6 +150,10 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup {
// ambiguous rather than letting source order decide.
const startLine = (props as { startLine?: number }).startLine;
if (startLine !== undefined && isPositionQualifiedLocalLabel(node.label)) {
if (props.startColumn !== undefined) {
const exactK = exactPositionKey(props.filePath, node.label, startLine, props.startColumn);
lookup.set(exactK, lookup.has(exactK) ? AMBIGUOUS_POSITION : node.id);
}
const posK = positionKey(props.filePath, node.label, startLine, props.name);
lookup.set(posK, lookup.has(posK) ? AMBIGUOUS_POSITION : node.id);
// A local-identity node carries `@<row>:<col>` on its last name segment. Record

View file

@ -787,7 +787,7 @@ export function pickUniqueGlobalCallable(
// because the list would then depend on the caller's scope, not just its file.
const cacheKey =
scopeDefsCache !== undefined && isCallerVisible === undefined
? `${name}${callerFilePath}`
? `${name}\0${callerFilePath}`
: undefined;
let scopeDefs: readonly SymbolDefinition[] | undefined =
cacheKey !== undefined ? scopeDefsCache!.get(cacheKey) : undefined;

View file

@ -1128,6 +1128,34 @@ export interface ObjectLiteralBindingInfo {
ownerName?: string;
}
/**
* True when an object-literal member is contained by an array before reaching
* another callable or class boundary.
*
* An array does not provide a stable named owner for its elements, so members
* below one cannot use `<binding>.<member>` identity or ownership. They still
* need distinct graph identities, however; callers use this predicate to opt
* into source-position qualification while keeping ownership suppressed.
*/
export const isArrayContainedObjectLiteralMember = (node: SyntaxNode): boolean => {
let current: SyntaxNode | null = node;
let sawObject = false;
while (current) {
if (current.type === 'object') sawObject = true;
if (current.type === 'array' && sawObject) return true;
if (
current !== node &&
(FUNCTION_NODE_TYPES.has(current.type) || CLASS_CONTAINER_TYPES.has(current.type))
) {
return false;
}
current = current.parent;
}
return false;
};
/**
* Block-statement AST types that disqualify an object-literal binding from
* carrying a HAS_METHOD edge. A `const` declared inside one of these is block-
@ -1283,6 +1311,13 @@ export const findObjectLiteralBindingInfo = (
objectDepth += 1;
}
if (current !== node && current.type === 'array') {
// `const handlers = [{ run() {} }]` has no `handlers.run` member.
// Crossing the array would mint a confident but false owner edge; keep
// the existing conservative under-approximation used for nested objects.
return null;
}
if (current.type === 'variable_declarator' && objectDepth >= 1) {
if (objectDepth > 1) {
// Method belongs to a nested object literal; safe under-approximation.

View file

@ -43,12 +43,12 @@ function containsPosition(node: SyntaxNode, row: number, column: number): boolea
}
/**
* Zero-based start row that keys the graph-to-scope position join for a bound
* callable (#2735).
* Zero-based start position that keys the graph-to-scope join for a bound
* callable (#2735/#3041).
*
* Graph-node queries may anchor on an outer binding wrapper while the scope
* channel anchors on the inner callable. The join is line-only, so a multi-line
* binding needs the graph node's `startLine` to follow the semantic definition.
* channel anchors on the inner callable. A bound graph node therefore follows
* the semantic definition's line and column rather than the outer wrapper.
*
* `ParsedFile.localDefs` is the language-agnostic source of that position.
* Matching uses only the canonical label, name, and source range; shared worker
@ -58,21 +58,26 @@ function containsPosition(node: SyntaxNode, row: number, column: number): boolea
* Missing or ambiguous semantic matches retain the wrapper row, preserving the
* existing fail-closed behavior.
*/
export function boundCallableStartRow(
export function boundCallableStartPosition(
definitionNode: SyntaxNode,
nodeName: string,
nodeLabel: NodeLabel,
localDefs: readonly SymbolDefinition[] | undefined,
nameNode?: SyntaxNode | null,
): number {
if (localDefs === undefined) return definitionNode.startPosition.row;
): { readonly row: number; readonly column: number } {
if (localDefs === undefined) return definitionNode.startPosition;
const origin = nameNode?.startPosition ?? definitionNode.startPosition;
let best: { row: number; distance: number } | undefined;
let best: { row: number; column: number; distance: number } | undefined;
let tied = false;
for (const def of localDefs) {
if (def.type !== nodeLabel || simpleDefinitionName(def) !== nodeName) continue;
if (
def.type !== nodeLabel ||
(simpleDefinitionName(def) !== nodeName && def.qualifiedName !== nodeName)
) {
continue;
}
const position = definitionIdPosition(def.nodeId, def.filePath);
if (position === undefined) continue;
@ -82,14 +87,29 @@ export function boundCallableStartRow(
const distance =
Math.abs(row - origin.row) * 1_000_000 + Math.abs(position.column - origin.column);
if (best === undefined || distance < best.distance) {
best = { row, distance };
best = { row, column: position.column, distance };
tied = false;
} else if (distance === best.distance && row !== best.row) {
} else if (
distance === best.distance &&
(row !== best.row || position.column !== best.column)
) {
tied = true;
}
}
return best !== undefined && !tied ? best.row : definitionNode.startPosition.row;
return best !== undefined && !tied
? { row: best.row, column: best.column }
: definitionNode.startPosition;
}
export function boundCallableStartRow(
definitionNode: SyntaxNode,
nodeName: string,
nodeLabel: NodeLabel,
localDefs: readonly SymbolDefinition[] | undefined,
nameNode?: SyntaxNode | null,
): number {
return boundCallableStartPosition(definitionNode, nodeName, nodeLabel, localDefs, nameNode).row;
}
/**
* A function-local callable's own name segment: its name plus its declaration
@ -114,8 +134,13 @@ export function boundCallableStartRow(
* bare/class-qualified ids, which is what keeps this off the symbols other
* files, saved queries and stored references actually address.
*/
export const positionQualifiedCallableName = (
name: string,
position: { readonly row: number; readonly column: number },
): string => `${name}@${position.row}:${position.column}`;
export const localIdentity = (node: SyntaxNode, name: string): string =>
`${name}@${node.startPosition.row}:${node.startPosition.column}`;
positionQualifiedCallableName(name, node.startPosition);
/**
* The qualified name of a callable nested inside another callable — THE single

View file

@ -1,8 +1,9 @@
import { parentPort, threadId, workerData } from 'node:worker_threads';
import {
boundCallableStartRow,
boundCallableStartPosition,
localIdentity,
nestedCallableQualifiedName,
positionQualifiedCallableName,
} from './callable-id.js';
import Parser from 'tree-sitter';
import JavaScript from 'tree-sitter-javascript';
@ -91,6 +92,7 @@ import {
getDefinitionNodeFromCaptures,
findEnclosingClassInfo,
findObjectLiteralBindingInfo,
isArrayContainedObjectLiteralMember,
findReturnShapeOwnerInfo,
isReturnShapeProperty,
findMemberAssignmentOwnerInfo,
@ -141,7 +143,11 @@ import {
templateConstraintsIdTag,
} from '../utils/template-arguments.js';
import type { LanguageProvider } from '../language-provider.js';
import { shouldHarvestModuleConstants } from '../language-provider.js';
import {
mergeCanonicalDefinitionProperties,
runDefinitionPropertiesExtractor,
shouldHarvestModuleConstants,
} from '../language-provider.js';
import type { ParsedFile } from 'gitnexus-shared';
import { extractParsedFile, type ScopeCaptureSourceKind } from '../scope-extractor-bridge.js';
import {
@ -836,6 +842,13 @@ const CALLABLE_PREFIX_BOUNDARY_TYPES: ReadonlySet<string> = new Set<string>([
'anonymous_object_creation_expression', // C#
]);
/**
* Object-literal callables use the binding owner in their identity so spelling
* a member as a property or shorthand method cannot change its graph semantics.
*/
const shouldObjectOwnerQualifyCallable = (label: NodeLabel): boolean =>
label === 'Function' || label === 'Method';
const enclosingCallablePrefix = (
node: SyntaxNode,
filePath: string,
@ -903,13 +916,27 @@ const callableOwnQualifiedName = (
// `ownName === null` branch below carries the position INSTEAD of a name,
// never in addition to one, so the two spellings cannot stack.
const ownName = efnResult?.funcName ?? genericFuncName(fnNode) ?? null;
let finalLabel = efnResult?.label ?? inferFunctionLabel(fnNode.type);
if (provider.labelOverride) {
const override = provider.labelOverride(fnNode, finalLabel);
if (override !== null) finalLabel = override;
}
const prefix = enclosingCallablePrefix(fnNode, filePath, provider);
const classInfo =
prefix === undefined
? cachedFindEnclosingClassInfo(fnNode, filePath, provider.resolveEnclosingOwner)
: null;
const owner = prefix ?? classInfo?.className;
const objectOwner =
prefix === undefined && classInfo === null && shouldObjectOwnerQualifyCallable(finalLabel)
? findObjectLiteralBindingInfo(fnNode, filePath, { includeOwnerName: true })?.ownerName
: undefined;
const owner = prefix ?? classInfo?.className ?? objectOwner;
const needsArrayPosition =
owner === undefined &&
ownName !== null &&
shouldObjectOwnerQualifyCallable(finalLabel) &&
isArrayContainedObjectLiteralMember(fnNode);
const result =
prefix !== undefined
? nestedCallableQualifiedName(prefix, fnNode, ownName ?? 'fn')
@ -917,7 +944,9 @@ const callableOwnQualifiedName = (
? localIdentity(fnNode, 'fn')
: owner
? `${owner}.${ownName}`
: ownName;
: needsArrayPosition
? positionQualifiedCallableName(ownName, fnNode.startPosition)
: ownName;
callableQualifiedNameCache.set(fnNode, result);
return result;
};
@ -970,8 +999,21 @@ const findEnclosingFunctionId = (
// to the METHOD, not directly to the class, and a Go receiver method can
// never itself be nested inside another callable.
const nestedPrefix = enclosingCallablePrefix(current, filePath, provider);
const objectOwnerName =
nestedPrefix === undefined &&
classInfo === null &&
shouldObjectOwnerQualifyCallable(finalLabel)
? findObjectLiteralBindingInfo(current, filePath, { includeOwnerName: true })?.ownerName
: undefined;
const ownerName =
nestedPrefix ?? classInfo?.className ?? standaloneMethodInfo?.receiverType ?? undefined;
nestedPrefix ??
classInfo?.className ??
standaloneMethodInfo?.receiverType ??
objectOwnerName;
const needsArrayPosition =
ownerName === undefined &&
shouldObjectOwnerQualifyCallable(finalLabel) &&
isArrayContainedObjectLiteralMember(current);
// Lockstep with the other two id-building phases — see
// `nestedCallableQualifiedName`, which is the shared rule. When a
// nested prefix exists it IS `ownerName`, so this branch and the
@ -981,7 +1023,9 @@ const findEnclosingFunctionId = (
? nestedCallableQualifiedName(nestedPrefix, current, funcName)
: ownerName
? `${ownerName}.${funcName}`
: funcName;
: needsArrayPosition
? positionQualifiedCallableName(funcName, current.startPosition)
: funcName;
// Include #<arity> suffix to match definition-phase Method/Constructor IDs.
// Use the same MethodExtractor (getMethodInfo) as the definition phase.
// When same-arity collisions exist, also append ~type1,type2.
@ -2279,23 +2323,24 @@ const processFileGroup = (
// wrapper while scope-resolution anchors on the INNER expression. The
// position join is line-only, so `startLine` must follow the initializer
// (ids still use `definitionNode` via `localIdentity`).
const startRow =
const startPosition =
definitionNode &&
(nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Constructor')
? boundCallableStartRow(
? boundCallableStartPosition(
definitionNode,
nodeName,
nodeLabel,
parsedFile?.localDefs,
nameNode,
)
: definitionNode?.startPosition.row;
: definitionNode?.startPosition;
const startLine =
startRow !== undefined
? startRow + lineOffset
startPosition !== undefined
? startPosition.row + lineOffset
: nameNode
? nameNode.startPosition.row + lineOffset
: lineOffset;
const startColumn = startPosition?.column ?? nameNode?.startPosition.column ?? 0;
// Compute enclosing class BEFORE node ID — needed to qualify method IDs
const needsOwner =
@ -2381,20 +2426,31 @@ const processFileGroup = (
// and COLLAPSE INTO ONE node — two distinct settings become one symbol,
// and the merged name then looks workspace-unique to name inference,
// which resolves reads of it to a node representing both.
const objectLiteralBindingInfo =
!enclosingClassId &&
(nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Property') &&
definitionNode
? findObjectLiteralBindingInfo(definitionNode, file.path, {
includeOwnerName:
shouldObjectOwnerQualifyCallable(nodeLabel) || nodeLabel === 'Property',
})
: null;
const objectLiteralOwnerInfo =
!enclosingClassId && (nodeLabel === 'Method' || nodeLabel === 'Property') && definitionNode
!enclosingClassId &&
(nodeLabel === 'Function' || nodeLabel === 'Method' || nodeLabel === 'Property') &&
definitionNode
? (findMemberAssignmentOwnerInfo(definitionNode, file.path) ??
findObjectLiteralBindingInfo(definitionNode, file.path, {
// Only `Property` opts into the qualifier; `Method` ids must stay
// byte-identical or every object-literal method in every indexed
// repo changes id.
includeOwnerName: nodeLabel === 'Property',
}) ??
objectLiteralBindingInfo ??
// R3-4: an anonymous literal in return position is owned by the
// function whose shape it is. Last in the chain so a variable-bound
// literal keeps its existing owner and its existing id.
(nodeLabel === 'Property' ? findReturnShapeOwnerInfo(definitionNode, file.path) : null))
: null;
const isArrayContainedObjectCallable =
!enclosingClassId &&
shouldObjectOwnerQualifyCallable(nodeLabel) &&
definitionNode !== undefined &&
isArrayContainedObjectLiteralMember(definitionNode);
// Provenance for narrowing (R3-4). A return shape is a real definition but
// the weaker one, and the unique-name pass ranks declared anchors above it
// so indexing these cannot change an answer that already resolved.
@ -2492,7 +2548,9 @@ const processFileGroup = (
// define `bar` stay distinct nodes.
objectLiteralOwnerInfo?.ownerName !== undefined
? `${objectLiteralOwnerInfo.ownerName}.${nodeName}`
: nodeName;
: isArrayContainedObjectCallable
? positionQualifiedCallableName(nodeName, startPosition)
: nodeName;
// #2742: qualify by the enclosing `mod` chain, so two same-named items at
// different module depths in one file are DISTINCT nodes. Without this,
@ -2821,19 +2879,42 @@ const processFileGroup = (
}
}
const isExported =
language === SupportedLanguages.Vue && isVueSetup
? isVueSetupTopLevel(nameNode || definitionNode)
: cachedExportCheck(provider.exportChecker, nameNode || definitionNode, nodeName);
if (definitionNode && provider.definitionPropertiesExtractor) {
const definitionProperties = runDefinitionPropertiesExtractor(
provider.definitionPropertiesExtractor,
{
nodeLabel,
nodeName,
definitionNode,
parsedImports: parsedFile?.parsedImports ?? [],
isExported,
},
(error) =>
reportWarning(
`Definition property extraction failed for ${file.path}:${nodeName}: ${error instanceof Error ? error.message : String(error)}`,
),
);
if (definitionProperties !== undefined) Object.assign(methodProps, definitionProperties);
}
result.nodes.push({
id: nodeId,
label: nodeLabel,
properties: {
properties: mergeCanonicalDefinitionProperties(methodProps, {
name: nodeName,
filePath: file.path,
startLine,
...(shouldObjectOwnerQualifyCallable(nodeLabel) &&
(objectLiteralBindingInfo?.ownerName || isArrayContainedObjectCallable)
? { startColumn }
: {}),
endLine: definitionNode ? definitionNode.endPosition.row + lineOffset : startLine,
language: language,
isExported:
language === SupportedLanguages.Vue && isVueSetup
? isVueSetupTopLevel(nameNode || definitionNode)
: cachedExportCheck(provider.exportChecker, nameNode || definitionNode, nodeName),
isExported,
...(qualifiedTypeName !== undefined ? { qualifiedName: qualifiedTypeName } : {}),
...(classTemplateArguments !== undefined && classTemplateArguments.length > 0
? { templateArguments: classTemplateArguments }
@ -2848,10 +2929,9 @@ const processFileGroup = (
}
: {}),
...(description !== undefined ? { description } : {}),
...methodProps,
...(declaredType !== undefined ? { declaredType } : {}),
...(returnShapeProperty ? { fromReturnShape: true, isDetail: true } : {}),
},
}),
});
// enclosingClassId already computed above (before nodeId generation)
@ -2896,8 +2976,12 @@ const processFileGroup = (
: {}),
});
// Only emit File -> Symbol DEFINES for top-level symbols (issue #1944).
if (ownerId === undefined) {
// Object-literal callables remain file definitions as well as members of
// their exported binding. Class members still use HAS_METHOD alone.
const isTopLevelObjectCallable =
objectLiteralBindingInfo?.ownerName !== undefined &&
shouldObjectOwnerQualifyCallable(nodeLabel);
if (ownerId === undefined || isTopLevelObjectCallable) {
const fileId = generateId('File', file.path);
const relId = generateId('DEFINES', `${fileId}->${nodeId}`);
result.relationships.push({
@ -2920,7 +3004,7 @@ const processFileGroup = (
type: memberEdgeType,
confidence: 1.0,
reason: objectLiteralOwnerInfo
? 'object literal method belongs to exported object binding'
? 'object literal member belongs to exported object binding'
: '',
});
}

View file

@ -483,7 +483,7 @@ export const streamAllCSVsToDisk = async (
const codeElementHeader = 'id,name,filePath,startLine,endLine,isExported,content,description';
const functionWriter = new BufferedCSVWriter(
path.join(csvDir, 'function.csv'),
codeElementHeader,
`${codeElementHeader},convexEndpointFactory`,
);
const classWriter = new BufferedCSVWriter(
path.join(csvDir, 'class.csv'),
@ -536,6 +536,7 @@ export const streamAllCSVsToDisk = async (
// Multi-language node types share the same CSV shape (no isExported column)
const multiLangHeader = 'id,name,filePath,startLine,endLine,content,description';
const constHeader = `${multiLangHeader},convexEndpointFactory`;
const MULTI_LANG_TYPES = [
'Struct',
'Enum',
@ -565,7 +566,7 @@ export const streamAllCSVsToDisk = async (
t,
new BufferedCSVWriter(
path.join(csvDir, `${t.toLowerCase()}.csv`),
t === 'Property' ? propertyHeader : multiLangHeader,
t === 'Property' ? propertyHeader : t === 'Const' ? constHeader : multiLangHeader,
),
);
}
@ -734,6 +735,8 @@ export const streamAllCSVsToDisk = async (
];
if (node.label === 'Class') {
row.push(escapeCSVField(formatCSVStringArray(node.properties.frameworkAnnotations)));
} else if (node.label === 'Function') {
row.push(escapeCSVField(String(node.properties.convexEndpointFactory ?? '')));
}
pending = writer.addRow(row.join(','));
} else {
@ -758,7 +761,9 @@ export const streamAllCSVsToDisk = async (
// empty BOOLEAN cell fails the COPY.
node.properties.isDetail === true ? 'true' : 'false',
]
: []),
: node.label === 'Const'
? [escapeCSVField(String(node.properties.convexEndpointFactory ?? ''))]
: []),
].join(','),
);
} else {

View file

@ -1547,9 +1547,15 @@ export const getCopyQuery = (table: NodeTableName, filePath: string): string =>
if (table === 'Method') {
return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, description, parameterCount, returnType) FROM "${filePath}" ${COPY_CSV_OPTS}`;
}
if (table === 'Function') {
return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, description, convexEndpointFactory) FROM "${filePath}" ${COPY_CSV_OPTS}`;
}
if (table === 'Property') {
return `COPY ${t}(id, name, filePath, startLine, endLine, content, description, declaredType, isDetail) FROM "${filePath}" ${COPY_CSV_OPTS}`;
}
if (table === 'Const') {
return `COPY ${t}(id, name, filePath, startLine, endLine, content, description, convexEndpointFactory) FROM "${filePath}" ${COPY_CSV_OPTS}`;
}
// TypeScript/JS code element tables have isExported; multi-language tables do not
if (TABLES_WITH_EXPORTED.has(table)) {
return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, description) FROM "${filePath}" ${COPY_CSV_OPTS}`;
@ -1602,6 +1608,16 @@ export const insertNodeToLbug = async (
? `, description: ${formatCypherValue(properties.description)}`
: '';
query = `CREATE (n:Class {id: ${formatCypherValue(properties.id)}, name: ${formatCypherValue(properties.name)}, filePath: ${formatCypherValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, isExported: ${!!properties.isExported}, content: ${formatCypherValue(properties.content || '')}${descPart}, frameworkAnnotations: ${formatCypherStringArray(properties.frameworkAnnotations)}})`;
} else if (label === 'Function') {
const descPart = properties.description
? `, description: ${formatCypherValue(properties.description)}`
: '';
query = `CREATE (n:Function {id: ${formatCypherValue(properties.id)}, name: ${formatCypherValue(properties.name)}, filePath: ${formatCypherValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, isExported: ${!!properties.isExported}, content: ${formatCypherValue(properties.content || '')}${descPart}, convexEndpointFactory: ${formatCypherValue(properties.convexEndpointFactory ?? '')}})`;
} else if (label === 'Const') {
const descPart = properties.description
? `, description: ${formatCypherValue(properties.description)}`
: '';
query = `CREATE (n:Const {id: ${formatCypherValue(properties.id)}, name: ${formatCypherValue(properties.name)}, filePath: ${formatCypherValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, content: ${formatCypherValue(properties.content || '')}${descPart}, convexEndpointFactory: ${formatCypherValue(properties.convexEndpointFactory ?? '')}})`;
} else if (TABLES_WITH_EXPORTED.has(label)) {
const descPart = properties.description
? `, description: ${formatCypherValue(properties.description)}`
@ -1692,6 +1708,16 @@ export const batchInsertNodesToLbug = async (
? `, n.description = ${formatCypherValue(properties.description)}`
: '';
query = `MERGE (n:Class {id: ${formatCypherValue(properties.id)}}) SET n.name = ${formatCypherValue(properties.name)}, n.filePath = ${formatCypherValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.isExported = ${!!properties.isExported}, n.content = ${formatCypherValue(properties.content || '')}${descPart}, n.frameworkAnnotations = ${formatCypherStringArray(properties.frameworkAnnotations)}`;
} else if (label === 'Function') {
const descPart = properties.description
? `, n.description = ${formatCypherValue(properties.description)}`
: '';
query = `MERGE (n:Function {id: ${formatCypherValue(properties.id)}}) SET n.name = ${formatCypherValue(properties.name)}, n.filePath = ${formatCypherValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.isExported = ${!!properties.isExported}, n.content = ${formatCypherValue(properties.content || '')}${descPart}, n.convexEndpointFactory = ${formatCypherValue(properties.convexEndpointFactory ?? '')}`;
} else if (label === 'Const') {
const descPart = properties.description
? `, n.description = ${formatCypherValue(properties.description)}`
: '';
query = `MERGE (n:Const {id: ${formatCypherValue(properties.id)}}) SET n.name = ${formatCypherValue(properties.name)}, n.filePath = ${formatCypherValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.content = ${formatCypherValue(properties.content || '')}${descPart}, n.convexEndpointFactory = ${formatCypherValue(properties.convexEndpointFactory ?? '')}`;
} else if (TABLES_WITH_EXPORTED.has(label)) {
const descPart = properties.description
? `, n.description = ${formatCypherValue(properties.description)}`

View file

@ -135,6 +135,10 @@ interface SharedDB {
* scan (#2623 follow-up). Optional with `?? false` semantics so the
* construction sites stay minimal. */
vectorLoaded?: boolean;
/** In-flight/completed lazy VECTOR probe for this Database lifecycle.
* Retaining a false result prevents every semantic request from retrying
* the same unavailable extension; teardown clears it before a reopen. */
vectorLoadPromise?: Promise<boolean>;
/** File identity at open — used to detect reuse of a shared read-only handle
* whose on-disk index was rebuilt/swapped since it opened (only reachable
* when a second pool consumer shares this dbPath; #2614 F2). */
@ -368,6 +372,7 @@ function closeOne(repoId: string): void {
shared.refCount = 0;
shared.ftsLoaded = false;
shared.vectorLoaded = false;
shared.vectorLoadPromise = undefined;
} else {
shared.db.close().catch(() => {});
dbCache.delete(entry.dbPath);
@ -823,14 +828,6 @@ async function doInitLbug(repoId: string, dbPath: string): Promise<void> {
if (!shared.ftsLoaded) {
shared.ftsLoaded = await loadFTSExtension(available[0], { policy: 'load-only' });
}
// VECTOR too — extension load scope is per-Database, so this one load
// makes QUERY_VECTOR_INDEX legal on every pooled connection. Same
// load-only contract as FTS above; on failure the semantic-query lane
// falls back to the exact scan with its own diagnostic (#2623 follow-up).
if (!shared.vectorLoaded) {
shared.vectorLoaded = await loadVectorExtension(available[0], { policy: 'load-only' });
}
// Register pool entry only after all connections are pre-warmed and FTS is
// loaded. Concurrent executeQuery calls see either "not initialized"
// (and throw cleanly) or a fully ready pool — never a half-built one.
@ -900,12 +897,6 @@ export async function initLbugWithDb(
if (!shared.ftsLoaded) {
shared.ftsLoaded = await loadFTSExtension(available[0], { policy: 'load-only' });
}
// VECTOR too — same per-Database scope and load-only contract as the
// doInitLbug site above (#2623 follow-up).
if (!shared.vectorLoaded) {
shared.vectorLoaded = await loadVectorExtension(available[0], { policy: 'load-only' });
}
pool.set(repoId, {
db: existingDb,
available,
@ -921,6 +912,143 @@ export async function initLbugWithDb(
traceRss('init', repoId);
}
/**
* Lazily load VECTOR for a semantic query.
*
* Exact graph reads never call this function, so opening their read pool does
* not probe or warn about an optional extension they do not use. The promise
* lives on SharedDB because extension scope is per Database, and also joins
* concurrent first semantic requests onto one LOAD attempt.
*/
export async function ensureVectorExtension(repoId: string): Promise<boolean> {
const entry = pool.get(repoId);
if (!entry) {
throw new Error(`LadybugDB not initialized for repo "${repoId}". Call initLbug first.`);
}
const shared = dbCache.get(entry.dbPath);
if (!shared) {
throw new Error(`LadybugDB shared handle is unavailable for repo "${repoId}".`);
}
if (shared.vectorLoaded) return true;
if (shared.vectorLoadPromise) return shared.vectorLoadPromise;
const loadAttempt = (async () => {
const conn = await checkout(entry);
try {
const loaded = await loadVectorExtension(conn, { policy: 'load-only' });
shared.vectorLoaded = loaded;
return loaded;
} finally {
checkin(entry, conn);
}
})();
const cachedAttempt = loadAttempt.catch((err) => {
// A transient checkout/load failure must not poison this Database for the
// rest of its lifetime. Keep resolved false cached, but let a later
// semantic request retry a rejected attempt.
if (shared.vectorLoadPromise === cachedAttempt) {
shared.vectorLoadPromise = undefined;
}
throw err;
});
shared.vectorLoadPromise = cachedAttempt;
return shared.vectorLoadPromise;
}
/**
* Detect an actual VECTOR procedure call without treating source text stored in
* Cypher literals or comments as executable syntax.
*/
function callsVectorIndex(cypher: string): boolean {
if (!/QUERY_VECTOR_INDEX/i.test(cypher)) return false;
let code = '';
let state: 'code' | 'single' | 'double' | 'backtick' | 'line-comment' | 'block-comment' = 'code';
let backtickIdentifier = '';
for (let i = 0; i < cypher.length; i++) {
const ch = cypher[i];
const next = cypher[i + 1];
if (state === 'code') {
if (ch === "'" || ch === '"' || ch === '`') {
state = ch === "'" ? 'single' : ch === '"' ? 'double' : 'backtick';
if (state === 'backtick') backtickIdentifier = '';
code += ' ';
} else if (ch === '/' && next === '/') {
state = 'line-comment';
code += ' ';
i++;
} else if (ch === '/' && next === '*') {
state = 'block-comment';
code += ' ';
i++;
} else {
code += ch;
}
continue;
}
if (state === 'line-comment') {
if (ch === '\n' || ch === '\r') {
state = 'code';
code += ch;
} else {
code += ' ';
}
continue;
}
if (state === 'block-comment') {
if (ch === '*' && next === '/') {
state = 'code';
code += ' ';
i++;
} else {
code += ch === '\n' || ch === '\r' ? ch : ' ';
}
continue;
}
if (state === 'backtick') {
if (ch === '`' && next === '`') {
backtickIdentifier += '`';
code += ' ';
i++;
} else if (ch === '`') {
state = 'code';
code +=
backtickIdentifier.toUpperCase() === 'QUERY_VECTOR_INDEX' ? 'QUERY_VECTOR_INDEX' : ' ';
} else if (ch === '\\' && next !== undefined) {
backtickIdentifier += next;
code += ' ';
i++;
} else {
backtickIdentifier += ch;
code += ch === '\n' || ch === '\r' ? ch : ' ';
}
continue;
}
if (ch === '\\') {
code += ' ';
if (next !== undefined) {
code += next === '\n' || next === '\r' ? next : ' ';
i++;
}
continue;
}
const closesLiteral = (state === 'single' && ch === "'") || (state === 'double' && ch === '"');
if (closesLiteral) state = 'code';
code += ch === '\n' || ch === '\r' ? ch : ' ';
}
return /\bCALL\s+QUERY_VECTOR_INDEX\s*\(/i.test(code);
}
/**
* Checkout a connection from the pool.
* Returns an available connection, or creates a new one if under the cap.
@ -1028,14 +1156,31 @@ export const executeParameterized = async (
poolSidecarLogger.warn(message),
);
const entry = pool.get(repoId);
let entry = pool.get(repoId);
if (!entry) {
throw new Error(`LadybugDB not initialized for repo "${repoId}". Call initLbug first.`);
}
entry.lastUsed = Date.now();
// Exact reads must not pay for VECTOR, but an explicit raw vector procedure
// call is a semantic read. Preflight before taking the query connection:
// ensureVectorExtension performs its own checkout, so holding one here could
// make a saturated pool wait for a connection that every caller is holding.
// A load rejection must not replace the query's own diagnostic.
if (callsVectorIndex(cypher)) {
await ensureVectorExtension(repoId).catch(() => false);
// The preflight suspends, so close/re-init may replace the pool entry.
// Re-read it before checkout to avoid querying through a stale handle.
entry = pool.get(repoId);
if (!entry) {
throw new Error(
`LadybugDB connection pool closed for repo "${repoId}" (re-init/teardown); retry the query.`,
);
}
}
const conn = await checkout(entry);
entry.lastUsed = Date.now();
silenceStdout();
activeQueryCount++;
let queryResult: lbug.QueryResult | lbug.QueryResult[] | undefined;

View file

@ -51,6 +51,7 @@ CREATE NODE TABLE Function (
isExported BOOLEAN,
content STRING,
description STRING,
convexEndpointFactory STRING,
PRIMARY KEY (id)
)`;
@ -170,7 +171,18 @@ export const NAMESPACE_SCHEMA = CODE_ELEMENT_BASE('Namespace');
export const TRAIT_SCHEMA = CODE_ELEMENT_BASE('Trait');
export const IMPL_SCHEMA = CODE_ELEMENT_BASE('Impl');
export const TYPE_ALIAS_SCHEMA = CODE_ELEMENT_BASE('TypeAlias');
export const CONST_SCHEMA = CODE_ELEMENT_BASE('Const');
export const CONST_SCHEMA = `
CREATE NODE TABLE \`Const\` (
id STRING,
name STRING,
filePath STRING,
startLine INT64,
endLine INT64,
content STRING,
description STRING,
convexEndpointFactory STRING,
PRIMARY KEY (id)
)`;
export const STATIC_SCHEMA = CODE_ELEMENT_BASE('Static');
export const VARIABLE_SCHEMA = CODE_ELEMENT_BASE('Variable');
export const PROPERTY_SCHEMA = `

View file

@ -0,0 +1,72 @@
import { executeParameterized } from '../../core/lbug/pool-adapter.js';
import { logger } from '../../core/logger.js';
export interface ConvexDispatchMetadata {
readonly factory?: string;
readonly boundary: string;
readonly staleIndex?: true;
readonly probeFailed?: true;
}
function isMissingConvexMetadataProperty(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error ?? '');
return /cannot find property\s+convexEndpointFactory|property[^\n]*convexEndpointFactory[^\n]*not (?:defined|found)/i.test(
message,
);
}
export async function queryConvexDispatchMetadata(
lbugPath: string,
symbolId: string,
symbolName: string,
symbolType: string,
runQuery: typeof executeParameterized = executeParameterized,
): Promise<ConvexDispatchMetadata | undefined> {
if (symbolType !== 'Const' && symbolType !== 'Function') return undefined;
const nodeLabel = symbolType === 'Function' ? 'Function' : 'Const';
try {
const rows = await runQuery(
lbugPath,
`MATCH (n:${nodeLabel} {id: $symbolId})
RETURN n.convexEndpointFactory AS factory`,
{ symbolId },
);
const row = rows[0];
if (row === undefined) return undefined;
const factory = String(row.factory ?? row[0] ?? '');
if (factory.length === 0) return undefined;
return {
factory,
boundary:
`${symbolName} is exported through Convex ${factory}({...}) and can be addressed through ` +
`the anyApi runtime proxy; callers across that dynamic-dispatch boundary leave no static ` +
`edge, so actual impact may be higher.`,
};
} catch (error) {
if (isMissingConvexMetadataProperty(error)) {
return {
staleIndex: true,
boundary:
'Convex runtime-proxy metadata is unavailable because this index predates ' +
'convexEndpointFactory; re-index before treating impact as exact.',
};
}
logger.warn(
{
context: 'impact:convex-metadata',
err: error instanceof Error ? error.message : String(error),
},
'GitNexus Convex metadata probe failed (degraded)',
);
return {
probeFailed: true,
boundary:
'Convex runtime-proxy metadata could not be checked; impact remains a lower bound ' +
'until the metadata probe succeeds.',
};
}
}

View file

@ -13,6 +13,7 @@ import {
initLbug,
executeQuery,
executeParameterized,
ensureVectorExtension,
closeLbug,
isLbugReady,
statDbIdentity,
@ -20,6 +21,7 @@ import {
} from '../../core/lbug/pool-adapter.js';
import { queryClassBeanMetadata } from './bean-metadata.js';
import { querySpringAopMetadata } from './aop-metadata.js';
import { queryConvexDispatchMetadata } from './convex-metadata.js';
import { isValidQueryParams } from '../../core/lbug/query-params.js';
import { toDisplayLine } from './line-display.js';
import { LBUG_ID_PROBE_BATCH_SIZE, LBUG_QUERY_BATCH_SIZE } from '../../core/lbug/query-batch.js';
@ -659,9 +661,8 @@ export interface EpistemicCauses {
*/
readonly receiverTyping: number;
/**
* Symbols on the far side of a dispatch boundary that the traversal could not
* attribute to the queried symbol: implementations plus interface-level
* consumers, summed over the boundary nodes that were flagged.
* Symbols on or beyond a dispatch boundary that the traversal could not
* attribute statically: implementations plus interface-level consumers.
*
* Unit: SYMBOLS, not call sites — deliberately, because a call-site count is
* not derivable on this side. The graph does not retain per-site multiplicity
@ -671,6 +672,10 @@ export interface EpistemicCauses {
* symbol reachable through two flagged boundary nodes is counted once per
* node, so this is itself a lower bound.
*
* Framework runtime-proxy metadata can prove that impact is incomplete but
* cannot provide this magnitude, so it contributes a boundary note while
* leaving this count unchanged.
*
* It is still directly comparable in magnitude with `receiverTyping` — both
* answer "how much is missing" — which `boundaries.length` was not.
*/
@ -711,6 +716,7 @@ function epistemicFrom(dropped: {
sites: number;
external: number;
undecided: number;
dispatch: number;
}): {
epistemic: 'exact' | 'lower-bound';
boundaries?: string[];
@ -725,7 +731,7 @@ function epistemicFrom(dropped: {
epistemic: 'exact',
causes: {
receiverTyping: 0,
dispatchBoundary: 0,
dispatchBoundary: dropped.dispatch,
externalBoundary: dropped.external,
undecidedSatisfaction: 0,
},
@ -740,7 +746,7 @@ function epistemicFrom(dropped: {
// would read a different magnitude than the human reading the text.
causes: {
receiverTyping: dropped.sites,
dispatchBoundary: 0,
dispatchBoundary: dropped.dispatch,
externalBoundary: dropped.external,
undecidedSatisfaction: dropped.undecided,
},
@ -1248,12 +1254,12 @@ export class LocalBackend {
private warnedSiblingDrift: Set<string> = new Set();
/**
* One-shot stderr warning for the VECTOR-extension fallback. Without this
* guard the diagnostic would fire on every `semanticSearch()` call on
* platforms where the extension is unsupported (e.g. Windows), making MCP
* stderr noisy per DoD §2.8.
* One-shot stderr guards for distinct VECTOR load and index-query failures.
* Keeping them separate preserves both diagnostics across semanticSearch calls
* without repeating either on hot paths.
*/
private warnedVectorUnsupported = false;
private warnedVectorLoadFailed = false;
private warnedVectorQueryFailed = false;
/**
* One-shot warning when a pruned or Node-unloadable optional embedding stack
@ -3214,21 +3220,31 @@ export class LocalBackend {
this.lastQueryEmbeddingDims.set(repo.lbugPath, dims);
const queryVecStr = `[${queryVec.join(',')}]`;
const maxDistance = getVectorMaxDistance(DEFAULT_MCP_VECTOR_MAX_DISTANCE);
let vectorReady = false;
try {
vectorReady = await ensureVectorExtension(repo.lbugPath);
} catch (err) {
if (!this.warnedVectorLoadFailed) {
this.warnedVectorLoadFailed = true;
logger.warn(
{ err },
'GitNexus [query:vector]: vector extension load failed; using exact scan fallback',
);
}
}
let bestChunks = new Map<
string,
{ distance: number; chunkIndex: number; startLine: number; endLine: number }
>();
// Always TRY the vector lane — no platform gate. LadybugDB ships the
// VECTOR extension for every supported platform, Windows included
// (#2623 follow-up; the old `platform !== 'win32'` gate was stale), so
// whether the index is queryable is a per-machine runtime fact. The
// catch below is the fallback: any failure (extension unloadable, index
// absent, older DB) degrades to the exact scan with a once-per-backend
// diagnostic instead of being silently swallowed.
try {
bestChunks = await collectBestChunks(limit, async (fetchLimit) => {
const vectorQuery = `
// Try the vector lane only after its lazy load succeeds. An unavailable
// extension is already reported by ExtensionManager; an index/query
// failure below gets its own once-per-backend diagnostic before the exact
// scan fallback.
if (vectorReady) {
try {
bestChunks = await collectBestChunks(limit, async (fetchLimit) => {
const vectorQuery = `
CALL QUERY_VECTOR_INDEX('${EMBEDDING_TABLE_NAME}', '${EMBEDDING_INDEX_NAME}',
CAST(${queryVecStr} AS FLOAT[${dims}]), ${fetchLimit})
YIELD node AS emb, distance
@ -3239,26 +3255,27 @@ export class LocalBackend {
ORDER BY distance
`;
const embResults = await executeQuery(repo.lbugPath, vectorQuery);
return embResults.map((row) => ({
nodeId: row.nodeId ?? row[0],
chunkIndex: row.chunkIndex ?? row[1] ?? 0,
startLine: row.startLine ?? row[2] ?? 0,
endLine: row.endLine ?? row[3] ?? 0,
distance: row.distance ?? row[4],
}));
});
} catch (err) {
bestChunks = new Map();
if (!this.warnedVectorUnsupported) {
// Rare diagnostic: surface why semantic search fell back to the
// exact scan. Emitted once per `LocalBackend` instance lifetime to
// avoid noisy stderr on hot semantic-search paths (DoD §2.8).
this.warnedVectorUnsupported = true;
logger.warn(
{ err },
'GitNexus [query:vector]: vector index query failed; using exact scan fallback',
);
const embResults = await executeQuery(repo.lbugPath, vectorQuery);
return embResults.map((row) => ({
nodeId: row.nodeId ?? row[0],
chunkIndex: row.chunkIndex ?? row[1] ?? 0,
startLine: row.startLine ?? row[2] ?? 0,
endLine: row.endLine ?? row[3] ?? 0,
distance: row.distance ?? row[4],
}));
});
} catch (err) {
bestChunks = new Map();
if (!this.warnedVectorQueryFailed) {
// Rare diagnostic: surface why semantic search fell back to the
// exact scan. Emitted once per `LocalBackend` instance lifetime to
// avoid noisy stderr on hot semantic-search paths (DoD §2.8).
this.warnedVectorQueryFailed = true;
logger.warn(
{ err },
'GitNexus [query:vector]: vector index query failed; using exact scan fallback',
);
}
}
}
@ -6356,6 +6373,7 @@ export class LocalBackend {
summaryOnly: true,
skipEpistemic: true,
skipEnrichment: true,
hasExplicitRelationTypes,
},
);
} catch (e) {
@ -6610,6 +6628,7 @@ export class LocalBackend {
limit: Number.isFinite(params.limit) ? params.limit : 100,
offset: Number.isFinite(params.offset) ? params.offset : 0,
pdgBridge,
hasExplicitRelationTypes,
});
return composeUnifiedPdgImpactResult(pdgResult, interproceduralResult);
} catch (e) {
@ -6626,6 +6645,7 @@ export class LocalBackend {
limit: Number.isFinite(params.limit) ? params.limit : 100,
offset: Number.isFinite(params.offset) ? params.offset : 0,
summaryOnly: params.summaryOnly,
hasExplicitRelationTypes,
});
}
@ -6775,6 +6795,7 @@ export class LocalBackend {
symId: string,
symType: string,
symName: string,
direction?: 'upstream' | 'downstream',
): Promise<{
epistemic: 'exact' | 'lower-bound';
boundaries?: string[];
@ -6811,6 +6832,19 @@ export class LocalBackend {
// the owning-type hop below would be a graph round-trip per method query in
// every index that has no such record — which is every non-Go one, since Go
// is the only language with a structural-satisfaction hook.
const convexDispatchPromise =
direction === 'downstream'
? Promise.resolve(undefined)
: queryConvexDispatchMetadata(repo.lbugPath, symId, symName, symType);
const interfaceRowsPromise = executeParameterized(
repo.lbugPath,
`MATCH (x)-[r:CodeRelation]->(iface)
WHERE x.id = $symId AND r.type IN $heritage
RETURN DISTINCT iface.id AS id, iface.name AS name, labels(iface)[0] AS label
ORDER BY id
LIMIT 25`,
{ symId, heritage: HERITAGE_TYPES },
).catch(() => []);
const undecidedSummary = meta?.undecidedInterfaceSatisfaction;
const undecidedDrops =
undecidedSummary === undefined
@ -6821,10 +6855,19 @@ export class LocalBackend {
? await this.owningTypeNames(repo, symId)
: []),
]);
const convexDispatch = await convexDispatchPromise;
const droppedBoundaries = {
...receiverDrops,
notes: [...receiverDrops.notes, ...undecidedDrops.notes],
notes: [
...receiverDrops.notes,
...undecidedDrops.notes,
...(convexDispatch === undefined ? [] : [convexDispatch.boundary]),
],
undecided: undecidedDrops.undecided,
// Endpoint/probe evidence proves incompleteness but does not expose a
// count of omitted symbols. Keep the magnitude at zero rather than
// inventing one from the presence of a note.
dispatch: 0,
};
try {
// Discover the interface / abstract supertypes on the target's boundary.
@ -6833,15 +6876,7 @@ export class LocalBackend {
if (symType === 'Interface') {
boundary.set(symId, { name: symName || '', label: 'Interface' });
}
const ifaceRows = await executeParameterized(
repo.lbugPath,
`MATCH (x)-[r:CodeRelation]->(iface)
WHERE x.id = $symId AND r.type IN $heritage
RETURN DISTINCT iface.id AS id, iface.name AS name, labels(iface)[0] AS label
ORDER BY id
LIMIT 25`,
{ symId, heritage: HERITAGE_TYPES },
).catch(() => []);
const ifaceRows = await interfaceRowsPromise;
for (const r of ifaceRows) {
const id = (r.id ?? r[0]) as string;
if (id && !boundary.has(id)) {
@ -6920,7 +6955,7 @@ export class LocalBackend {
boundaries: [...droppedBoundaries.notes, ...boundaries],
causes: {
receiverTyping: droppedBoundaries.sites,
dispatchBoundary: dispatchBoundarySymbols,
dispatchBoundary: droppedBoundaries.dispatch + dispatchBoundarySymbols,
externalBoundary: droppedBoundaries.external,
undecidedSatisfaction: droppedBoundaries.undecided,
},
@ -6984,6 +7019,8 @@ export class LocalBackend {
skipEpistemic?: boolean;
skipEnrichment?: boolean;
pdgBridge?: PdgBridgeOptions;
/** Preserve an explicit caller filter; implicit structural seeds must not widen it. */
hasExplicitRelationTypes?: boolean;
},
): Promise<any> {
const { maxDepth, relationTypes, includeTests, minConfidence } = opts;
@ -7027,7 +7064,13 @@ export class LocalBackend {
causes?: EpistemicCauses;
}> = opts.skipEpistemic
? Promise.resolve({})
: this.computeEpistemicBoundary(repo, symId, symType, (sym.name || sym[1]) as string);
: this.computeEpistemicBoundary(
repo,
symId,
symType,
(sym.name || sym[1]) as string,
direction,
);
const beanMetadataPromise =
opts.skipEpistemic || summaryOnly
? Promise.resolve(undefined)
@ -7040,7 +7083,11 @@ export class LocalBackend {
const visited = new Set<string>([symId]);
const pdgBridgeEvidenceById = new Map<string, PdgBridgeEvidenceInfo>();
let frontier = [symId];
const objectCallableFrontier: string[] = [];
let traversalComplete = true;
// Fetch one sentinel row beyond the cap so generated object bindings
// degrade visibly instead of allocating an unbounded seed frontier.
const OBJECT_CALLABLE_MEMBER_CAP = 5000;
// Fix #480: For Java (and other JVM) Class/Interface nodes, CALLS edges
// point to Constructor nodes and IMPORTS edges point to File nodes — not
@ -7115,6 +7162,63 @@ export class LocalBackend {
}
} catch (e) {
logQueryError('impact:class-node-expansion', e);
traversalComplete = false;
}
}
// Function-valued properties on exported object bindings are represented
// as Const/Variable -[:HAS_METHOD]-> Function. HAS_METHOD is intentionally
// absent from the default usage traversal, but downstream impact on the
// binding still needs to enter its own callable member before following
// CALLS.
if (
direction === 'downstream' &&
(symType === 'Const' || symType === 'Variable') &&
relationTypes.includes('CALLS') &&
!relationTypes.includes('HAS_METHOD') &&
!opts.hasExplicitRelationTypes
) {
try {
const memberRows = await executeParameterized(
repo.lbugPath,
`
MATCH (n)-[hm:CodeRelation]->(member:Function)
WHERE n.id = $symId AND hm.type = 'HAS_METHOD'
RETURN DISTINCT member.id AS id, member.name AS name,
'Function' AS type, member.filePath AS filePath
ORDER BY id
LIMIT ${OBJECT_CALLABLE_MEMBER_CAP + 1}
UNION ALL
MATCH (n)-[hm:CodeRelation]->(member:Method)
WHERE n.id = $symId AND hm.type = 'HAS_METHOD'
RETURN DISTINCT member.id AS id, member.name AS name,
'Method' AS type, member.filePath AS filePath
ORDER BY id
LIMIT ${OBJECT_CALLABLE_MEMBER_CAP + 1}
`,
{ symId },
);
memberRows.sort((a, b) => compareCodeUnits(String(a.id ?? a[0]), String(b.id ?? b[0])));
if (memberRows.length > OBJECT_CALLABLE_MEMBER_CAP) traversalComplete = false;
for (const row of memberRows.slice(0, OBJECT_CALLABLE_MEMBER_CAP)) {
const memberId = row.id || row[0];
if (memberId && !visited.has(memberId)) {
visited.add(memberId);
objectCallableFrontier.push(memberId);
impacted.push({
depth: 1,
id: memberId,
name: row.name || row[1],
type: row.type || row[2],
filePath: row.filePath || row[3] || '',
relationType: 'HAS_METHOD',
confidence: 1,
});
}
}
} catch (e) {
logQueryError('impact:object-callable-expansion', e);
traversalComplete = false;
}
}
@ -7269,7 +7373,10 @@ export class LocalBackend {
break;
}
frontier = nextFrontier;
frontier =
depth === 1 && objectCallableFrontier.length > 0
? [...new Set([...nextFrontier, ...objectCallableFrontier])]
: nextFrontier;
}
// Stamp the finalized, order-independent bridge evidence (strongest across
@ -7883,6 +7990,7 @@ export class LocalBackend {
// the #1858 epistemic/boundaries fields — computing them per neighbor is
// dead work on the highest-volume path, so suppress them here too.
skipEpistemic: true,
hasExplicitRelationTypes: opts.relationTypes.length > 0,
});
} catch {
return null;

View file

@ -97,7 +97,26 @@ export function getResourceTemplates(): ResourceTemplate[] {
{
uriTemplate: 'gitnexus://group/{name}/status',
name: 'Group Index Status',
description: 'Per-repo index and contract-registry staleness for a repository group',
// The payload is a bare serialization, so nothing in it says which of
// three states a reader is looking at. Both distinctions below are
// additive fields whose meaning is invisible without this: `missing`
// alone cannot separate "not registered" from "registry unreadable", and
// an omitted `unreadableRepos` key looks exactly like a measured zero.
description:
'Per-repo index and contract-registry staleness for a repository group. ' +
'Every configured repo carries both `missing` and `unresolvable`: a repo genuinely absent ' +
'from the global registry is missing:true with unresolvable:false; a repo whose registry ' +
'entry could not be read or resolved is unresolvable:true with an unresolvableReason ' +
'(missing stays true there too, so a consumer written before the split still sees every ' +
'unusable repo); a healthy repo is neither. The group-level unreadableRepos list is ' +
'three-state, and an ABSENT key is not an empty one: absent means the last sync never ' +
'recorded which repos it could read (provenance unknown — treat cross-repo answers for ' +
'this group as a floor), an empty list means the sync measured none, and a populated list ' +
'names the repos whose contracts are missing from the registry. suppressedMatchStages is ' +
'three-state the same way: absent is a registry predating the field, an empty list means ' +
'the sync skipped no matching stage, and a populated list names stages it was ASKED to ' +
'skip — those cross-link counts are a lower bound by request, and the remedy is to re-sync ' +
'without that flag rather than to repair a repo.',
mimeType: 'text/yaml',
},
];
@ -389,7 +408,12 @@ async function getContextResource(backend: LocalBackend, repoName?: string): Pro
lines.push(
' - gitnexus://group/{name}/contracts: Group contract registry (optional ?type=&repo=&unmatchedOnly=)',
);
lines.push(' - gitnexus://group/{name}/status: Group index / contract staleness');
lines.push(
' - gitnexus://group/{name}/status: Group index / contract staleness — separates a repo absent ' +
'from the registry (missing, not unresolvable) from one whose entry could not be read ' +
'(unresolvable + unresolvableReason), and carries unreadableRepos as absent=never recorded / ' +
'empty=measured none / populated=named',
);
return lines.join('\n');
}

View file

@ -290,9 +290,10 @@ COMPLETENESS OF incoming: alongside symbol/incoming/outgoing the result carries
- causes: { receiverTyping, dispatchBoundary, externalBoundary, undecidedSatisfaction } — machine-readable WHY. Every field counts MISSING THINGS, never sentences:
- causes.receiverTyping (unit: call sites) > 0 — RESOLVER GAP: the analyzer dropped that many call sites on this name because it could not type the receiver, so they are missing from incoming. Do not read an absent caller as proof none exists.
- causes.externalBoundary (unit: call sites) > 0 — the calls left the indexed program (System.out.println, fetch(...)). NOT a defect: no in-graph node could have been reached. An epistemic:'exact' result can carry this.
- causes.dispatchBoundary (unit: symbols) > 0 — DI / interface dispatch: implementations plus interface-level consumers behind a boundary static analysis cannot cross. Irreducible.
- causes.dispatchBoundary (unit: symbols) > 0 — DI or interface dispatch: that many symbols sit on or beyond a boundary static analysis cannot cross. Irreducible. A symbol count, not a site count — per-site multiplicity is not retained for these edges — so compare its magnitude with receiverTyping, not its exact value. A framework runtime-proxy boundary can make epistemic lower-bound while this value remains 0 because endpoint metadata proves the gap but cannot count omitted symbols.
- causes.undecidedSatisfaction (unit: unjudged interface/type pairs) > 0 — the analyzer could not decide whether a type satisfies an interface, so no IMPLEMENTS edge exists and no dispatch boundary was left for the walk to notice. Usually fixable by making the missing dependency available to analysis.
REQUIRES RE-INDEX: causes.receiverTyping and causes.externalBoundary come from index-time metadata only a current analyzer writes; against an older index they read as absent/0, which is indistinguishable from "nothing was dropped". Re-run \`gitnexus analyze\` before trusting a zero there.
REQUIRES RE-INDEX: causes.receiverTyping, causes.externalBoundary, causes.undecidedSatisfaction, and framework runtime-proxy boundary detection depend on index-time metadata that only a current analyzer writes. Against an older index the metadata can be absent, which is indistinguishable from "nothing was dropped" unless the schema probe detects the stale index — re-run \`gitnexus analyze\` before trusting a zero or an apparently exact result.
GROUP MODE: set "repo" to "@<groupName>" to run context in each member repo (aggregated list), or "@<groupName>/<groupRepoPath>" for one member. If you use "@<groupName>" only, the member defaults to the lexicographically first key in group.yaml "repos".
@ -484,11 +485,11 @@ Output includes:
- causes: { receiverTyping, dispatchBoundary, externalBoundary, undecidedSatisfaction } — the machine-readable split of WHY, so an agent gating its own edits can tell a fixable analyzer gap from an irreducible one. Every field counts MISSING THINGS, never sentences:
- causes.receiverTyping (unit: call sites) > 0 — the RESOLVER GAP signal: the analyzer dropped that many call sites because it could not establish the receiver's type (unresolved constructor, factory, chained expression). Those callers are absent from byDepth. Treat the result as incomplete: grep the symbol name before deleting or renaming.
- causes.externalBoundary (unit: call sites) > 0 — those calls left the indexed program (System.out.println, fetch(...), os.environ.*). NOT a defect and NOT a reason the count is short: there is no in-graph node any edge could have reached. An epistemic:'exact' result can carry this.
- causes.dispatchBoundary (unit: symbols) > 0 — DI / interface dispatch: that many implementations plus interface-level consumers sit on the far side of a boundary a static walk cannot cross. Irreducible; a compiler refuses here too. A symbol count, not a site count — per-site multiplicity is not retained for these edges — so compare its magnitude with receiverTyping, not its exact value.
- causes.dispatchBoundary (unit: symbols) > 0 — DI or interface dispatch: that many symbols sit on or beyond a boundary a static walk cannot cross. Irreducible. A symbol count, not a site count — per-site multiplicity is not retained for these edges — so compare its magnitude with receiverTyping, not its exact value. A framework runtime-proxy boundary can make epistemic lower-bound while this value remains 0 because endpoint metadata proves the gap but cannot count omitted symbols.
- causes.undecidedSatisfaction (unit: unjudged interface/type pairs) > 0 — the analyzer could not DECIDE whether a type satisfies an interface (a type in a required signature named a package it could not resolve), so no IMPLEMENTS edge exists and no dispatch boundary was left for the walk to notice. Distinct from every cause above, which count decided facts that could not be attributed; this one counts questions never answered. It is the only cause that shortens a result WITHOUT leaving a trace in the graph, so an unhedged zero on a symbol reached only through such an interface would otherwise read as 'nobody calls this'. Usually fixable: it most often means a dependency is missing from the analyzed tree.
REQUIRES RE-INDEX: causes.receiverTyping, causes.externalBoundary and causes.undecidedSatisfaction are read from index-time metadata that only a current analyzer writes. Against an older index they read as absent/0, which is indistinguishable from "nothing was dropped" — re-run \`gitnexus analyze\` before trusting a zero there.
REQUIRES RE-INDEX: causes.receiverTyping, causes.externalBoundary, causes.undecidedSatisfaction, and framework runtime-proxy boundary detection depend on index-time metadata that only a current analyzer writes. Against an older index the metadata can be absent, which is indistinguishable from "nothing was dropped" unless the schema probe detects the stale index — re-run \`gitnexus analyze\` before trusting a zero or an apparently exact result.
Depth groups:
- d=1: WILL BREAK (direct callers/importers)
@ -504,7 +505,7 @@ Handles disambiguation: when multiple symbols share the target name, returns ran
EdgeType: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, HAS_METHOD, HAS_PROPERTY, METHOD_OVERRIDES, METHOD_IMPLEMENTS, ACCESSES
Confidence: 1.0 = certain, <0.8 = fuzzy match
GROUP MODE: set "repo" to "@<groupName>" for cross-repo impact anchored at the default member (lexicographically first key in group.yaml "repos"), or "@<groupName>/<groupRepoPath>" to choose the member (same path keys as in group.yaml). Phase-1 walk runs in that member; cross-boundary fan-out uses the group bridge. A cross entry with fanout_status:"not_attempted" proves the declared repository boundary, but its far endpoint has no graph symbol; do not interpret empty by_depth or affected_processes on that entry as a completed zero-impact walk. The fan-out attempts at most 50 neighbour crossings, strongest-confidence first; when it stops early the response carries truncated:true, truncatedRepos, and riskEpistemic:"lower-bound" — dropping a crossing can only move risk DOWN, so treat that risk as a floor, never as a verdict.
GROUP MODE: set "repo" to "@<groupName>" for cross-repo impact anchored at the default member (lexicographically first key in group.yaml "repos"), or "@<groupName>/<groupRepoPath>" to choose the member (same path keys as in group.yaml). Phase-1 walk runs in that member; cross-boundary fan-out uses the group bridge. A cross entry with fanout_status:"not_attempted" proves the declared repository boundary, but its far endpoint has no graph symbol; do not interpret empty by_depth or affected_processes on that entry as a completed zero-impact walk. The fan-out attempts at most 50 neighbour crossings, strongest-confidence first. Any short answer carries truncated:true, truncatedRepos, riskEpistemic:"lower-bound" AND a truncationReason — dropping a crossing can only move risk DOWN, so treat that risk as a floor, never as a verdict. truncated:true does NOT always mean the fan-out ran out of room, so branch on truncationReason: the remedy differs. 'timeout' (the fan-out's wall-clock budget expired) and 'partial' (a neighbour crossing, or the local walk, was cut short) are runtime limits — the same query can return more on a retry or with a larger timeoutMs. 'incomplete-sync' is structural: the group bridge was built by a sync that could not say which repos it read, or that could not read an in-scope repo, so those repos' contracts are absent from EVERY query against this bridge, and truncatedRepos names them even when ZERO crossings to them were attempted. Retrying returns the same floor — run group_sync (\`gitnexus group sync\`) and query again. 'suppressed-stage' is also structural but has a DIFFERENT remedy: the sync was asked to skip a matching stage (\`--exact-only\` / exactOnly), so cross-links that stage would have found are absent BY REQUEST. Re-running the sync unchanged returns the same floor — re-run it WITHOUT that flag. Do not report a repo as broken for this reason; nothing failed to read.
SERVICE: optional monorepo path prefix (case-sensitive path segments). When "repo" starts with "@", scopes the local impact walk and cross-repo symbol paths to files under that prefix; ignored for a normal indexed repo name.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
@ -849,21 +850,27 @@ WHEN TO USE: Discover groups before group_sync. Optional "name" returns a single
},
{
name: 'group_sync',
description: `Rebuild the Contract Registry (contracts.json) for a group: extract HTTP contracts, apply manifest links, exact-match cross-links.
description: `Rebuild the Contract Registry (contracts.json) for a group: extract contracts (HTTP, gRPC, Thrift, topics, includes), apply manifest links, then cross-link by exact contract-id match followed by wildcard service match.
WHEN TO USE: After changing group.yaml or re-indexing member repos.`,
// Writes contracts.json on every call; conservatively non-idempotent
// even though output is deterministic for identical input.
WHEN TO USE: After changing group.yaml or re-indexing member repos.
READ THE RESULT: \`missingRepos\` are configured repos with no entry in the registry (index them, or drop them from group.yaml); \`unreadableRepos\` ARE registered but this sync could not extract from them — the index would not open (version skew, lock, corruption), or an extractor failed partway — so NONE of their contracts are in this sync and a following group_impact / group_contracts is a lower bound, not a verdict. \`registryOutcome\` says what happened to the file, and the three values a call here can return each need a different response: 'written' — this run's contracts replaced contracts.json; 'preserved' — nothing could be read, so contracts.json was rewritten keeping the previous sync's contracts and cross-links verbatim and refreshing only \`missingRepos\`/\`unreadableRepos\` to describe THIS run (the file changed, the contracts in it did not, and they are as old as the last sync that succeeded); 'superseded' — nothing could be read, and another sync replaced contracts.json while this one waited for the group lock; that file was left untouched and this run's lists were NOT recorded, because they describe an older group state than what is on disk (so the registry is fresher than this response's diagnostics, not staler); 'no-prior-registry' — nothing could be read AND there was no previous contracts.json to carry forward, so none was written and this group has no contract registry on disk. Only 'no-prior-registry' means there is nothing to read: after it, group_contracts / group_impact have no registry at all rather than a stale one, so fix the repos above and re-run before trusting either. \`suppressedMatchStages\` names matching stages this sync was ASKED to skip, with the same three states as the repo lists: ABSENT means a registry written before the field existed, \`[]\` means this sync suppressed nothing, and a populated list means the cross-link set is a lower bound BY REQUEST — a later group_impact / group_contracts on it reports truncationReason 'suppressed-stage'.\n\nPARAMETERS ARE VALIDATED: \`exactOnly\` must be a real boolean — the string "false" is rejected, not coerced to true. The retired \`skipEmbeddings\` and \`allowStale\` parameters are refused by name; drop them from the call.`,
// Usually writes contracts.json, so conservatively non-idempotent even
// though output is deterministic for identical input. When no configured
// repo could be read it still rewrites the file, keeping the previous
// registry's contracts and refreshing only its diagnostic fields
// (`registryOutcome: 'preserved'`); it writes nothing when there was no
// previous registry to carry forward (`'no-prior-registry'`).
annotations: DESTRUCTIVE_TOOL_ANNOTATIONS,
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Group name' },
skipEmbeddings: {
exactOnly: {
type: 'boolean',
description: 'Exact + BM25 only (Demo PR: same as default exact path)',
description:
'Skip the wildcard service-match stage; cross-link only on exact contract-id match. Manifest links still apply.',
},
exactOnly: { type: 'boolean', description: 'Exact match only in cascade' },
},
required: ['name'],
},

View file

@ -105,6 +105,25 @@ export interface LockRecord {
export interface IndexLockHandle {
/** Our own record — `invocationId` is shown to waiters as the holder id. */
readonly record: LockRecord;
/**
* `true` ONLY on the no-op handle returned when the filesystem refused to
* create the lock file (see {@link LOCK_UNWRITABLE_CODES}); absent on every
* handle that owns a real lock. Purely descriptive — it surfaces a fact this
* module already had, and changes nothing about when or how a lock is taken.
*
* It exists because that degradation is otherwise INVISIBLE at the API
* boundary: the no-op handle is byte-identical in shape to a real one, so a
* caller for whom "lock-free" is not an acceptable outcome (a long, expensive
* critical section whose lost update destroys data — e.g. a group sync) has no
* way to tell it apart and fail closed. A filesystem probe is not a substitute:
* {@link selectBackend} returns `socket` on Linux and Windows, where this
* branch cannot occur at all, so a probe would refuse on the two platforms
* that never degrade.
*
* Additive by construction: every caller that ignores this field behaves
* exactly as it did before it existed.
*/
readonly lockFree?: true;
/** Idempotent; only removes the lock file if it still carries our token. */
release(): void;
}
@ -317,8 +336,14 @@ export const isLockUnwritableCode = (code: string | undefined): boolean =>
code !== undefined && LOCK_UNWRITABLE_CODES.has(code);
/** A lock handle that owns nothing — returned when the filesystem refuses to
* create the lock file (see {@link LOCK_UNWRITABLE_CODES}). Release is a no-op. */
const noopHandle = (record: LockRecord): IndexLockHandle => ({ record, release: () => {} });
* create the lock file (see {@link LOCK_UNWRITABLE_CODES}). Release is a no-op.
* Carries {@link IndexLockHandle.lockFree} so a caller that must not run
* unprotected can tell this apart from a handle that owns a real lock. */
const noopHandle = (record: LockRecord): IndexLockHandle => ({
record,
lockFree: true,
release: () => {},
});
/**
* Delete orphaned build/staging artifacts left in the lock directory by a

View file

@ -568,8 +568,89 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
// and the v37/v38 clash it was written for: the next free value above every
// IN-FLIGHT claim, not above origin/main. Every open PR touching gitnexus/ was
// scanned; #3017 is the only other claimant.
//
// 72 -> 74 adds import-proven Convex endpoint metadata to Const/Function worker
// output. A warm v72 cache has no convexEndpointFactory property, so the MCP
// impact probe would keep claiming exact results for unchanged endpoints. The
// parse-cache bump makes unchanged files re-parse; analyzer runner identity
// drift separately forces the graph re-emit (run-analyze.ts), and an id/schema
// migration needs both guarantees. Version 73 is intentionally skipped because
// concurrent PR #3046 (fixes #3041) claims it. Re-check main and open PRs
// immediately before merge.
//
// 74 -> 76 makes object-literal Function and Method members owner-qualified
// (#3041). A warm v74 cache replays the old collapsed callable ids and omits
// the new Const/Variable -> callable HAS_METHOD ownership edges, so this bump
// makes unchanged files re-parse rather than waiting for a source edit. The
// persisted graph is rebuilt separately when `analyzerRunnerIdentitiesEqual`
// detects the changed analyzer build in run-analyze.ts. Both guards are
// required; a parse-cache bump alone must never be read as a graph rebuild.
// Version 75 is intentionally skipped because concurrent PR #3017 claims it.
//
// 74 -> 75 adds #3009's NestJS decorator routes to the JS/TS decoratorRoutes
// channel. Same reasoning as 69: a warm pre-feature cache replays unchanged
// worker results, which for every already-indexed NestJS repo means replaying
// the empty route set this change exists to fix — the fix would appear to do
// nothing until something else invalidated the cache.
//
// 75, not 71 (this branch's original claim) and not 74: origin/main cascaded
// past both while this PR was open. #2980 took 71, #3046 claims 73, and the
// Convex endpoint-metadata change took 74 (skipping 73 for exactly that
// reason). 75 is the next free value above origin/main AND above every
// in-flight claim — the rule the ledger states, not "one above main". Every
// open PR touching gitnexus/src/storage/parse-cache.ts was scanned at this
// merge: #3046 (73) and #1616 (a stale 2) are the only other claimants.
// RE-CHECK AGAINST origin/main AND OPEN PRs IMMEDIATELY BEFORE MERGING.
const SCHEMA_BUMP = 72;
//
// 75 -> 76 for the NestJS multi-path (array) form: `@Get(['a','b'])` now yields
// one `decoratorRoutes` entry per path where it previously yielded none. That
// changes worker output for the same file, and 75 was already claimed earlier
// on this same branch — so a warm cache written by a dev or CI build at v75,
// before the array form landed, would replay the pre-feature captures under a
// BYTE-IDENTICAL `PARSE_CACHE_VERSION` and the array form would be inert. The
// package version is untouched here, so it cannot rescue that case. Same-branch
// re-bumping is unusual, but the ledger's rule is about what a warm cache can
// replay, not about how the value was reached.
//
// 76 -> 77 because 76 was NOT free. While this branch sat in review, origin/main
// advanced to ac68f5254 and #3046 took 76 — the value this branch already held.
// `gitnexus/package.json` is 1.6.9 on both sides, so `PARSE_CACHE_VERSION` was
// the byte-identical string `76+1.6.9` on two branches that changed
// incompatible worker output. Every warm cache would have been reused across
// both features, making both inert while every test stayed green.
//
// The sharpest part: #3046 skipped 75 BECAUSE this branch held it, then took
// 76 — and this branch had meanwhile moved 75 -> 76 for the array form. Two
// PRs each doing the bookkeeping correctly still collided, because each
// re-checked once and neither re-checked after the other moved. That is what
// the re-check line below is for, and why it says AT MERGE rather than
// when you pick the number.
//
// 77 is free at this merge: origin/main is 76, and the open PRs touching this
// constant are #2840 (a stale 71) and #1616 (a stale 2). Scan with the contents
// API at each PR head, not `gh pr diff` — that exits non-zero on an
// inaccessible fork and prints nothing, so a grep over its output skips the PR
// silently. #2840 was missed exactly that way this round.
//
// WHY THIS IS STILL A HAND-PICKED NUMBER, when `SCHEMA_FINGERPRINT` next door
// is a derived sha256 that cannot collide. The derivation exists and already
// runs: `resolveAnalyzerRunnerIdentity` computes `build.digest` over the
// analyzer build tree on every analyze. It is not used here because it moves on
// ANY build change — a comment-only edit, this paragraph included — so every
// dev and CI rebuild would force a full re-parse of every repo. That is the
// expensive half of the trade, and `PARSE_CACHE_VERSION` already carries the
// package version, so this counter's only job is separating builds that SHARE
// one: dev, CI, unreleased. Exactly the population a whole-build digest would
// punish, and exactly the population that hits the exact-clash failure.
//
// So the counter stays, and the open cost is that a bump nobody makes is
// invisible: a capture change with no bump ships inert and no check can see it.
// #2860 (a base-branch CI comparison) closes the "not greater than main" axis
// only. A digest over just the determinant subset — the language queries plus
// `route-extractors/` and `workers/` module content — would close the missing-
// bump axis without invalidating on unrelated churn, and is the real follow-up.
// RE-CHECK AGAINST origin/main AND OPEN PRs IMMEDIATELY BEFORE MERGING.
const SCHEMA_BUMP = 77;
const GITNEXUS_PKG_VERSION = (() => {
try {
// package.json sits at gitnexus/package.json — two levels up from

View file

@ -616,18 +616,138 @@ const sanitizeEntries = (entries: RegistryEntry[]): RegistryEntry[] =>
});
/**
* Read the global registry. Returns empty array if not found.
* A registry row we can actually resolve a repo from.
*
* `Array.isArray` is not enough on the strict path: `[{}]` is a JSON array, so
* a malformed registry passed the shape check, every configured repo failed to
* resolve, and — because none of them produced a load ERROR — the total-failure
* guard stayed off and a good contracts.json was replaced by an empty one. That
* is the same fail-open the strict mode exists to close, one level down from
* the file to the rows inside it.
*
* Only the three fields the resolution path actually depends on are required.
* `indexedAt` / `lastCommit` are deliberately NOT: callers already default them
* (`e?.indexedAt || ''`), so demanding them would reject a legacy row that
* resolves perfectly well — trading a fail-open for a fail-shut on real data.
*
* Two of the three must also be non-blank, because `typeof '' === 'string'`
* passes a row that cannot identify anything. `name` is what
* `defaultResolveHandle` matches a configured repo against, so a blank one
* matches nothing and puts every repo in `missingRepos` — the same fail-open,
* dressed as a clean answer. `storagePath` is what the resolved handle carries
* to `path.join(storagePath, 'lbug')`; blank, that joins to a relative `lbug`
* under the CWD, so the sync opens an index that is not the repo's.
*
* `path` stays at the bare string check, on the same reasoning that exempts
* `indexedAt` / `lastCommit`: require only what the resolution path depends on
* to IDENTIFY the repo. This check rejects the WHOLE registry, which is
* machine-wide, so a field tightened past what resolution needs would let one
* blank value in one row break every group sync on the machine — including
* groups whose repos all resolve.
*/
export const readRegistry = async (): Promise<RegistryEntry[]> => {
const isResolvableEntry = (value: unknown): value is RegistryEntry => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const e = value as Record<string, unknown>;
const identifies = (v: unknown): boolean => typeof v === 'string' && v.trim() !== '';
return identifies(e.name) && identifies(e.storagePath) && typeof e.path === 'string';
};
/**
* Shared body for the two read modes below.
*
* `strict` distinguishes "the registry says nothing is registered" from "the
* registry could not be read". Lenient collapses both into `[]`.
*
* ENOENT is lenient in BOTH modes: no file genuinely means nothing has been
* registered yet, and every first-run path depends on that.
*/
const readRegistryFile = async (strict: boolean): Promise<RegistryEntry[]> => {
let raw: string;
try {
const raw = await fs.readFile(getGlobalRegistryPath(), 'utf-8');
const data = JSON.parse(raw);
return Array.isArray(data) ? sanitizeEntries(data) : [];
} catch {
raw = await fs.readFile(getGlobalRegistryPath(), 'utf-8');
} catch (err) {
if (strict && (err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
return [];
}
try {
// The parse gets its OWN guarded region, narrower than the checks below,
// and the parser's error is DISCARDED rather than rethrown.
//
// `JSON.parse`'s SyntaxError quotes a ten-character window of the source
// either side of the break — `Unexpected token 'L', ..."end.git"},<here>"...`.
// Registry rows carry remote URLs with their HTTPS userinfo verbatim, so a
// registry that breaks on one of those URLs puts the credential into that
// window, and the thrown message is not the only place it goes from there:
// `groupStatus` interpolates it into `unresolvableReason` for an MCP
// client, and `gitnexus group sync` prints it.
//
// Not logged and not attached as `cause` either, deliberately against this
// file's own convention of handing the `Error` object to the logger so it
// captures stack and cause: under MCP stdio the client writes those records
// to a log file on disk, so following the convention here would move the
// byte window from one channel to a more durable one. The parser's position
// offset is not worth a credential — the path and the failure class are
// what an operator acts on, and they are what the two errors below say too.
let data: unknown;
try {
data = JSON.parse(raw);
} catch {
throw new Error(`${getGlobalRegistryPath()} is not valid JSON (registry is corrupt)`);
}
if (!Array.isArray(data)) {
if (strict) {
throw new Error(`${getGlobalRegistryPath()} is not a JSON array (registry is corrupt)`);
}
return [];
}
if (strict) {
// Reject the WHOLE registry, never filter the bad rows out. Dropping them
// would report the repos they name as unregistered, which is precisely
// the unreadable-as-missing answer this mode refuses to give.
const bad = data.findIndex((entry) => !isResolvableEntry(entry));
if (bad !== -1) {
throw new Error(
`${getGlobalRegistryPath()} entry ${bad} does not identify a repo — name and storagePath must be non-empty strings and path must be a string (registry is corrupt)`,
);
}
}
return sanitizeEntries(data as RegistryEntry[]);
} catch (err) {
if (strict) throw err;
return [];
}
};
/**
* Read the global registry. Returns empty array if not found — and, note, also
* when the file exists but cannot be read or parsed. That is fine for a
* read-only listing, where an unreadable registry and an empty one print the
* same nothing. It is not fine for a caller that ACTS on emptiness; see
* `readRegistryStrict`.
*/
export const readRegistry = async (): Promise<RegistryEntry[]> => readRegistryFile(false);
/**
* Read the global registry, refusing to report an unreadable one as empty.
*
* An EACCES after a `sudo gitnexus analyze`, a truncated registry.json, or an
* $HOME-on-NFS blip otherwise presents as "no repo is registered" — an
* unreadable condition reported as missing, which is exactly the conflation
* #3011 removes one frame further down. `syncGroup` is the caller that acts on
* that answer, by replacing a good contracts.json with an empty one.
*
* Deliberately a separate export rather than an option on `readRegistry`:
* leaving that signature untouched keeps every existing lenient call site
* provably unaffected, and the mode is legible at the call site.
*
* No count here on purpose. This comment carried one, it said nine, and the
* real figure was thirteen by the time anyone checked and fourteen shortly
* after — a number in prose beside code that moves is a claim that rots
* silently, which is the defect class this whole change set is about. The
* argument does not need the figure: it holds for one call site or fifty.
*/
export const readRegistryStrict = async (): Promise<RegistryEntry[]> => readRegistryFile(true);
/**
* Write the global registry to disk.
*

View file

@ -0,0 +1,62 @@
/**
* Child process for the cross-process group sync-lock tests (R9). Holds the
* REAL `withGroupSyncLock` on GROUP_DIR so the parent's `syncGroup` contends
* with a genuinely separate process — the only way to observe the property this
* lock exists for. An in-process mock cannot: the socket backend's exclusion is
* a kernel binding, and the file backend's is an O_EXCL create.
*
* Env:
* GROUP_LOCK_MODULE file:// URL or path of the group-lock module to import.
* GROUP_DIR the group directory to lock.
* MARKER written (with our pid) once the lock is HELD.
* HOLD_MS how long to hold before releasing. 0/unset = hold until
* killed (used by the holder-death and no-contention cases).
* CONTRACTS optional: path to write just before releasing, standing in
* for the first sync's persist. Written LAST on purpose — if
* the lock were absent the waiting sync would have written
* first and this would overwrite it, which is exactly the
* lost update the test hunts.
* RELEASED optional: path stamped with Date.now() just before release.
*/
import { writeFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
// On Windows `import('C:\\…')` throws ERR_UNSUPPORTED_ESM_URL_SCHEME (a bare
// drive path reads as a URL scheme), so address the module as a file:// URL.
const spec = process.env.GROUP_LOCK_MODULE.startsWith('file:')
? process.env.GROUP_LOCK_MODULE
: pathToFileURL(process.env.GROUP_LOCK_MODULE).href;
const { withGroupSyncLock } = await import(spec);
const holdMs = Number(process.env.HOLD_MS ?? 0);
// Nothing else keeps this process alive: the socket backend's server is unref'd
// and the file backend holds no open handle.
const keepalive = setInterval(() => {}, 1000);
await withGroupSyncLock(process.env.GROUP_DIR, async () => {
writeFileSync(process.env.MARKER, String(process.pid));
if (holdMs <= 0) {
await new Promise(() => {}); // hold until the parent kills us
return;
}
await new Promise((r) => setTimeout(r, holdMs));
if (process.env.CONTRACTS) {
writeFileSync(
process.env.CONTRACTS,
JSON.stringify({
version: 1,
generatedAt: new Date().toISOString(),
writtenBy: 'child',
contracts: [],
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
unreadableRepos: [],
}),
);
}
if (process.env.RELEASED) writeFileSync(process.env.RELEASED, String(Date.now()));
});
clearInterval(keepalive);
process.exit(0);

View file

@ -0,0 +1,26 @@
/**
* A NestJS controller mounting a METHOD-AGNOSTIC route at `/api/widgets` — the
* same URL `app/api/widgets/route.ts` produces as a Next.js filesystem route.
*
* `@All` maps to httpMethod '*', which `routeNodeKey` keys by URL alone, so
* this route collides with the filesystem one. It is the shape behind #3049.
*
* `@Get('gadgets')` is the NON-colliding companion, and it is here for a
* reason the `@All` route cannot serve: because the `@All` route loses its key
* to the filesystem node and is dropped as a duplicate, it leaves NO trace in
* the graph at all — so the whole of this file could stop being extracted
* without a single assertion changing. `GET /api/gadgets` is the only witness
* this fixture can offer that NestJS extraction ran (see the test's comment).
*/
@Controller('api')
export class WidgetsController {
@All('widgets')
handleEveryVerb() {
return 'nest handler';
}
@Get('gadgets')
listGadgets() {
return '[]';
}
}

View file

@ -0,0 +1,14 @@
import { Controller, Get } from '@nestjs/common';
/**
* A controller with no prefix of its own. `@Controller()` is legal NestJS and
* means "mount at the root", so the method path must reach the graph bare —
* this is the fixture half of the extractor's `prefix: '' -> null` mapping.
*/
@Controller()
export class HealthController {
@Get('health')
health(): string {
return 'ok';
}
}

View file

@ -0,0 +1,16 @@
import { Controller, Get } from '@nestjs/common';
const ROUTE_PREFIXES = { legacy: 'legacy' };
/**
* The prefix is not a literal, so the URLs of this controller's methods are
* unknowable here. `route_map` presents its output as fact: a missing route is
* recoverable, a route mounted at the wrong URL is not.
*/
@Controller(ROUTE_PREFIXES.legacy)
export class LegacyController {
@Get('reports')
legacyReports(): string {
return 'legacy';
}
}

View file

@ -0,0 +1,35 @@
import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
import { VenuesService } from './venues.service';
import type { Venue } from './venues.service';
/**
* The shape #3009 is about: the URL is split across two decorators. The class
* decorator carries the prefix and the method decorator carries the verb plus
* the remainder, so neither half is a route on its own.
*/
@Controller('venues')
export class VenuesController {
constructor(private readonly venues: VenuesService) {}
// Pathless index route: its URL is the controller prefix and nothing else.
@Get()
findAll(): Venue[] {
return this.venues.listVenues();
}
@Get('search')
search(@Query('q') term: string): Venue[] {
return this.venues.searchVenues(term);
}
// Same URL as findAll(), different verb — two Route nodes, not one.
@Post()
create(@Body() input: Venue): Venue {
return this.venues.insertVenue(input);
}
@Delete(':id')
remove(@Param('id') id: string): void {
this.venues.deleteVenue(id);
}
}

View file

@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common';
export interface Venue {
id: string;
name: string;
}
/**
* Not a controller. It exists so each handler body calls a real symbol, and so
* the `@Controller` file gate is shown skipping a decorated class that declares
* no routes.
*/
@Injectable()
export class VenuesService {
private readonly rows: Venue[] = [];
listVenues(): Venue[] {
return this.rows;
}
searchVenues(term: string): Venue[] {
return this.rows.filter((row) => row.name.includes(term));
}
insertVenue(input: Venue): Venue {
this.rows.push(input);
return input;
}
deleteVenue(id: string): void {
const index = this.rows.findIndex((row) => row.id === id);
if (index !== -1) this.rows.splice(index, 1);
}
}

View file

@ -0,0 +1,107 @@
/**
* Reads the bare-name sets in `src/config/ignore-service.ts` out of source.
*
* Those sets are module-private, and exporting them purely to be testable would
* widen a production surface to satisfy a test — the call
* `receiver-twin-list-drift.test.ts` documents. So the guards read the source
* instead, through the TypeScript parser the repo already vendors and already
* uses this way (`literal-collectors.ts`, `query-determinism-guard.test.ts`,
* `cli-index-help.test.ts`).
*
* Using the real parser is what makes the guards trustworthy. A text scanner has
* to decide whether a delimiter opens a comment or sits inside a string, and it
* gets that wrong in both directions here: the ignore-list comments quote paths
* and carry an apostrophe (`Next.js's`), while a glob string such as `'** / *'`
* contains a comment-open sequence. It also has to guess which bracket belongs
* to the declaration rather than to a type annotation. Each of those is a way to
* silently read fewer members — and a guard that quietly stops seeing members is
* the exact defect these guards exist to catch.
*
* `setEntries` therefore refuses anything that is not a plain list of string
* literals, rather than skipping the members it cannot resolve.
*/
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import ts from 'typescript';
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
/** The analyzer's ignore rules — the sets every guard in this family reads. */
export const IGNORE_SERVICE_PATH = path.join(
REPO_ROOT,
'gitnexus',
'src',
'config',
'ignore-service.ts',
);
/** The browser upload pre-filter, whose excluded-directory set must not drift from the above. */
export const UPLOAD_FILTER_PATH = path.join(
REPO_ROOT,
'gitnexus-web',
'src',
'lib',
'upload-filter.ts',
);
export const readSource = (file: string): string => readFileSync(file, 'utf8');
/**
* The string literals `setName` is constructed from, in declaration order.
*
* Throws — never returns a short list — when the declaration is missing or holds
* anything other than plain string literals (a spread, an interpolation, a
* concatenation, a computed value).
*/
export const setEntries = (source: string, setName: string): string[] => {
const sourceFile = ts.createSourceFile(
'ignore-set-source.ts',
source,
ts.ScriptTarget.Latest,
true,
);
let elements: ts.NodeArray<ts.Expression> | undefined;
const visit = (node: ts.Node): void => {
if (
elements === undefined &&
ts.isVariableDeclaration(node) &&
ts.isIdentifier(node.name) &&
node.name.text === setName &&
node.initializer !== undefined &&
ts.isNewExpression(node.initializer) &&
node.initializer.arguments?.length === 1 &&
ts.isArrayLiteralExpression(node.initializer.arguments[0])
) {
elements = node.initializer.arguments[0].elements;
return;
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
if (elements === undefined) {
throw new Error(`${setName} is not declared as \`new Set([...])\` — update this test`);
}
const unresolvable = elements.filter((element) => !ts.isStringLiteral(element));
if (unresolvable.length > 0) {
throw new Error(
`${setName} holds ${unresolvable.length} member(s) that are not plain string literals ` +
`(first: \`${unresolvable[0].getText(sourceFile)}\`). A source-reading guard cannot resolve ` +
`those, so switch this set to a runtime assertion rather than letting the guard see fewer members.`,
);
}
return elements.map((element) => (element as ts.StringLiteral).text);
};
/**
* True when `setName` is mutated by `.add(...)` anywhere in `source`.
*
* `setEntries` reads the declaration only, so a member appended afterwards would
* be invisible to it. The guards assert this is false rather than under-reporting.
*/
export const hasRuntimeAdd = (source: string, setName: string): boolean =>
new RegExp(`\\b${setName}\\s*\\.\\s*add\\s*\\(`).test(source);

View file

@ -7,6 +7,7 @@
* - happy path: file-scope export const / const / export var → returns binding
* - local-inside-function / arrow / class-constructor → null
* - nested object literal → null (safe under-approximation)
* - object literal reached through an array → null
* - block-scoped declaration (if / for body) → null
* - IIFE-wrapped object literal → null
* - assignment without declarator → null (no throw)
@ -125,6 +126,17 @@ describe('findObjectLiteralBindingInfo — negative: nested literals', () => {
});
});
describe('findObjectLiteralBindingInfo — negative: array elements', () => {
it('does not invent an owner member for an object literal inside an array', () => {
const tree = parseTs(`export const handlers = [{ run() {} }, { run() {} }];`);
const methodNodes = findMethodNodes(tree.rootNode, 'run');
expect(methodNodes).toHaveLength(2);
for (const methodNode of methodNodes) {
expect(findObjectLiteralBindingInfo(methodNode, 'src/handlers.ts')).toBe(null);
}
});
});
describe('findObjectLiteralBindingInfo — negative: block scope', () => {
it('declared inside top-level if-block → null', () => {
const tree = parseTs(`

View file

@ -0,0 +1,218 @@
import fs from 'fs';
import path from 'path';
import { beforeAll, expect, it, vi } from 'vitest';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import {
loadParseCache,
PARSE_CACHE_VERSION,
pruneCache,
saveParseCache,
type ParseCache,
} from '../../src/storage/parse-cache.js';
import {
getDurableParsedFileDir,
pruneAndSaveDurableParsedFileStore,
} from '../../src/storage/parsedfile-store.js';
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
import { listRegisteredRepos } from '../../src/storage/repo-manager.js';
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
vi.mock('../../src/storage/repo-manager.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../../src/storage/repo-manager.js')>()),
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
}));
let repoDir = '';
let warmReplayUsedWorkers = true;
const replayProperties = new Map<string, unknown>();
let bareHandlerFunctionId = '';
withTestLbugDB(
'convex-impact-epistemic-e2e',
(handle) => {
let backend: LocalBackend;
beforeAll(() => {
backend = (handle as typeof handle & { _backend: LocalBackend })._backend;
});
it('persists import-proven endpoint metadata through a warm parse cache', () => {
expect(warmReplayUsedWorkers).toBe(false);
expect(replayProperties).toEqual(
new Map([
['aliasedWrite', 'mutation'],
['generatedAction', 'internalAction'],
['javascriptQuery', 'query'],
['bareHandler', 'query'],
['publicQuery', 'query'],
]),
);
});
it.each([
['publicQuery', 'endpoints.ts', 'query'],
['aliasedWrite', 'endpoints.ts', 'mutation'],
['generatedAction', 'endpoints.ts', 'internalAction'],
['javascriptQuery', 'endpoints.js', 'query'],
])(
'marks real indexed Convex endpoint %s as lower-bound',
async (target, filePath, factory) => {
const result = await backend.callTool('impact', {
target,
file_path: filePath,
direction: 'upstream',
});
expect(result.epistemic).toBe('lower-bound');
expect(result.boundaries.join(' ')).toContain(`Convex ${factory}`);
expect(result.causes.dispatchBoundary).toBe(0);
},
);
it('marks a bare Function handler as lower-bound', async () => {
expect(bareHandlerFunctionId).not.toBe('');
const result = await backend.callTool('impact', {
target_uid: bareHandlerFunctionId,
direction: 'upstream',
});
expect(result.epistemic).toBe('lower-bound');
expect(result.boundaries.join(' ')).toContain('Convex query');
expect(result.causes.dispatchBoundary).toBe(0);
});
it.each([
['unrelatedQuery', 'endpoints.ts'],
['localQuery', 'local.ts'],
])('keeps non-Convex same-shape control %s exact', async (target, filePath) => {
const result = await backend.callTool('impact', {
target,
file_path: filePath,
direction: 'upstream',
});
expect(result.epistemic).toBe('exact');
expect(result.boundaries).toBeUndefined();
});
it('does not apply the inbound Convex boundary to downstream impact', async () => {
const result = await backend.callTool('impact', {
target: 'publicQuery',
file_path: 'endpoints.ts',
direction: 'downstream',
});
expect(result.epistemic).toBe('exact');
expect(result.boundaries).toBeUndefined();
});
it('carries the Convex boundary through context()', async () => {
const result = await backend.callTool('context', {
name: 'publicQuery',
file_path: 'endpoints.ts',
});
expect(result.status).toBe('found');
expect(result.epistemic).toBe('lower-bound');
expect(result.boundaries.join(' ')).toContain('Convex query');
});
it('keeps a non-Convex same-shape context exact', async () => {
const result = await backend.callTool('context', {
name: 'localQuery',
file_path: 'local.ts',
});
expect(result.status).toBe('found');
expect(result.epistemic).toBe('exact');
expect(result.boundaries).toBeUndefined();
});
},
{
beforeFTS: async (dbPath) => {
const storageDir = path.dirname(dbPath);
repoDir = path.join(storageDir, 'repo');
const cacheDir = path.join(storageDir, 'parse-cache');
fs.mkdirSync(repoDir, { recursive: true });
fs.writeFileSync(
path.join(repoDir, 'endpoints.ts'),
`import { queryGeneric as query, mutationGeneric as write } from 'convex/server';
import { query as generatedQuery, internalAction as internalRun } from './_generated/server';
import { query as dbQuery } from './database';
export const publicQuery = // legal line-comment trivia
query({ handler: async () => null });
export const aliasedWrite = write({ handler: async () => null });
export const generatedAction = internalRun({ handler: async () => null });
export const bareHandler = generatedQuery(async () => null);
export const unrelatedQuery = dbQuery({ handler: async () => null });
`,
);
fs.writeFileSync(
path.join(repoDir, 'local.ts'),
`function query(config: unknown) { return config; }
export const localQuery = query({ handler: async () => null });
`,
);
fs.writeFileSync(
path.join(repoDir, 'endpoints.js'),
`import { query } from './_generated/server.js';
export const javascriptQuery = query({ handler: async () => null });
`,
);
const cold: ParseCache = {
version: PARSE_CACHE_VERSION,
entries: new Map(),
usedKeys: new Set(),
storagePath: cacheDir,
onDiskKeys: new Set(),
};
await runPipelineFromRepo(repoDir, () => {}, { parseCache: cold, workerPoolSize: 1 });
pruneCache(cold, cold.usedKeys);
const savedKeys = await saveParseCache(cacheDir, cold);
await pruneAndSaveDurableParsedFileStore(
getDurableParsedFileDir(cacheDir),
PARSE_CACHE_VERSION,
new Set(savedKeys),
);
const warm = await loadParseCache(cacheDir);
const replay = await runPipelineFromRepo(repoDir, () => {}, {
parseCache: warm ?? undefined,
workerPoolSize: 1,
});
warmReplayUsedWorkers = replay.usedWorkerPool;
replay.graph.forEachNode((node) => {
if (node.properties.convexEndpointFactory !== undefined) {
replayProperties.set(node.properties.name, node.properties.convexEndpointFactory);
if (node.label === 'Function' && node.properties.name === 'bareHandler') {
bareHandlerFunctionId = node.id;
}
}
});
const adapter = await import('../../src/core/lbug/lbug-adapter.js');
await adapter.loadGraphToLbug(replay.graph, repoDir, storageDir);
},
poolAdapter: true,
afterSetup: async (handle) => {
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'convex-e2e',
path: repoDir,
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'convex-e2e',
stats: { files: 3, nodes: 6, communities: 0, processes: 0 },
},
]);
const backend = new LocalBackend();
await backend.init();
(handle as typeof handle & { _backend?: LocalBackend })._backend = backend;
},
timeout: 180_000,
},
);

View file

@ -187,6 +187,125 @@ describe('filesystem-walker', () => {
});
});
describe('ambiguous source-directory names (#3039)', () => {
let sourceDir: string;
beforeAll(async () => {
sourceDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-source-names-'));
await fs.mkdir(path.join(sourceDir, 'apps', 'client', 'src', 'shared', 'env'), {
recursive: true,
});
await fs.mkdir(path.join(sourceDir, 'packages', 'ai', 'src', 'generated'), {
recursive: true,
});
await fs.mkdir(path.join(sourceDir, 'build-cache', 'generated'), { recursive: true });
await fs.mkdir(path.join(sourceDir, 'env'), { recursive: true });
await fs.mkdir(path.join(sourceDir, 'generated'), { recursive: true });
await fs.mkdir(path.join(sourceDir, 'backend', 'env', 'Scripts'), { recursive: true });
await fs.mkdir(path.join(sourceDir, 'backend', 'env', 'include'), { recursive: true });
await fs.mkdir(path.join(sourceDir, 'backend', 'env', 'share'), { recursive: true });
await fs.writeFile(
path.join(sourceDir, 'apps', 'client', 'src', 'shared', 'env', 'getAppEnv.ts'),
'export const getAppEnv = () => "test";\n',
);
await fs.writeFile(
path.join(sourceDir, 'packages', 'ai', 'src', 'generated', 'bundle.ts'),
'export const bundled = true;\n',
);
await fs.writeFile(
path.join(sourceDir, 'apps', 'client', 'src', 'vite-env.d.ts'),
'declare const APP_ENV: string;\n',
);
await fs.writeFile(
path.join(sourceDir, 'apps', 'client', 'src', 'service.ts'),
'export class UserService {}\n',
);
await fs.writeFile(
path.join(sourceDir, 'apps', 'client', 'src', 'service.d.ts'),
'export declare class UserService {}\n',
);
await fs.writeFile(
path.join(sourceDir, 'apps', 'client', 'src', 'legacy.js'),
'export class LegacyService {}\n',
);
await fs.writeFile(
path.join(sourceDir, 'apps', 'client', 'src', 'legacy.d.ts'),
'export declare class LegacyService {}\n',
);
await fs.writeFile(
path.join(sourceDir, 'build-cache', 'generated', 'ignored.ts'),
'export const ignored = true;\n',
);
await fs.writeFile(path.join(sourceDir, '.gitignore'), 'build-cache/generated/\n');
await fs.writeFile(path.join(sourceDir, 'env', 'pyvenv.cfg'), 'home = python\n');
await fs.writeFile(path.join(sourceDir, 'env', 'settings.py'), 'VALUE = 1\n');
await fs.writeFile(path.join(sourceDir, 'backend', 'env', 'pyvenv.cfg'), 'home = python\n');
await fs.writeFile(
path.join(sourceDir, 'backend', 'env', 'Scripts', 'activate_this.py'),
'VALUE = 1\n',
);
await fs.writeFile(
path.join(sourceDir, 'backend', 'env', 'include', 'header.py'),
'VALUE = 1\n',
);
await fs.writeFile(
path.join(sourceDir, 'backend', 'env', 'share', 'manual.py'),
'VALUE = 1\n',
);
await fs.writeFile(
path.join(sourceDir, 'generated', 'client.ts'),
'export const generatedClient = true;\n',
);
});
afterAll(async () => {
await fs.rm(sourceDir, { recursive: true, force: true });
});
it('discovers nested env/generated and .d.ts source while pruning root artifacts', async () => {
const files = await walkRepositoryPaths(sourceDir);
const paths = files.map((file) => file.path);
expect(paths).toContain('apps/client/src/shared/env/getAppEnv.ts');
expect(paths).toContain('packages/ai/src/generated/bundle.ts');
expect(paths).toContain('apps/client/src/vite-env.d.ts');
expect(paths).toContain('apps/client/src/service.ts');
expect(paths).not.toContain('apps/client/src/service.d.ts');
expect(paths).toContain('apps/client/src/legacy.js');
expect(paths).toContain('apps/client/src/legacy.d.ts');
expect(paths).not.toContain('build-cache/generated/ignored.ts');
expect(paths).not.toContain('env/settings.py');
expect(paths).not.toContain('backend/env/Scripts/activate_this.py');
expect(paths).not.toContain('backend/env/include/header.py');
expect(paths).not.toContain('backend/env/share/manual.py');
expect(paths).not.toContain('generated/client.ts');
});
it('preserves case variants that were not hardcoded ignore names', async () => {
const caseDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-walker-source-case-'));
try {
await fs.mkdir(path.join(caseDir, 'Generated'), { recursive: true });
await fs.mkdir(path.join(caseDir, 'Env'), { recursive: true });
await fs.writeFile(
path.join(caseDir, 'Generated', 'client.cs'),
'public class GeneratedClient {}\n',
);
await fs.writeFile(
path.join(caseDir, 'Env', 'settings.ts'),
'export const environment = "test";\n',
);
const paths = (await walkRepositoryPaths(caseDir)).map((file) => file.path);
expect(paths).toContain('Generated/client.cs');
expect(paths).toContain('Env/settings.ts');
} finally {
await fs.rm(caseDir, { recursive: true, force: true });
}
});
});
describe('.gitnexusignore support', () => {
let nexusignoreDir: string;
@ -394,6 +513,7 @@ describe('filesystem-walker', () => {
describe('large file skip threshold (#991)', () => {
let sizeDir: string;
const BIG_FILE = 'src/big.ts';
const BIG_DECLARATION = 'src/big.d.ts';
const BIG_FILE_BYTES = 600 * 1024;
const ORIGINAL_ENV = process.env.GITNEXUS_MAX_FILE_SIZE;
let cap: ReturnType<typeof _captureLogger>;
@ -403,6 +523,10 @@ describe('filesystem-walker', () => {
await fs.mkdir(path.join(sizeDir, 'src'), { recursive: true });
await fs.writeFile(path.join(sizeDir, 'src', 'small.ts'), 'export const x = 1;');
await fs.writeFile(path.join(sizeDir, BIG_FILE), 'x'.repeat(BIG_FILE_BYTES));
await fs.writeFile(
path.join(sizeDir, BIG_DECLARATION),
'export declare const generatedTypes: string;\n',
);
});
afterAll(async () => {
@ -429,6 +553,7 @@ describe('filesystem-walker', () => {
const paths = files.map((f) => f.path.replace(/\\/g, '/'));
expect(paths).toContain('src/small.ts');
expect(paths).not.toContain(BIG_FILE);
expect(paths).toContain(BIG_DECLARATION);
});
it('includes the 600KB file when GITNEXUS_MAX_FILE_SIZE=1024', async () => {
@ -436,6 +561,7 @@ describe('filesystem-walker', () => {
const files = await walkRepositoryPaths(sizeDir);
const paths = files.map((f) => f.path.replace(/\\/g, '/'));
expect(paths).toContain(BIG_FILE);
expect(paths).not.toContain(BIG_DECLARATION);
});
it('falls back to default and warns once on invalid GITNEXUS_MAX_FILE_SIZE', async () => {

View file

@ -1,15 +1,20 @@
/**
* Smoke-test `gitnexus group` CLI (same spawn pattern as cli-e2e.test.ts, via
* CLI_SPAWN_PREFIX: built dist in CI, tsx-on-source locally).
* Does not exercise LadybugDB-backed commands end-to-end (needs indexed fixtures).
* Does not exercise LadybugDB-backed QUERY commands end-to-end (needs indexed
* fixtures). `group sync` IS driven end-to-end below, but only through the two
* shapes that need no indexed repo: a group whose members are absent from the
* registry, and a group whose members are registered at a storage path holding
* no `lbug` file at all — which is what makes them unreadable.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import { CLI_SPAWN_PREFIX } from '../../helpers/cli-entry.js';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import os from 'node:os';
import { INDEX_METADATA_FILE } from '../../../src/storage/repo-meta.js';
const testDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(testDir, '../../..');
@ -25,16 +30,20 @@ afterAll(() => {
}
});
function runGroup(args: string[]) {
function runGroupIn(home: string, args: string[]) {
return spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, 'group', ...args], {
cwd: repoRoot,
encoding: 'utf8',
timeout: 20000,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, GITNEXUS_HOME: tmpHome },
env: { ...process.env, GITNEXUS_HOME: home },
});
}
function runGroup(args: string[]) {
return runGroupIn(tmpHome, args);
}
describe('group CLI', () => {
it('create + list', () => {
const c = runGroup(['create', 'acme']);
@ -62,6 +71,41 @@ describe('group CLI', () => {
expect(source).not.toMatch(blanketClosePattern);
});
/**
* `--skip-embeddings` and `--allow-stale` were both accepted by commander and
* then read by nothing: the first named a BM25/embedding cascade that was
* never built, the second a stale-index warning that no sync path ever
* emitted. An operator who passed either got a silent no-op and a clean exit,
* which is worse than the flag not existing — so they are gone, and the CLI
* must now say so.
*
* `unknown option` is asserted rather than just a nonzero exit because
* `group sync <missing-group>` ALSO exits nonzero (GroupNotFoundError), so
* the exit code alone cannot tell "the flag is rejected" from "the group is
* not there". The control below is what makes that distinction visible.
*/
it('test_sync_rejects_removed_skip_embeddings_flag', () => {
const r = runGroup(['sync', 'acme', '--skip-embeddings']);
expect(r.status).not.toBe(0);
expect(r.stderr).toContain("unknown option '--skip-embeddings'");
});
it('test_sync_rejects_removed_allow_stale_flag', () => {
const r = runGroup(['sync', 'acme', '--allow-stale']);
expect(r.status).not.toBe(0);
expect(r.stderr).toContain("unknown option '--allow-stale'");
});
it('control: the surviving --exact-only flag is still parsed', () => {
// Without this, the two cases above would also pass against a `group sync`
// that rejected EVERY option. This one reaches the action handler and
// fails on the group instead, which is the proof that commander accepted
// the flag itself.
const r = runGroup(['sync', 'no-such-group', '--exact-only']);
expect(r.stderr).not.toContain('unknown option');
expect(`${r.stderr}\n${r.stdout}`).toContain('no-such-group');
});
it('group impact requires --target and --repo', () => {
const c = runGroup(['create', 'impcli']);
expect(c.status).toBe(0);
@ -107,3 +151,527 @@ describe('group CLI', () => {
}
});
});
describe('group contracts reports its completeness', () => {
/**
* `groupContracts` returns the structured triple alongside the contracts, so
* an agent can tell a complete listing from a floor. The `--json` path used
* to destructure `{ contracts, crossLinks }` and re-serialize just those two,
* which silently dropped every other field the service returned — including
* the ones that say the listing is incomplete. Printing the payload whole is
* what keeps a new field from needing a matching CLI edit to become visible.
*/
const seedRegistry = (group: string, registry: Record<string, unknown>): void => {
const groupDir = path.join(tmpHome, 'groups', group);
fs.mkdirSync(groupDir, { recursive: true });
fs.writeFileSync(path.join(groupDir, 'contracts.json'), JSON.stringify(registry, null, 2));
};
const baseRegistry = {
version: 1,
generatedAt: '2026-01-01T00:00:00.000Z',
contracts: [],
crossLinks: [],
repoSnapshots: {},
missingRepos: [],
};
it('carries the incompleteness fields through --json', () => {
expect(runGroup(['create', 'jsonfloor']).status).toBe(0);
seedRegistry('jsonfloor', { ...baseRegistry, unreadableRepos: ['app/backend'] });
const r = runGroup(['contracts', 'jsonfloor', '--json']);
expect(r.status).toBe(0);
const payload = JSON.parse(r.stdout) as Record<string, unknown>;
expect(payload.unreadableRepos).toEqual(['app/backend']);
expect(payload.truncated).toBe(true);
expect(payload.truncationReason).toBe('incomplete-sync');
expect(payload.riskEpistemic).toBe('lower-bound');
// Still everything it always returned.
expect(payload.contracts).toEqual([]);
expect(payload.crossLinks).toEqual([]);
});
it('tells a human reader the listing is a floor, and which repos are missing from it', () => {
expect(runGroup(['create', 'humanfloor']).status).toBe(0);
seedRegistry('humanfloor', { ...baseRegistry, unreadableRepos: ['app/backend'] });
const r = runGroup(['contracts', 'humanfloor']);
expect(r.status).toBe(0);
expect(r.stdout).toContain('app/backend');
expect(r.stdout.toLowerCase()).toContain('incomplete');
});
it('control: a complete registry says nothing about truncation on either surface', () => {
expect(runGroup(['create', 'complete']).status).toBe(0);
seedRegistry('complete', { ...baseRegistry, unreadableRepos: [] });
const j = JSON.parse(runGroup(['contracts', 'complete', '--json']).stdout) as Record<
string,
unknown
>;
expect(j.truncated).toBe(false);
expect(j.truncationReason).toBeUndefined();
expect(j.riskEpistemic).toBeUndefined();
const h = runGroup(['contracts', 'complete']);
expect(h.stdout.toLowerCase()).not.toContain('incomplete');
});
});
/**
* The per-repo status table had ONE failure label — `MISSING (no entry in the
* registry)` — and every reason a repo failed to resolve was printed with it,
* including a global registry that could not be read at all. For that case the
* line states something nobody measured (the command never got to read any
* entry) and points at the wrong repair: index the repo, when the fix is to
* repair the registry.
*
* These cases go through the real CLI because the label is the deliverable —
* the service payload can carry the distinction perfectly while the table
* still prints one word for both.
*/
describe('group status names which failure a repo hit', () => {
let home: string;
/** Two members: one the registry will know about, one it never will. */
const GROUP_YAML = `version: 1
name: labels
description: ""
repos:
backend: backend-registry
svc/users: svc-users-registry
links: []
packages: {}
detect:
http: false
grpc: false
thrift: false
topics: false
shared_libs: false
embedding_fallback: false
matching:
bm25_threshold: 0.7
embedding_threshold: 0.65
max_candidates_per_step: 3
`;
beforeEach(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-group-status-labels-'));
const groupDir = path.join(home, 'groups', 'labels');
fs.mkdirSync(groupDir, { recursive: true });
fs.writeFileSync(path.join(groupDir, 'group.yaml'), GROUP_YAML, 'utf8');
});
afterEach(() => {
fs.rmSync(home, { recursive: true, force: true });
});
/**
* A registry row that survives `LocalBackend.init()`'s validation pass —
* which prunes (and rewrites) any entry whose storage path has no metadata
* file, so a row backed by nothing would silently become a genuine absence
* before `group status` ever read the registry.
*/
const registeredRow = (name: string, dirName: string): Record<string, string> => {
const repoPath = path.join(home, dirName);
const storagePath = path.join(repoPath, '.gitnexus');
fs.mkdirSync(storagePath, { recursive: true });
fs.writeFileSync(path.join(storagePath, INDEX_METADATA_FILE), '{}', 'utf8');
return {
name,
path: repoPath,
storagePath,
indexedAt: '2026-01-01T00:00:00.000Z',
lastCommit: 'abc123',
};
};
const writeRegistry = (body: string): void =>
fs.writeFileSync(path.join(home, 'registry.json'), body, 'utf8');
it('says MISSING for a repo a readable registry simply does not hold', () => {
// The label this command has always printed, kept honest: the registry
// reads fine and genuinely has no row for either member.
writeRegistry('[]');
const r = runGroupIn(home, ['status', 'labels']);
expect(r.status).toBe(0);
expect(r.stdout).toMatch(/^ +backend +MISSING {3}\(no entry in the registry\)$/m);
expect(r.stdout).toMatch(/^ +svc\/users +MISSING {3}\(no entry in the registry\)$/m);
expect(r.stdout).not.toContain('UNRESOLVABLE');
});
it('says UNRESOLVABLE for a repo the registry holds but cannot resolve', () => {
// Two registered clones under one name: the row is right there, and
// resolution still cannot pick one. Printing "no entry in the registry"
// here would be a false statement about the file just read — and the two
// members must come out with DIFFERENT labels in the same table.
writeRegistry(
JSON.stringify([
registeredRow('backend-registry', 'clone-a'),
registeredRow('backend-registry', 'clone-b'),
]),
);
const r = runGroupIn(home, ['status', 'labels']);
expect(r.status).toBe(0);
// One line, not four: the ambiguity error is multi-line and gets folded.
expect(r.stdout).toMatch(/^ +backend +UNRESOLVABLE \(.*backend-registry.*\)$/m);
expect(r.stdout).toMatch(/^ +svc\/users +MISSING {3}\(no entry in the registry\)$/m);
});
it('says UNRESOLVABLE for every member when the registry itself cannot be read', () => {
// Nothing was measured about any repo, so "no entry in the registry" is a
// claim about a file that could not be parsed. Every configured member is
// unresolved — including one whose row might have been perfectly fine.
writeRegistry('{"repos": []}');
const r = runGroupIn(home, ['status', 'labels']);
expect(r.status).toBe(0);
expect(r.stdout).toMatch(/^ +backend +UNRESOLVABLE \(.*registry\.json.*\)$/m);
expect(r.stdout).toMatch(/^ +svc\/users +UNRESOLVABLE \(.*registry\.json.*\)$/m);
expect(r.stdout).not.toContain('MISSING');
});
});
/**
* A group.yaml with every detector off, so nothing in a sync opens a repo graph
* and the only thing that can vary is what the registry says about its members.
*
* `links` is spliced in verbatim because the two shapes below need different
* ones: a manifest link is the single input that makes a sync produce contracts
* with no indexed repo (synthetic UIDs — see
* `group-service-sync-lazy-import.test.ts`), which is what gives the wrote-line
* counts to assert something other than zeroes.
*/
function writeGroupYaml(
home: string,
group: string,
repos: Record<string, string>,
links = '[]',
): string {
const groupDir = path.join(home, 'groups', group);
fs.mkdirSync(groupDir, { recursive: true });
const repoLines = Object.entries(repos)
.map(([groupPath, registryName]) => ` ${groupPath}: ${registryName}`)
.join('\n');
fs.writeFileSync(
path.join(groupDir, 'group.yaml'),
`version: 1
name: ${group}
description: ""
repos:
${repoLines}
links: ${links}
packages: {}
detect:
http: false
grpc: false
thrift: false
topics: false
shared_libs: false
embedding_fallback: false
includes: false
workspace_deps: false
matching:
bm25_threshold: 0.7
embedding_threshold: 0.65
max_candidates_per_step: 3
`,
'utf8',
);
return groupDir;
}
/**
* `group sync` has three mutually exclusive things it can say about
* contracts.json, and the sentence is the ONLY channel that distinguishes them:
* all three exit 0, and two of them leave the file's contract counts identical.
*
* The line used to be the unconditional `Wrote contracts.json (0 contracts, 0
* cross-links)`, printed even on a run that deliberately kept the previous
* registry — a confident false statement about persisted state on the exact
* path this command exists to make legible. These go through the real CLI
* because the sentence IS the deliverable: the service payload can carry
* `registryOutcome` perfectly while the console still says one thing for all
* three.
*/
describe('group sync says what it did to contracts.json', () => {
let home: string;
/** Contracts a preserve run must carry forward untouched. */
const PRIOR_REGISTRY = {
version: 1,
generatedAt: '2026-01-01T00:00:00.000Z',
repoSnapshots: {},
missingRepos: [],
unreadableRepos: [],
contracts: [],
crossLinks: [],
};
beforeEach(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-group-sync-outcome-'));
});
afterEach(() => {
fs.rmSync(home, { recursive: true, force: true });
});
/**
* Registry rows whose storage directory exists but holds no `lbug` file, so
* `initLbug` throws `LadybugDB not found at …` for every one of them. That is
* a load ERROR, not an absence: the repos resolve, and every one of them
* lands on `unreadableRepos` — the only state that reaches the two
* total-failure branches. A row missing from registry.json instead reports as
* MISSING and syncs to a written registry, which is the other case below.
*/
const registerReposWithNoIndex = (registryNames: Record<string, string>): void => {
const rows = Object.entries(registryNames).map(([registryName, dirName]) => {
const repoPath = path.join(home, dirName);
const storagePath = path.join(repoPath, '.gitnexus');
fs.mkdirSync(storagePath, { recursive: true });
return {
name: registryName,
path: repoPath,
storagePath,
indexedAt: '2026-01-01T00:00:00.000Z',
lastCommit: 'abc123',
};
});
fs.writeFileSync(path.join(home, 'registry.json'), JSON.stringify(rows), 'utf8');
};
it('prints what it wrote, and the counts, on a sync that produced a registry', () => {
// Every member is genuinely absent from the registry, which is a clean
// (if empty-handed) sync: the total-failure guard is gated on a load error,
// never on an empty result. The declared manifest link still yields two
// synthetic contracts and one cross-link, so the counts in the line are
// non-zero and therefore say something.
const groupDir = writeGroupYaml(
home,
'wrote',
{ 'app/backend': 'wrote-backend', 'app/frontend': 'wrote-frontend' },
`
- from: app/frontend
to: app/backend
type: custom
contract: rotateSigningKey
role: consumer`,
);
fs.writeFileSync(path.join(home, 'registry.json'), '[]', 'utf8');
const r = runGroupIn(home, ['sync', 'wrote']);
expect(r.status).toBe(0);
expect(r.stdout).toContain('Wrote contracts.json (2 contracts, 1 cross-links)');
// The other two sentences are about the same file and contradict this one.
expect(r.stdout).not.toContain('Kept the previous contracts.json');
expect(r.stdout).not.toContain('Did NOT write contracts.json');
expect(fs.existsSync(path.join(groupDir, 'contracts.json'))).toBe(true);
});
/**
* The per-stage `Matching:` block. It used to print `Matching cascade:` and
* count `exact` alone, while the `Wrote contracts.json (…)` line beneath it
* reported every cross-link — so for any group with manifest or wildcard
* links the two numbers disagreed with nothing on screen explaining why.
*
* A manifest fixture is enough to pin both halves. The stage counts have to
* sum to the printed total, and the skipped rendering does not depend on a
* stage having matched anything: `--exact-only` records the suppression
* whatever the fixture contains.
*/
const writeManifestGroup = (name: string): void => {
writeGroupYaml(
home,
name,
{ 'app/backend': `${name}-backend`, 'app/frontend': `${name}-frontend` },
`
- from: app/frontend
to: app/backend
type: custom
contract: rotateSigningKey
role: consumer`,
);
fs.writeFileSync(path.join(home, 'registry.json'), '[]', 'utf8');
};
it('prints a count for every matching stage, and they sum to the written total', () => {
writeManifestGroup('stages');
const r = runGroupIn(home, ['sync', 'stages']);
expect(r.status).toBe(0);
expect(r.stdout).toContain('exact: 0 cross-links (confidence 1.0)');
expect(r.stdout).toContain('manifest: 1 cross-links');
expect(r.stdout).toContain('wildcard: 0 cross-links');
// The reconciliation this block exists for: 0 + 1 + 0 is the total below.
expect(r.stdout).toContain('Wrote contracts.json (2 contracts, 1 cross-links)');
});
it('names a stage it was told to skip as skipped, not as zero', () => {
writeManifestGroup('skipped');
const r = runGroupIn(home, ['sync', 'skipped', '--exact-only']);
expect(r.status).toBe(0);
expect(r.stdout).toContain('wildcard: skipped (--exact-only)');
// "ran and matched nothing" must not be printable for a stage that never ran.
expect(r.stdout).not.toContain('wildcard: 0 cross-links');
// Manifest links are unaffected by the flag, so the total still says so.
expect(r.stdout).toContain('manifest: 1 cross-links');
});
// control: the skipped rendering tracks the flag, not the fixture. Without
// this, printing `skipped` unconditionally would pass the case above.
it('control: the same group without the flag reports the stage as zero', () => {
writeManifestGroup('unskipped');
const r = runGroupIn(home, ['sync', 'unskipped']);
expect(r.stdout).toContain('wildcard: 0 cross-links');
expect(r.stdout).not.toContain('skipped (--exact-only)');
});
it('says the previous contracts.json was KEPT when no repo could be read', () => {
// "Did NOT write contracts.json" was false here: this path REWRITES the
// file, keeping the previous sync's contracts and replacing only the two
// diagnostic lists. Saying otherwise sent an operator looking at an
// unchanged mtime to conclude the sync had not run.
const groupDir = writeGroupYaml(home, 'kept', {
'app/backend': 'kept-backend',
'app/frontend': 'kept-frontend',
});
registerReposWithNoIndex({ 'kept-backend': 'backend', 'kept-frontend': 'frontend' });
const contractsPath = path.join(groupDir, 'contracts.json');
fs.writeFileSync(contractsPath, JSON.stringify(PRIOR_REGISTRY), 'utf8');
const r = runGroupIn(home, ['sync', 'kept']);
expect(r.status).toBe(0);
expect(r.stdout).toContain(
'Kept the previous contracts.json — no repo in this group could be read.',
);
expect(r.stdout).toContain('Its contracts and cross-links are unchanged');
expect(r.stdout).not.toContain('Wrote contracts.json');
expect(r.stdout).not.toContain('Did NOT write contracts.json');
// What makes the sentence true rather than merely present: the file is
// still there, its contracts are the previous run's, and only the
// diagnostic list describes THIS run.
const onDisk = JSON.parse(fs.readFileSync(contractsPath, 'utf8')) as Record<string, unknown>;
expect(onDisk.contracts).toEqual(PRIOR_REGISTRY.contracts);
expect(onDisk.generatedAt).toBe(PRIOR_REGISTRY.generatedAt);
expect(onDisk.unreadableRepos).toEqual(['app/backend', 'app/frontend']);
});
it('says nothing was written when no repo could be read and there is no prior registry', () => {
// Distinct from the branch above on purpose: there is nothing on disk to
// keep, so promising the previous sync's contracts are safe would send an
// operator whose group has never synced looking for a file that has never
// existed.
const groupDir = writeGroupYaml(home, 'nothing', {
'app/backend': 'nothing-backend',
'app/frontend': 'nothing-frontend',
});
registerReposWithNoIndex({ 'nothing-backend': 'backend', 'nothing-frontend': 'frontend' });
const r = runGroupIn(home, ['sync', 'nothing']);
expect(r.status).toBe(0);
expect(r.stdout).toContain(
'Did NOT write contracts.json — no repo in this group could be read,',
);
expect(r.stdout).toContain('there is no previous contracts.json to fall back on');
expect(r.stdout).not.toContain('Wrote contracts.json');
expect(r.stdout).not.toContain('Kept the previous contracts.json');
// And the claim is true of disk: no file was invented to go with it.
expect(fs.existsSync(path.join(groupDir, 'contracts.json'))).toBe(false);
});
});
/**
* `undefined` and `[]` are different answers about the last sync's unreadable
* repos — "never recorded" versus the measurement "none" — and `group status`
* is where an operator reads them. Printing nothing for both would let an
* unmeasured sync read as evidence that every index opened cleanly, which is
* the fail-open the tri-state exists to close.
*/
describe('group status reports what the last sync recorded as unreadable', () => {
let home: string;
const BASE_REGISTRY = {
version: 1,
generatedAt: '2026-01-01T00:00:00.000Z',
repoSnapshots: {},
missingRepos: [],
contracts: [],
crossLinks: [],
};
const NOT_RECORDED_LINE = 'Last sync unreadable repos: not recorded';
beforeEach(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-group-status-unreadable-'));
// An empty registry, so every member reports MISSING and nothing in the
// per-repo table can vary between these three cases.
fs.writeFileSync(path.join(home, 'registry.json'), '[]', 'utf8');
});
afterEach(() => {
fs.rmSync(home, { recursive: true, force: true });
});
const seed = (group: string, registry: Record<string, unknown>): void => {
const groupDir = writeGroupYaml(home, group, {
'app/backend': `${group}-backend`,
'app/frontend': `${group}-frontend`,
});
fs.writeFileSync(path.join(groupDir, 'contracts.json'), JSON.stringify(registry), 'utf8');
};
it('says the field was not recorded when the registry never carried it', () => {
// A contracts.json written before the field existed has no opinion about
// which indexes were readable, and the remedy is to re-run the sync — not
// to conclude that none of them failed.
seed('unrecorded', BASE_REGISTRY);
const r = runGroupIn(home, ['status', 'unrecorded']);
expect(r.status).toBe(0);
expect(r.stdout).toContain(NOT_RECORDED_LINE);
expect(r.stdout).toContain('the registry predates this field, or its value could not be read');
expect(r.stdout).toContain('Re-run `gitnexus group sync` to record it.');
});
it('says nothing at all when the registry recorded an empty list', () => {
// `[]` is a measurement — this sync accounted for every repo — so there is
// no caveat to print and no repo to name. Reporting the "not recorded"
// caveat here would tell an operator to re-run the sync that just
// succeeded.
seed('measured', { ...BASE_REGISTRY, unreadableRepos: [] });
const r = runGroupIn(home, ['status', 'measured']);
expect(r.status).toBe(0);
expect(r.stdout).not.toContain('Last sync unreadable repos');
});
it('names the repos when the registry recorded some', () => {
// Without this, "says nothing at all" above would also be satisfied by a
// command that never printed this line on any registry.
seed('named', { ...BASE_REGISTRY, unreadableRepos: ['app/backend'] });
const r = runGroupIn(home, ['status', 'named']);
expect(r.status).toBe(0);
expect(r.stdout).toContain('Last sync unreadable repos: app/backend');
expect(r.stdout).not.toContain(NOT_RECORDED_LINE);
});
});

View file

@ -0,0 +1,607 @@
/**
* The per-group sync lock (R9): two concurrent syncs of one group cannot lose
* one another's writes, and a sync that cannot be protected does not run.
*
* The exclusion cases contend with a REAL second process (`group-sync-lock-child.mjs`)
* rather than an in-process mock: the default backend's exclusion is a kernel
* socket binding and the file backend's is an O_EXCL create, so nothing observed
* inside one process can prove either.
*
* Nothing here is platform-skipped. The cases that need the FILE backend pin it
* explicitly (`GITNEXUS_INDEX_LOCK_BACKEND=file`) — the pin is load-bearing, not
* incidental: on Linux and Windows `selectBackend()` answers `socket`, and the
* socket backend never touches the filesystem, so an unpinned filesystem-failure
* case would measure nothing on two of the three platforms.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { syncGroup } from '../../../src/core/group/sync.js';
import {
GROUP_SYNC_LOCK_TIMEOUT_MS,
GroupSyncLockError,
getGroupSyncLockDir,
withGroupSyncLock,
} from '../../../src/core/group/group-lock.js';
import { GroupService } from '../../../src/core/group/service.js';
import { makeGroupToolPort } from '../../unit/group/fixtures.js';
import type { LockRecord } from '../../../src/storage/index-lock.js';
import { CLI_SPAWN_PREFIX, tsxLoaderUrl } from '../../helpers/cli-entry.js';
import type { GroupConfig, StoredContract } from '../../../src/core/group/types.js';
const testDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(testDir, '../../..');
const childScript = path.resolve(repoRoot, 'test', 'fixtures', 'group-sync-lock-child.mjs');
const groupLockSource = path.resolve(repoRoot, 'src', 'core', 'group', 'group-lock.ts');
const indexLockSpecifier = '../../../src/storage/index-lock.js';
const groupLockSpecifier = '../../../src/core/group/group-lock.js';
const makeConfig = (name: string): GroupConfig => ({
version: 1,
name,
description: '',
repos: {},
links: [],
packages: {},
detect: {
http: true,
grpc: false,
thrift: false,
topics: false,
includes: false,
workspace_deps: false,
},
matching: {},
});
const parentContract: StoredContract = {
contractId: 'http::GET::/api/parent',
type: 'http',
role: 'provider',
symbolUid: 'uid-parent',
symbolRef: { filePath: 'src/parent.ts', name: 'Parent.get' },
symbolName: 'Parent.get',
confidence: 0.9,
meta: { method: 'GET', path: '/api/parent' },
repo: 'app/parent',
};
/** A persisting sync driven entirely off an extractor override (no repo index). */
const runSync = (groupDir: string) =>
syncGroup(makeConfig(path.basename(groupDir)), {
groupDir,
extractorOverride: async () => [parentContract],
});
const contractsPath = (groupDir: string): string => path.join(groupDir, 'contracts.json');
const readContracts = (groupDir: string): Record<string, unknown> =>
JSON.parse(readFileSync(contractsPath(groupDir), 'utf8')) as Record<string, unknown>;
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
const waitFor = async (predicate: () => boolean, timeoutMs: number): Promise<void> => {
const start = Date.now();
for (;;) {
if (predicate()) return;
if (Date.now() - start > timeoutMs) throw new Error('condition not met within timeout');
await sleep(25);
}
};
const waitForExit = (proc: ChildProcess, timeoutMs: number): Promise<void> =>
new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('child did not exit')), timeoutMs);
proc.once('exit', () => {
clearTimeout(timer);
resolve();
});
});
let home: string;
const children: ChildProcess[] = [];
/** Create `<home>/groups/<name>` with a group.yaml, the way `group create` does. */
const makeGroup = (name: string): string => {
const dir = path.join(home, 'groups', name);
mkdirSync(dir, { recursive: true });
writeFileSync(
path.join(dir, 'group.yaml'),
`version: 1\nname: ${name}\ndescription: ''\nrepos: {}\nlinks: []\n`,
);
return dir;
};
/**
* Spawn the holder. tsx-on-source (not `dist/`) so the child runs the same
* module this process imported — the lock's directory and endpoint derivation
* must agree across the two, and a stale build would silently prove nothing.
*/
const spawnHolder = (opts: {
groupDir: string;
marker: string;
holdMs?: number;
contracts?: string;
released?: string;
backend?: string;
}): ChildProcess => {
const child = spawn(process.execPath, ['--import', tsxLoaderUrl(), childScript], {
env: {
...process.env,
GROUP_LOCK_MODULE: pathToFileURL(groupLockSource).href,
GROUP_DIR: opts.groupDir,
MARKER: opts.marker,
HOLD_MS: String(opts.holdMs ?? 0),
...(opts.contracts ? { CONTRACTS: opts.contracts } : {}),
...(opts.released ? { RELEASED: opts.released } : {}),
...(opts.backend ? { GITNEXUS_INDEX_LOCK_BACKEND: opts.backend } : {}),
},
stdio: ['ignore', 'pipe', 'pipe'],
});
children.push(child);
return child;
};
beforeEach(() => {
home = mkdtempSync(path.join(os.tmpdir(), 'gnx-group-lock-'));
});
afterEach(async () => {
for (const c of children) {
if (c.exitCode === null && c.signalCode === null) c.kill('SIGKILL');
}
children.length = 0;
vi.doUnmock(indexLockSpecifier);
vi.resetModules();
delete process.env.GITNEXUS_INDEX_LOCK_BACKEND;
delete process.env.GITNEXUS_INDEX_LOCK_TIMEOUT_MS;
rmSync(home, { recursive: true, force: true });
});
describe('group sync lock — uncontended (regression gate)', () => {
it('a single sync completes and writes contracts.json exactly as before', async () => {
const groupDir = makeGroup('solo');
const result = await runSync(groupDir);
expect(result.registryOutcome).toBe('written');
expect(result.contracts).toHaveLength(1);
expect(existsSync(contractsPath(groupDir))).toBe(true);
expect((readContracts(groupDir).contracts as unknown[]).length).toBe(1);
}, 60_000);
it('holds the lock on <groupDir>/sync-lock, never on the group directory itself', async () => {
// KTD3. `acquireIndexLock`'s file backend writes `analyze.lock` into the
// directory it is handed, so handing it the group directory would drop a
// lock file beside contracts.json and share a namespace with anything else
// that ever locks a group. Pin the file backend: on the socket backend the
// lock leaves no filesystem trace at all, so this would assert nothing.
process.env.GITNEXUS_INDEX_LOCK_BACKEND = 'file';
const groupDir = makeGroup('located');
expect(getGroupSyncLockDir(groupDir)).toBe(path.join(groupDir, 'sync-lock'));
await runSync(groupDir);
expect(existsSync(getGroupSyncLockDir(groupDir))).toBe(true);
expect(existsSync(path.join(groupDir, 'analyze.lock'))).toBe(false);
}, 60_000);
});
describe('group sync lock — cross-process exclusion', () => {
it('makes the second sync wait, so the final registry is the LATER sync, not the first', async () => {
// The child writes its own contracts.json LAST, immediately before releasing.
// Without the lock the parent's sync would finish first and the child's write
// would land on top of it — the lost update this exists to prevent. With the
// lock the parent cannot start persisting until the child is done, so the
// final file is the parent's.
const groupDir = makeGroup('contended');
const marker = path.join(home, 'held.marker');
const released = path.join(home, 'released.marker');
const HOLD_MS = 1500;
spawnHolder({
groupDir,
marker,
holdMs: HOLD_MS,
contracts: contractsPath(groupDir),
released,
});
await waitFor(() => existsSync(marker), 60_000);
const startedAt = Date.now();
const result = await runSync(groupDir);
const finishedAt = Date.now();
expect(result.registryOutcome).toBe('written');
// The holder released before we finished persisting.
const releasedAt = Number(readFileSync(released, 'utf8'));
expect(finishedAt).toBeGreaterThanOrEqual(releasedAt);
// And we genuinely waited rather than racing through: the hold began before
// our clock started, so a lock-free run would have finished near-instantly.
expect(finishedAt - startedAt).toBeGreaterThan(HOLD_MS / 2);
// The surviving registry is ours, not the holder's.
const written = readContracts(groupDir);
expect(written.writtenBy).toBeUndefined();
expect((written.contracts as StoredContract[])[0].contractId).toBe(parentContract.contractId);
}, 120_000);
it('lets a waiting sync proceed once the holder dies', async () => {
const groupDir = makeGroup('bereaved');
const marker = path.join(home, 'held.marker');
const child = spawnHolder({ groupDir, marker }); // holds until killed
await waitFor(() => existsSync(marker), 60_000);
let settled = false;
const pending = runSync(groupDir).finally(() => {
settled = true;
});
await sleep(600);
expect(settled).toBe(false); // blocked on the live holder
child.kill('SIGKILL');
await waitForExit(child, 30_000);
const result = await pending;
expect(result.registryOutcome).toBe('written');
expect(existsSync(contractsPath(groupDir))).toBe(true);
}, 120_000);
it('does not make syncs of two different groups contend', async () => {
// The holder never releases, so if the lock were group-agnostic this sync
// would block until the wait ceiling and the case would fail by timeout.
const held = makeGroup('group-a');
const other = makeGroup('group-b');
const marker = path.join(home, 'held.marker');
const child = spawnHolder({ groupDir: held, marker });
await waitFor(() => existsSync(marker), 60_000);
const result = await runSync(other);
expect(result.registryOutcome).toBe('written');
expect(existsSync(contractsPath(other))).toBe(true);
expect(existsSync(contractsPath(held))).toBe(false);
expect(child.exitCode).toBeNull(); // still holding group-a
}, 120_000);
});
describe('group sync lock — fails closed', () => {
/** Occupy `<groupDir>/sync-lock` with a regular file: the lock directory then
* cannot be created (EEXIST), on every platform, with no permission games. */
const blockLockDir = (groupDir: string): void => {
writeFileSync(getGroupSyncLockDir(groupDir), 'not a directory');
};
it('refuses to sync when the sync-lock directory cannot be created', async () => {
process.env.GITNEXUS_INDEX_LOCK_BACKEND = 'file';
const groupDir = makeGroup('blocked');
blockLockDir(groupDir);
await expect(runSync(groupDir)).rejects.toBeInstanceOf(GroupSyncLockError);
expect(existsSync(contractsPath(groupDir))).toBe(false);
}, 60_000);
it('rejects the lock-free handle a read-only filesystem produces', async () => {
// KTD4.2. `acquireIndexLock` answers EROFS/EACCES/EPERM with a no-op handle
// that is byte-identical in shape to a real one — right for `analyze`, fatal
// here, because the sync would go on to write with nothing protecting it.
// The failure is injected at the one syscall that produces it, so the REAL
// acquire path runs and the REAL no-op handle comes back; a permissions
// fixture would have to be skipped on Windows, where mode bits do not deny
// directory creation, and skipping is what makes this guarantee a fiction.
process.env.GITNEXUS_INDEX_LOCK_BACKEND = 'file';
const groupDir = makeGroup('readonly');
const lockDir = getGroupSyncLockDir(groupDir);
vi.resetModules();
vi.doMock('node:fs', async () => {
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
const mkdirSync: typeof actual.mkdirSync = ((
p: Parameters<typeof actual.mkdirSync>[0],
o,
) => {
if (String(p) === lockDir) {
const err: NodeJS.ErrnoException = new Error(`EACCES: permission denied, mkdir '${p}'`);
err.code = 'EACCES';
throw err;
}
return actual.mkdirSync(p, o);
}) as typeof actual.mkdirSync;
return { ...actual, mkdirSync, default: { ...actual, mkdirSync } };
});
const fresh = await import(groupLockSpecifier);
let ran = false;
await expect(
fresh.withGroupSyncLock(groupDir, async () => {
ran = true;
}),
).rejects.toMatchObject({ name: 'GroupSyncLockError', reason: 'lock-free' });
expect(ran).toBe(false);
vi.doUnmock('node:fs');
vi.resetModules();
}, 60_000);
it('propagates an acquire timeout instead of running the sync unprotected', async () => {
const groupDir = makeGroup('timed-out');
const holder: LockRecord = {
v: 1,
pid: 4242,
hostname: os.hostname(),
startTime: null,
token: 't',
invocationId: 'other-sync',
acquiredAt: new Date().toISOString(),
};
vi.resetModules();
vi.doMock(indexLockSpecifier, async () => {
const actual =
await vi.importActual<typeof import('../../../src/storage/index-lock.js')>(
indexLockSpecifier,
);
return {
...actual,
acquireIndexLock: async () => {
throw new actual.IndexLockTimeoutError(holder, 600_000);
},
};
});
const fresh = await import(groupLockSpecifier);
let ran = false;
const err = await fresh
.withGroupSyncLock(groupDir, async () => {
ran = true;
})
.then(
() => null,
(e: Error) => e,
);
expect(ran).toBe(false);
expect(err).toMatchObject({ name: 'GroupSyncLockError', reason: 'timeout' });
// The wording is the subject of the suite below; here it only has to be the
// group lock's own message rather than the primitive's raw failure text.
expect((err as Error).message).toContain('sync lock on group "timed-out"');
// The original error is preserved as `cause`, holder metadata intact. Asserted
// structurally, not with `instanceof`: this case drives a freshly re-evaluated
// module graph, whose `IndexLockTimeoutError` is a different class object from
// the statically imported one.
expect((err as { cause?: unknown }).cause).toMatchObject({
name: 'IndexLockTimeoutError',
holder: { invocationId: 'other-sync' },
holderKnown: true,
});
}, 60_000);
it('passes its own wait ceiling, so GITNEXUS_INDEX_LOCK_TIMEOUT_MS cannot make it unbounded', async () => {
// `resolveTimeoutMs` prefers an explicit argument over the env var, whose
// `<= 0` case resolves to POSITIVE_INFINITY — inheriting it would turn this
// lock's fail-closed timeout into a hang.
process.env.GITNEXUS_INDEX_LOCK_TIMEOUT_MS = '0';
const groupDir = makeGroup('ceiling');
const seen: Array<Record<string, unknown>> = [];
vi.resetModules();
vi.doMock(indexLockSpecifier, async () => {
const actual =
await vi.importActual<typeof import('../../../src/storage/index-lock.js')>(
indexLockSpecifier,
);
return {
...actual,
acquireIndexLock: async (_dir: string, o: Record<string, unknown>) => {
seen.push(o);
return { record: {} as LockRecord, release: () => {} };
},
};
});
const fresh = await import(groupLockSpecifier);
await fresh.withGroupSyncLock(groupDir, async () => undefined);
expect(seen).toHaveLength(1);
expect(seen[0].timeoutMs).toBe(GROUP_SYNC_LOCK_TIMEOUT_MS);
expect(Number.isFinite(seen[0].timeoutMs as number)).toBe(true);
}, 60_000);
});
describe('group sync lock — what the timeout says happened', () => {
/**
* Drive `withGroupSyncLock` against an `acquireIndexLock` that waits `waitMs`
* and then times out exactly as the primitive does, and hand back the error
* the wrapper produced.
*
* `reportedMs` is the figure baked into the INHERITED message and is
* deliberately nowhere near the real wait: a wrapper that re-uses the
* primitive's text reports that number, one that times the acquisition itself
* reports `waitMs`. `IndexLockTimeoutError` carries no elapsed field, so the
* two are the only places the figure can come from.
*/
const timedOutAcquire = async (opts: {
groupDir: string;
waitMs: number;
reportedMs: number;
holderKnown: boolean;
}): Promise<Error | null> => {
// `holderKnown: false` mirrors `unknownHolder()` in index-lock.ts: the
// socket backend exposes no owner metadata, so the record is a placeholder
// (`pid -1`) that no message may present as a real holder.
const holder: LockRecord = {
v: 1,
pid: opts.holderKnown ? 4242 : -1,
hostname: os.hostname(),
startTime: null,
token: opts.holderKnown ? 't' : '',
invocationId: opts.holderKnown ? 'other-sync' : '<unreadable>',
acquiredAt: opts.holderKnown ? new Date().toISOString() : '',
};
vi.resetModules();
vi.doMock(indexLockSpecifier, async () => {
const actual =
await vi.importActual<typeof import('../../../src/storage/index-lock.js')>(
indexLockSpecifier,
);
return {
...actual,
acquireIndexLock: async () => {
await sleep(opts.waitMs);
throw new actual.IndexLockTimeoutError(holder, opts.reportedMs, opts.holderKnown);
},
};
});
const fresh = await import(groupLockSpecifier);
return fresh
.withGroupSyncLock(opts.groupDir, async () => undefined)
.then(
() => null,
(e: Error) => e,
);
};
const waitedMsIn = (message: string): number =>
Number(/Timed out after (\d+)ms/.exec(message)?.[1] ?? NaN);
it('names the group, the operation, and the wait it measured itself', async () => {
const groupDir = makeGroup('slow-group');
const err = await timedOutAcquire({
groupDir,
waitMs: 120,
reportedMs: 600_000,
holderKnown: true,
});
expect(err).toMatchObject({ name: 'GroupSyncLockError', reason: 'timeout' });
const msg = String(err?.message);
expect(msg).toContain('sync lock on group "slow-group"');
expect(msg).toContain(getGroupSyncLockDir(groupDir));
expect(msg).toContain('was not synced');
// The elapsed wait is this wrapper's own measurement. The primitive
// announced 600000ms; the acquisition actually took ~120ms, and only a
// wrapper that timed it can say so. Half the sleep is the floor, the way
// the exclusion case above bounds its own wait — a timer cannot fire at
// half its delay on any host.
const waited = waitedMsIn(msg);
expect(Number.isFinite(waited)).toBe(true);
expect(waited).toBeGreaterThanOrEqual(60);
expect(msg).not.toContain('600000');
// The primitive's error is still the cause, so nothing is lost by rewording.
expect((err as { cause?: unknown }).cause).toMatchObject({
name: 'IndexLockTimeoutError',
holder: { invocationId: 'other-sync' },
});
}, 60_000);
it('does not blame an analyze, and does not name a holder the backend cannot identify', async () => {
// The inherited message says "another gitnexus analyze" holds the lock —
// a cause this path cannot establish (nothing but a group sync ever locks
// `<groupDir>/sync-lock`), and on the socket backend it cannot name the
// holder at all: `holderKnown` is false and `holder.pid` is the placeholder
// -1. Fail-closed made both claims user-visible for the first time.
const groupDir = makeGroup('anonymous-holder');
const err = await timedOutAcquire({
groupDir,
waitMs: 0,
reportedMs: 600_000,
holderKnown: false,
});
const msg = String(err?.message);
expect(msg).toContain('sync lock on group "anonymous-holder"');
expect(msg).not.toMatch(/analyze/i);
// No pid is quoted at all — not the placeholder, not any other. Matched on
// the shape the message would use to name one, so a random temp-directory
// segment cannot satisfy it by accident.
expect(msg).not.toMatch(/pid\s+-?\d+/i);
expect(msg).toContain('cannot identify the holder');
}, 60_000);
it('names the holder when the backend does identify one', async () => {
// The other half of the branch: on the file backend the record is real, and
// suppressing it would throw away the one thing that lets an operator find
// the process to wait for.
const groupDir = makeGroup('identified-holder');
const err = await timedOutAcquire({
groupDir,
waitMs: 0,
reportedMs: 600_000,
holderKnown: true,
});
const msg = String(err?.message);
expect(msg).toMatch(/pid 4242/);
expect(msg).toContain(os.hostname());
expect(msg).toContain('other-sync');
expect(msg).not.toMatch(/analyze/i);
expect(msg).not.toContain('cannot identify the holder');
}, 60_000);
it('control: an acquisition that succeeds raises nothing', async () => {
// No mock: the real lock, uncontended. Without this, every assertion above
// could be satisfied by a wrapper that failed on every acquisition.
const groupDir = makeGroup('uncontended-message');
await expect(withGroupSyncLock(groupDir, async () => 'ran')).resolves.toBe('ran');
}, 60_000);
});
describe('group sync lock — how a lock failure surfaces', () => {
it('fails the `group sync` command with the lock message, not a stack trace', () => {
process.env.GITNEXUS_INDEX_LOCK_BACKEND = 'file';
const groupDir = makeGroup('cli-blocked');
writeFileSync(getGroupSyncLockDir(groupDir), 'not a directory');
const run = spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, 'group', 'sync', 'cli-blocked'], {
cwd: repoRoot,
encoding: 'utf8',
timeout: 120_000,
env: { ...process.env, GITNEXUS_HOME: home, GITNEXUS_INDEX_LOCK_BACKEND: 'file' },
});
expect(run.status).not.toBe(0);
// The message goes through pino (`console.error` is an eslint error in this
// package — a forcing function for that migration), so it arrives as a JSON
// envelope rather than raw text. Read the `msg` field: asserting the raw
// substring would pass only by accident of quoting, and would go green
// again if the line were ever downgraded to a bare stderr write.
const logged = run.stderr
.split('\n')
.filter((line) => line.trim().startsWith('{'))
.map((line) => JSON.parse(line) as { level: number; msg: string });
const failure = logged.find((entry) => entry.msg.includes('Did not sync group'));
expect(failure, `no failure log in stderr: ${run.stderr}`).toBeDefined();
expect(failure?.level).toBe(50); // pino error
expect(failure?.msg).toContain('Did not sync group "cli-blocked"');
expect(failure?.msg).toContain('sync lock');
expect(run.stderr).not.toContain('GroupSyncLockError: ');
expect(run.stderr).not.toMatch(/^\s+at /m); // no stack frames
expect(existsSync(contractsPath(groupDir))).toBe(false);
}, 180_000);
it('returns a lock failure through group_sync as an error payload, never an empty success', async () => {
process.env.GITNEXUS_INDEX_LOCK_BACKEND = 'file';
const groupDir = makeGroup('mcp-blocked');
writeFileSync(getGroupSyncLockDir(groupDir), 'not a directory');
process.env.GITNEXUS_HOME = home;
try {
const service = new GroupService(makeGroupToolPort(home));
const payload = (await service.groupSync({ name: 'mcp-blocked' })) as Record<string, unknown>;
expect(typeof payload.error).toBe('string');
expect(String(payload.error)).toContain('sync lock');
// The failure must not masquerade as a clean sync of an empty group.
expect(payload.contracts).toBeUndefined();
expect(payload.registryOutcome).toBeUndefined();
expect(existsSync(contractsPath(groupDir))).toBe(false);
} finally {
delete process.env.GITNEXUS_HOME;
}
}, 120_000);
});

View file

@ -40,6 +40,36 @@ describe('ignore + language-skip E2E', () => {
path.join(tmpDir, 'src', 'greet.ts'),
"export function greet(): string {\n return 'hello';\n}\n",
);
await fs.writeFile(
path.join(tmpDir, 'src', 'service.ts'),
'export class UserService { load(): string { return "loaded"; } }\n',
);
await fs.writeFile(
path.join(tmpDir, 'src', 'service.d.ts'),
'export declare class UserService { load(): string; }\n',
);
await fs.writeFile(
path.join(tmpDir, 'src', 'vite-env.d.ts'),
'declare const APP_ENV: string;\n',
);
await fs.writeFile(path.join(tmpDir, 'src', 'esm-service.mts'), 'export class EsmService {}\n');
await fs.writeFile(
path.join(tmpDir, 'src', 'esm-service.d.mts'),
'export declare class EsmService {}\n',
);
await fs.writeFile(path.join(tmpDir, 'src', 'cjs-service.cts'), 'export class CjsService {}\n');
await fs.writeFile(
path.join(tmpDir, 'src', 'cjs-service.d.cts'),
'export declare class CjsService {}\n',
);
await fs.writeFile(
path.join(tmpDir, 'src', 'ambient.d.mts'),
'export declare class AmbientEsmService {}\n',
);
await fs.writeFile(
path.join(tmpDir, 'src', 'ambient.d.cts'),
'export declare class AmbientCjsService {}\n',
);
// Swift file — triggers language skip when grammar unavailable
await fs.writeFile(
@ -70,6 +100,15 @@ describe('ignore + language-skip E2E', () => {
expect(paths).toContain('src/index.ts');
expect(paths).toContain('src/greet.ts');
expect(paths).toContain('src/service.ts');
expect(paths).not.toContain('src/service.d.ts');
expect(paths).toContain('src/vite-env.d.ts');
expect(paths).toContain('src/esm-service.mts');
expect(paths).not.toContain('src/esm-service.d.mts');
expect(paths).toContain('src/cjs-service.cts');
expect(paths).not.toContain('src/cjs-service.d.cts');
expect(paths).toContain('src/ambient.d.mts');
expect(paths).toContain('src/ambient.d.cts');
});
it('includes .swift files (discovery does not filter by language)', async () => {
@ -130,6 +169,30 @@ describe('ignore + language-skip E2E', () => {
expect(functionNames).toContain('main');
expect(functionNames).toContain('greet');
const userServiceNodes = nodes.filter(
(node) => node.label === 'Class' && node.properties.name === 'UserService',
);
expect(userServiceNodes).toHaveLength(1);
expect(userServiceNodes[0].properties.filePath).toBe('src/service.ts');
expect(nodes.some((node) => node.properties.filePath === 'src/service.d.ts')).toBe(false);
expect(
nodes.filter((node) => node.label === 'Class' && node.properties.name === 'EsmService'),
).toHaveLength(1);
expect(
nodes.filter((node) => node.label === 'Class' && node.properties.name === 'CjsService'),
).toHaveLength(1);
expect(
nodes.filter(
(node) => node.label === 'Class' && node.properties.name === 'AmbientEsmService',
),
).toHaveLength(1);
expect(
nodes.filter(
(node) => node.label === 'Class' && node.properties.name === 'AmbientCjsService',
),
).toHaveLength(1);
// Function nodes should reference the correct source files
const fnFilePaths = functionNodes.map((n) =>
(n.properties.filePath as string).replace(/\\/g, '/'),

View file

@ -42,6 +42,21 @@ const SEED = [
`CREATE (leaf:Function {id: 'Function:src/util.ts:formatDate', name: 'formatDate', filePath: 'src/util.ts', startLine: 1, endLine: 3, isExported: true, content: '', description: ''})`,
`CREATE (caller:Function {id: 'Function:src/page.ts:renderHeader', name: 'renderHeader', filePath: 'src/page.ts', startLine: 1, endLine: 10, isExported: true, content: '', description: ''})`,
`MATCH (a:Function {id:'Function:src/page.ts:renderHeader'}), (b:Function {id:'Function:src/util.ts:formatDate'}) CREATE (a)-[:CodeRelation {type:'CALLS', confidence:0.9, reason:'direct', step:0}]->(b)`,
...[
['listOrders', 'query'],
['createOrder', 'mutation'],
['syncOrders', 'action'],
['readInternal', 'internalQuery'],
['writeInternal', 'internalMutation'],
['runInternal', 'internalAction'],
].map(
([name, factory]) =>
`CREATE (:Const {id: 'Const:src/convex.ts:${name}', name: '${name}', filePath: 'src/convex.ts', startLine: 1, endLine: 3, content: '', description: '', convexEndpointFactory: '${factory}'})`,
),
`CREATE (:Const {id: 'Const:src/negative.ts:localQuery', name: 'localQuery', filePath: 'src/negative.ts', startLine: 1, endLine: 1, content: '', description: '', convexEndpointFactory: ''})`,
`CREATE (:Const {id: 'Const:src/negative.ts:nestedQuery', name: 'nestedQuery', filePath: 'src/negative.ts', startLine: 2, endLine: 2, content: '', description: '', convexEndpointFactory: ''})`,
`CREATE (:Const {id: 'Const:src/negative.ts:memberQuery', name: 'memberQuery', filePath: 'src/negative.ts', startLine: 3, endLine: 3, content: '', description: '', convexEndpointFactory: ''})`,
];
withTestLbugDB(
@ -101,6 +116,40 @@ withTestLbugDB(
expect(result.impactedCount).toBeGreaterThanOrEqual(1);
});
it.each([
['listOrders', 'query'],
['createOrder', 'mutation'],
['syncOrders', 'action'],
['readInternal', 'internalQuery'],
['writeInternal', 'internalMutation'],
['runInternal', 'internalAction'],
])('marks Convex %s/%s runtime dispatch as a lower bound', async (target, factory) => {
const result = await backend.callTool('impact', {
target,
file_path: 'src/convex.ts',
direction: 'upstream',
});
expect(result.epistemic).toBe('lower-bound');
expect(result.boundaries.join(' ')).toContain(`Convex ${factory}`);
expect(result.boundaries.join(' ')).toContain('anyApi');
expect(result.causes.dispatchBoundary).toBe(0);
});
it.each(['localQuery', 'nestedQuery', 'memberQuery'])(
'keeps non-wrapper control %s exact',
async (target) => {
const result = await backend.callTool('impact', {
target,
file_path: 'src/negative.ts',
direction: 'upstream',
});
expect(result.epistemic).toBe('exact');
expect(result.boundaries).toBeUndefined();
},
);
it('context() carries the same epistemic signal', async () => {
const result = await backend.callTool('context', {
name: 'EmailLogger',

View file

@ -341,7 +341,7 @@ withTestLbugDB(
}
});
it('QUERY_VECTOR_INDEX works through the pool once the pre-warm loads VECTOR', async (ctx) => {
it('QUERY_VECTOR_INDEX works through the pool after its lazy query preflight', async (ctx) => {
const core = await import('../../src/core/lbug/lbug-adapter.js');
const { batchInsertEmbeddings } =
await import('../../src/core/embeddings/embedding-pipeline.js');
@ -376,12 +376,12 @@ withTestLbugDB(
// loads are per-Database, so a shared/injected Database would inherit
// the VECTOR load from createVectorIndex above and pass even without
// the pre-warm fix. A fresh Database has nothing loaded — only the
// pool's own pre-warm can make the vector lane legal.
// pool's own lazy query preflight can make the vector lane legal.
await core.closeLbug();
// The regression: through the POOL, the vector lane must work without
// any caller loading the extension. Pre-fix this rejects with
// "Catalog exception: function QUERY_VECTOR_INDEX is not defined".
// any caller loading the extension. The query-specific preflight loads
// VECTOR here while exact reads remain untouched.
await initLbug('vec-repo', handle.dbPath);
const vec = `CAST([${embedding.join(',')}] AS FLOAT[${EMBEDDING_DIMS}])`;
const rows = (await executeQuery(

View file

@ -12,6 +12,8 @@
* Fixture: `test/fixtures/multi-verb-route-app/`
* - ItemController.java: GET /api/items, POST /api/items, GET /api/widgets
* - app/api/widgets/route.ts: Next.js filesystem route → /api/widgets
* - api/widgetsController.ts: NestJS `@All` → method-agnostic /api/widgets,
* plus `@Get('gadgets')` → GET /api/gadgets (the non-colliding witness)
* - web/itemsClient.ts: verb-less fetch() consumers of both URLs
*/
import { describe, it, expect, beforeAll } from 'vitest';
@ -81,6 +83,56 @@ describe('Multi-verb Route node identity (#2289)', () => {
expect(decoratorNode!.properties.method).toBe('GET');
});
it("never stamps a losing route's handler onto a filesystem node (#3049)", () => {
// The NestJS `@All('widgets')` route keys by URL alone (routeNodeKey drops
// '*'), so it collides with the filesystem route, claims the key unopposed
// in claim(), and is then dropped here by first-writer-wins. Its handler
// must not survive that loss: a Next.js Route node carrying a NestJS
// controller method is a fabricated fact, not a missing one, and
// api_impact is documented to be run BEFORE editing a route handler.
const fsNode = routeNode(generateId('Route', '/api/widgets'));
expect(fsNode, 'filesystem Route node /api/widgets should exist').toBeTruthy();
expect(fsNode!.properties.handlerSymbolId).toBeUndefined();
});
it('extracts a NON-colliding NestJS route (the witness that #3049 suppressed a REAL claim)', () => {
// Load-bearing for the assertion above it, which cannot stand alone:
// `handlerSymbolId === undefined` passes identically whether the guard
// correctly dropped a real NestJS `@All` claim or whether NestJS
// extraction produced nothing at all. Disproved by experiment — commenting
// out `...extractNestRoutes(...args)` in `languages/typescript.ts` and
// rebuilding `dist/` left the whole suite green.
//
// Asserting that the `WidgetsController` class and its `handleEveryVerb`
// method exist does NOT repair that, and is the first thing the next
// reader will try: those nodes come from the definitions phase, which runs
// regardless, while `extractNestRoutes` is wired only as the
// `decoratorRoutes` hook and contributes no class or method node.
// `@All('widgets')` also cannot witness its own extraction — it collides
// on `routeNodeKey`, is dropped as a duplicate, and leaves no graph trace.
// A second route at a URL nothing else claims is the only evidence the
// graph can carry, so this node existing IS the proof the `@All` claim the
// guard suppressed was real.
const gadgets = routeNode(routeId('GET', '/api/gadgets'));
expect(gadgets, 'NestJS GET /api/gadgets Route node should exist').toBeTruthy();
const handler = result.graph.getNode(String(gadgets!.properties.handlerSymbolId));
expect(String(handler?.properties.name)).toBe('listGadgets');
});
it('still resolves a same-URL route that did NOT lose its key', () => {
// The guard above is scoped to pre-seeded sources, so the Java
// `GET /api/widgets` node — a different key, resolved for itself — keeps
// its handler. Without this, the fix reads as "filesystem collisions are
// handler-less" when the real rule is "a route that lost donates nothing".
const decoratorNode = routeNode(routeId('GET', '/api/widgets'));
const handler = result.graph.getNode(String(decoratorNode!.properties.handlerSymbolId));
expect(String(handler?.properties.name)).toBe('getWidgets');
});
it('connects a verb-less fetch() consumer to every Route node at the URL', () => {
const consumerFileId = generateId('File', 'web/itemsClient.ts');
// Collect FETCHES targets without a test-level conditional: pass the

View file

@ -0,0 +1,216 @@
/**
* End-to-end coverage of NestJS `@Controller` + `@Get`/`@Post`/`@Delete` route
* ingestion (#3009).
*
* The unit suite (`test/unit/nest-decorator-routes.test.ts`) pins what the
* extractor RETURNS. Nothing there proves the return value becomes anything:
* the reported symptom was not a wrong `ExtractedDecoratorRoute`, it was
* `api_impact` — whose documented job is to be run BEFORE modifying a route
* handler — reporting every live endpoint as non-existent, because the graph
* held no `Route` nodes at all. That is the tier this file covers: the routes
* phase performing the prefix/path join, `claim()` in call-processor resolving
* `handlerName` to a real symbol UID, and `prefix: null` surviving both.
*
* The fixture lives at `test/fixtures/nest-route-app/`, mirroring
* `spring-route-app/` — Spring solves the identical two-decorator shape and
* `nest.ts` is modelled on it.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'node:path';
import type { GraphNode, GraphRelationship } from 'gitnexus-shared';
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
import type { PipelineResult } from '../../src/types/pipeline.js';
const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'nest-route-app');
// Compared against POSIX-normalized graph paths (see `routes()`), so these stay
// forward-slashed rather than going through `path.join` — the Windows shard
// would otherwise compare backslashes against the graph's forward slashes.
const CONTROLLER_FILE = 'src/venues/venues.controller.ts';
const HEALTH_FILE = 'src/health/health.controller.ts';
interface RouteView {
/** `${method} ${url}` — the `routeNodeKey` identity, as a sortable string. */
readonly identity: string;
/** The handler the route resolved to, or the literal `'undefined'`. */
readonly handler: string;
readonly handlerLabel: string;
readonly handlerFile: string;
}
describe('NestJS decorator route ingestion pipeline', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(FIXTURE, () => {}, {});
}, 120_000);
const nodes = (): readonly GraphNode[] => {
const out: GraphNode[] = [];
result.graph.forEachNode((node) => void out.push(node));
return out;
};
const relationships = (): readonly GraphRelationship[] => {
const out: GraphRelationship[] = [];
result.graph.forEachRelationship((rel) => void out.push(rel));
return out;
};
const routeNodes = (): readonly GraphNode[] => nodes().filter((node) => node.label === 'Route');
/**
* Unresolved fields are stringified rather than branched on, so a route that
* lost its handler reads as the literal `'undefined'` in the diff instead of
* quietly skipping an assertion.
*/
const routes = (): readonly RouteView[] =>
routeNodes()
.map((node) => {
const handler = result.graph.getNode(String(node.properties.handlerSymbolId));
return {
identity: `${String(node.properties.method)} ${String(node.properties.name)}`,
handler: String(handler?.properties.name),
handlerLabel: String(handler?.label),
handlerFile: String(handler?.properties.filePath).replaceAll('\\', '/'),
};
})
.sort((a, b) => a.identity.localeCompare(b.identity));
const identities = (): readonly string[] => routes().map((route) => route.identity);
it('emits Route nodes at all — the reported symptom was zero', () => {
// Its own case because every expectation below is satisfiable by an empty
// graph in the ways that matter least: `not.toContain` passes vacuously on
// an empty array, and a full-table `toEqual` against `[]` reports a missing
// element rather than "the extractor never ran".
expect(routeNodes().length).toBeGreaterThan(0);
});
it('mounts a pathless @Get() at the controller prefix WITH its handler attached', () => {
// The reason `nest.ts` emits '/' instead of '' for a pathless decorator:
// `claim()` short-circuits on a falsy `routePath`, so '' would still create
// this Route node and silently drop `handlerSymbolId`. The unit test can
// only see `routePath === '/'` at the extractor boundary; the guard being
// load-bearing is observable here and nowhere else.
expect(routes().find((route) => route.identity === 'GET /venues')).toEqual({
identity: 'GET /venues',
handler: 'findAll',
handlerLabel: 'Method',
handlerFile: CONTROLLER_FILE,
});
});
it('joins each @Controller prefix with its method paths and resolves every handler', () => {
// Two invariants, one table, because a projection of this assertion can
// only fail where this one already does. The join is what the extractor
// cannot do alone — it ships `prefix` and the routes phase folds it in via
// `normalizeExtractedRoutePath`, so these read `/venues/search`, not
// `search`. And `claim()` is first-writer-wins per `(method, url)`, so a
// mis-keyed route surfaces as a handler donated to the wrong URL rather
// than as an absence.
expect(routes()).toEqual([
{
identity: 'DELETE /venues/:id',
handler: 'remove',
handlerLabel: 'Method',
handlerFile: CONTROLLER_FILE,
},
{
identity: 'GET /health',
handler: 'health',
handlerLabel: 'Method',
handlerFile: HEALTH_FILE,
},
{
identity: 'GET /venues',
handler: 'findAll',
handlerLabel: 'Method',
handlerFile: CONTROLLER_FILE,
},
{
identity: 'GET /venues/search',
handler: 'search',
handlerLabel: 'Method',
handlerFile: CONTROLLER_FILE,
},
{
identity: 'POST /venues',
handler: 'create',
handlerLabel: 'Method',
handlerFile: CONTROLLER_FILE,
},
]);
});
it('splits one URL into one Route node per verb', () => {
// `@Get()` and `@Post()` on the same controller share a URL. `routeNodeKey`
// makes them distinct identities; collapsing them would drop a live
// endpoint and hand its handler to the survivor.
expect(
routes()
.filter((route) => route.identity.endsWith(' /venues'))
.map((route) => `${route.identity} -> ${route.handler}`),
).toEqual(['GET /venues -> findAll', 'POST /venues -> create']);
});
it('carries a prefix-less @Controller() through to a bare URL', () => {
// `@Controller()` maps to `prefix: null`, and null must reach the join as
// "no prefix" rather than as the string 'null' or a leading empty segment.
expect(identities()).toContain('GET /health');
expect(identities().filter((id) => id.includes('//'))).toEqual([]);
});
it('records per-decorator provenance on the HANDLES_ROUTE edge', () => {
// Nest routes are DECLARED by an annotation, so they take the generic
// `decorator-<name>` source rather than a bespoke one — the same channel
// Spring and FastAPI use, which is the point of modelling on `spring.ts`.
const routeIds = new Set(routeNodes().map((node) => node.id));
expect(
[
...new Set(
relationships()
.filter((rel) => rel.type === 'HANDLES_ROUTE' && routeIds.has(rel.targetId))
.map((rel) => String(rel.reason)),
),
].sort(),
).toEqual(['decorator-Delete', 'decorator-Get', 'decorator-Post']);
});
describe('precision — what must NOT become a route', () => {
// Absence assertions are satisfied just as well by a file that was never
// read, so prove ingestion first; otherwise this whole block is decoration.
it('ingested the unreadable-prefix controller', () => {
expect(
nodes()
.filter((node) => String(node.properties.filePath ?? '').endsWith('legacy.controller.ts'))
.map((node) => String(node.properties.name)),
).toEqual(expect.arrayContaining(['LegacyController', 'legacyReports']));
});
it('drops a controller whose prefix is not a literal', () => {
// `@Controller(ROUTE_PREFIXES.legacy)` — the URL is unknowable, and
// `route_map` presents its output as fact. Emitting `/reports` here would
// be a wrong route, which is worse than a missing one.
expect(identities().filter((id) => id.includes('reports'))).toEqual([]);
expect(identities().filter((id) => id.includes('legacy'))).toEqual([]);
});
it('ingested the decorated non-controller service', () => {
expect(
nodes()
.filter((node) => String(node.properties.filePath ?? '').endsWith('venues.service.ts'))
.map((node) => String(node.properties.name)),
).toEqual(expect.arrayContaining(['VenuesService', 'listVenues', 'searchVenues']));
});
it('does not mint routes from a class with no @Controller', () => {
// Verb decorators are believed only inside a `@Controller` class; without
// that gate any library sharing the names would mint phantom endpoints.
expect(routes().filter((route) => route.handlerFile.endsWith('venues.service.ts'))).toEqual(
[],
);
});
});
});

View file

@ -0,0 +1,185 @@
import { beforeAll, describe, expect, it, vi } from 'vitest';
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
import { listRegisteredRepos } from '../../src/storage/repo-manager.js';
import { withTestLbugDB, type IndexedDBHandle } from '../helpers/test-indexed-db.js';
vi.mock('../../src/storage/repo-manager.js', async (importActual) => ({
...(await importActual<typeof import('../../src/storage/repo-manager.js')>()),
listRegisteredRepos: vi.fn().mockResolvedValue([]),
cleanupOldKuzuFiles: vi.fn().mockResolvedValue({ found: false, needsReindex: false }),
findSiblingClones: vi.fn().mockResolvedValue([]),
}));
type BackendHandle = IndexedDBHandle & { backend?: LocalBackend };
const FIRST = 'Const:src/convex.ts:first';
const SECOND = 'Const:src/convex.ts:second';
const THIRD = 'Variable:src/convex.ts:third';
const FIRST_HANDLER = 'Function:src/convex.ts:first.handler';
const SECOND_HANDLER = 'Function:src/convex.ts:second.handler';
const THIRD_HANDLER = 'Function:src/convex.ts:third.handler';
const HELPER_A = 'Function:src/convex.ts:helperA';
const HELPER_B = 'Function:src/convex.ts:helperB';
const HELPER_C = 'Function:src/convex.ts:helperC';
const SERVICE = 'Const:src/convex.ts:service';
const SERVICE_RUN = 'Method:src/convex.ts:service.run#0';
const HELPER_D = 'Function:src/convex.ts:helperD';
withTestLbugDB(
'object-literal-impact-3041',
(handle) => {
describe('downstream impact through object-owned callables (#3041)', () => {
let backend: LocalBackend;
beforeAll(() => {
const attached = (handle as BackendHandle).backend;
if (!attached) throw new Error('LocalBackend was not attached during setup');
backend = attached;
});
it.each([
['first', HELPER_A, HELPER_B],
['second', HELPER_B, HELPER_A],
])('%s reaches only its own helper', async (target, expected, excluded) => {
const result = await backend.callTool('impact', {
target,
direction: 'downstream',
});
expect(result).not.toHaveProperty('error');
const ids = Object.values(result.byDepth ?? {})
.flatMap((entries) => entries as Array<{ id: string }>)
.map((entry) => entry.id);
expect(ids).toContain(expected);
expect(ids).not.toContain(excluded);
});
it('a let binding reaches its owned handler calls', async () => {
const result = await backend.callTool('impact', {
target: 'third',
direction: 'downstream',
});
expect(result).not.toHaveProperty('error');
const ids = Object.values(result.byDepth ?? {})
.flatMap((entries) => entries as Array<{ id: string }>)
.map((entry) => entry.id);
expect(ids).toContain(HELPER_C);
expect(ids).not.toContain(HELPER_A);
expect(ids).not.toContain(HELPER_B);
});
it('counts the implicit member as depth 1 and its callee as depth 2', async () => {
const firstHop = await backend.callTool('impact', {
target: 'first',
direction: 'downstream',
maxDepth: 1,
});
expect(firstHop.byDepth?.['1']?.map((entry: { id: string }) => entry.id)).toEqual([
FIRST_HANDLER,
]);
expect(firstHop.byDepth?.['2']).toBeUndefined();
const secondHop = await backend.callTool('impact', {
target: 'first',
direction: 'downstream',
maxDepth: 2,
});
expect(secondHop.byDepth?.['1']?.map((entry: { id: string }) => entry.id)).toEqual([
FIRST_HANDLER,
]);
expect(secondHop.byDepth?.['2']?.map((entry: { id: string }) => entry.id)).toContain(
HELPER_A,
);
expect(secondHop.summary?.direct).toBe(1);
});
it('does not widen an explicit CALLS-only traversal through HAS_METHOD', async () => {
const result = await backend.callTool('impact', {
target: 'first',
direction: 'downstream',
relationTypes: ['CALLS'],
maxDepth: 3,
});
expect(result.impactedCount).toBe(0);
expect(result.byDepth).toEqual({});
});
it('enters a Method-labelled shorthand member on the default traversal', async () => {
const result = await backend.callTool('impact', {
target: 'service',
direction: 'downstream',
maxDepth: 2,
});
expect(result.byDepth?.['1']?.map((entry: { id: string }) => entry.id)).toEqual([
SERVICE_RUN,
]);
expect(result.byDepth?.['2']?.map((entry: { id: string }) => entry.id)).toEqual([HELPER_D]);
});
it('preserves explicit HAS_METHOD traversal depth', async () => {
const firstHop = await backend.callTool('impact', {
target: 'first',
direction: 'downstream',
maxDepth: 1,
relationTypes: ['HAS_METHOD', 'CALLS'],
});
expect(firstHop).not.toHaveProperty('error');
expect(firstHop.byDepth?.['1']?.map((entry: { id: string }) => entry.id)).toEqual([
FIRST_HANDLER,
]);
const secondHop = await backend.callTool('impact', {
target: 'first',
direction: 'downstream',
maxDepth: 2,
relationTypes: ['HAS_METHOD', 'CALLS'],
});
expect(secondHop).not.toHaveProperty('error');
expect(secondHop.byDepth?.['2']?.map((entry: { id: string }) => entry.id)).toContain(
HELPER_A,
);
});
});
},
{
seed: [
`CREATE (:Const {id: '${FIRST}', name: 'first', filePath: 'src/convex.ts', startLine: 1, endLine: 1})`,
`CREATE (:Const {id: '${SECOND}', name: 'second', filePath: 'src/convex.ts', startLine: 2, endLine: 2})`,
`CREATE (:Variable {id: '${THIRD}', name: 'third', filePath: 'src/convex.ts', startLine: 3, endLine: 3})`,
`CREATE (:Function {id: '${FIRST_HANDLER}', name: 'handler', filePath: 'src/convex.ts', startLine: 3, endLine: 3})`,
`CREATE (:Function {id: '${SECOND_HANDLER}', name: 'handler', filePath: 'src/convex.ts', startLine: 4, endLine: 4})`,
`CREATE (:Function {id: '${THIRD_HANDLER}', name: 'handler', filePath: 'src/convex.ts', startLine: 5, endLine: 5})`,
`CREATE (:Function {id: '${HELPER_A}', name: 'helperA', filePath: 'src/convex.ts', startLine: 5, endLine: 5})`,
`CREATE (:Function {id: '${HELPER_B}', name: 'helperB', filePath: 'src/convex.ts', startLine: 6, endLine: 6})`,
`CREATE (:Function {id: '${HELPER_C}', name: 'helperC', filePath: 'src/convex.ts', startLine: 7, endLine: 7})`,
`CREATE (:Const {id: '${SERVICE}', name: 'service', filePath: 'src/convex.ts', startLine: 8, endLine: 8})`,
`CREATE (:Method {id: '${SERVICE_RUN}', name: 'run', filePath: 'src/convex.ts', startLine: 8, endLine: 8})`,
`CREATE (:Function {id: '${HELPER_D}', name: 'helperD', filePath: 'src/convex.ts', startLine: 9, endLine: 9})`,
`MATCH (a:Const), (b:Function) WHERE a.id = '${FIRST}' AND b.id = '${FIRST_HANDLER}' CREATE (a)-[:CodeRelation {type: 'HAS_METHOD', confidence: 1.0, reason: 'object literal member'}]->(b)`,
`MATCH (a:Const), (b:Function) WHERE a.id = '${SECOND}' AND b.id = '${SECOND_HANDLER}' CREATE (a)-[:CodeRelation {type: 'HAS_METHOD', confidence: 1.0, reason: 'object literal member'}]->(b)`,
`MATCH (a:Variable), (b:Function) WHERE a.id = '${THIRD}' AND b.id = '${THIRD_HANDLER}' CREATE (a)-[:CodeRelation {type: 'HAS_METHOD', confidence: 1.0, reason: 'object literal member'}]->(b)`,
`MATCH (a:Function), (b:Function) WHERE a.id = '${FIRST_HANDLER}' AND b.id = '${HELPER_A}' CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 0.85, reason: 'direct'}]->(b)`,
`MATCH (a:Function), (b:Function) WHERE a.id = '${SECOND_HANDLER}' AND b.id = '${HELPER_B}' CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 0.85, reason: 'direct'}]->(b)`,
`MATCH (a:Function), (b:Function) WHERE a.id = '${THIRD_HANDLER}' AND b.id = '${HELPER_C}' CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 0.85, reason: 'direct'}]->(b)`,
`MATCH (a:Const), (b:Method) WHERE a.id = '${SERVICE}' AND b.id = '${SERVICE_RUN}' CREATE (a)-[:CodeRelation {type: 'HAS_METHOD', confidence: 1.0, reason: 'object literal member'}]->(b)`,
`MATCH (a:Method), (b:Function) WHERE a.id = '${SERVICE_RUN}' AND b.id = '${HELPER_D}' CREATE (a)-[:CodeRelation {type: 'CALLS', confidence: 0.85, reason: 'direct'}]->(b)`,
],
poolAdapter: true,
afterSetup: async (handle) => {
vi.mocked(listRegisteredRepos).mockResolvedValue([
{
name: 'object-literal-impact-repo',
path: '/object-literal-impact/repo',
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'abc123',
stats: { files: 1, nodes: 6, communities: 0, processes: 0 },
},
]);
const backend = new LocalBackend();
await backend.init();
(handle as BackendHandle).backend = backend;
},
},
);

View file

@ -36,6 +36,17 @@ import {
type PipelineResult,
} from './resolvers/helpers.js';
import { generateId } from '../../src/lib/utils.js';
import {
loadParseCache,
PARSE_CACHE_VERSION,
pruneCache,
saveParseCache,
type ParseCache,
} from '../../src/storage/parse-cache.js';
import {
getDurableParsedFileDir,
pruneAndSaveDurableParsedFileStore,
} from '../../src/storage/parsedfile-store.js';
const DIST_WORKER = path.resolve(
__dirname,
@ -146,7 +157,7 @@ describe.skipIf(!hasDistWorker)('object-literal owner resolution — worker pipe
// The Method node id encodes arity disambiguation (#1 = one-arity overload).
// Pin the canonical id so a regression that targets a phantom node fails.
const expectedTargetId = generateId('Method', 'src/service.ts:getUser#1');
const expectedTargetId = generateId('Method', 'src/service.ts:fooService.getUser#1');
expect(callerToGetUser).toEqual([
{
targetId: expectedTargetId,
@ -236,3 +247,331 @@ describe.skipIf(!hasDistWorker)(
});
},
);
// ── #3041: same-named function-valued properties ───────────────────────────
describe.skipIf(!hasDistWorker)(
'object-literal owner resolution — same-named property callables (#3041)',
() => {
let repoRoot: string;
let result: PipelineResult;
beforeAll(async () => {
repoRoot = writeFixture({
'src/convex.ts': `function query<T>(config: T): T { return config; }
function helperA(ctx: unknown) { return ctx; }
function helperB(ctx: unknown) { return ctx; }
function helperC(ctx: unknown) { return ctx; }
export const first = query({ handler: async (ctx: unknown) => helperA(ctx) }); export const second = query({ handler: async (ctx: unknown) => helperB(ctx) });
export let third = query({ handler: async (ctx: unknown) => helperC(ctx) });
`,
});
result = await runPipelineFromRepo(repoRoot, () => undefined, {
skipGraphPhases: true,
});
}, 60000);
afterAll(() => removeFixture(repoRoot));
it('creates one owner-qualified handler node per exported const', () => {
const handlerIds: string[] = [];
result.graph.forEachNode((node) => {
if (node.label === 'Function' && node.properties.name === 'handler') {
handlerIds.push(node.id);
}
});
expect(handlerIds.sort()).toEqual(
[
generateId('Function', 'src/convex.ts:first.handler'),
generateId('Function', 'src/convex.ts:second.handler'),
generateId('Function', 'src/convex.ts:third.handler'),
].sort(),
);
});
it('links each exported const to only its own handler', () => {
const ownership = getRelationships(result, 'HAS_METHOD')
.filter((edge) => edge.target === 'handler')
.map((edge) => `${edge.source}:${edge.rel.targetId}`)
.sort();
expect(ownership).toEqual(
[
`first:${generateId('Function', 'src/convex.ts:first.handler')}`,
`second:${generateId('Function', 'src/convex.ts:second.handler')}`,
`third:${generateId('Function', 'src/convex.ts:third.handler')}`,
].sort(),
);
});
it('keeps each handler call on its real owner with no cross-attribution', () => {
const calls = getRelationships(result, 'CALLS')
.filter(
(edge) =>
edge.target === 'helperA' || edge.target === 'helperB' || edge.target === 'helperC',
)
.map((edge) => `${edge.rel.sourceId}->${edge.target}`)
.sort();
expect(calls).toEqual(
[
`${generateId('Function', 'src/convex.ts:first.handler')}->helperA`,
`${generateId('Function', 'src/convex.ts:second.handler')}->helperB`,
`${generateId('Function', 'src/convex.ts:third.handler')}->helperC`,
].sort(),
);
});
it('keeps owner-qualified handlers reachable from their file definition', () => {
const defined = getRelationships(result, 'DEFINES')
.filter((edge) => edge.target === 'handler')
.map((edge) => edge.rel.targetId)
.sort();
expect(defined).toEqual(
[
generateId('Function', 'src/convex.ts:first.handler'),
generateId('Function', 'src/convex.ts:second.handler'),
generateId('Function', 'src/convex.ts:third.handler'),
].sort(),
);
});
},
);
describe.skipIf(!hasDistWorker)('object-literal shorthand method identity (#3041)', () => {
let repoRoot: string;
let result: PipelineResult;
beforeAll(async () => {
repoRoot = writeFixture({
'src/shorthand.ts': `function helperA(value: string) { return value; }
function helperB(value: string) { return value; }
export const alpha = { run(value: string) { return helperA(value); } };
export const beta = { run(value: string) { return helperB(value); } };
`,
});
result = await runPipelineFromRepo(repoRoot, () => undefined, {
skipGraphPhases: true,
});
}, 60_000);
afterAll(() => removeFixture(repoRoot));
it('gives sibling shorthand methods distinct owner-qualified identities and calls', () => {
const nodeIds = new Set<string>();
result.graph.forEachNode((node) => nodeIds.add(node.id));
const alphaRun = generateId('Method', 'src/shorthand.ts:alpha.run#1');
const betaRun = generateId('Method', 'src/shorthand.ts:beta.run#1');
const calls = getRelationships(result, 'CALLS')
.filter((edge) => edge.target === 'helperA' || edge.target === 'helperB')
.map((edge) => `${edge.rel.sourceId}->${edge.target}`)
.sort();
expect(nodeIds.has(alphaRun)).toBe(true);
expect(nodeIds.has(betaRun)).toBe(true);
expect(calls).toEqual([`${alphaRun}->helperA`, `${betaRun}->helperB`]);
});
it('keeps shorthand methods on both File DEFINES and binding HAS_METHOD edges', () => {
const methodIds = [
generateId('Method', 'src/shorthand.ts:alpha.run#1'),
generateId('Method', 'src/shorthand.ts:beta.run#1'),
].sort();
const defined = getRelationships(result, 'DEFINES')
.filter((edge) => edge.target === 'run')
.map((edge) => edge.rel.targetId)
.sort();
const owned = getRelationships(result, 'HAS_METHOD')
.filter((edge) => edge.target === 'run')
.map((edge) => edge.rel.targetId)
.sort();
expect(defined).toEqual(methodIds);
expect(owned).toEqual(methodIds);
});
});
describe.skipIf(!hasDistWorker)('object-literal dotted property identity (#3041)', () => {
let repoRoot: string;
let result: PipelineResult;
beforeAll(async () => {
repoRoot = writeFixture({
'src/dotted.ts': `function helperC(value: number) { return value; }
function helperD(value: number) { return value; }
export const p = { 'q.r': (value: number) => helperC(value) };
export const z = { r: (value: number) => helperD(value) };
`,
});
result = await runPipelineFromRepo(repoRoot, () => undefined, {
skipGraphPhases: true,
});
}, 60_000);
afterAll(() => removeFixture(repoRoot));
it('attributes dotted and plain member calls to their own owner-qualified nodes', () => {
const calls = getRelationships(result, 'CALLS')
.filter((edge) => edge.target === 'helperC' || edge.target === 'helperD')
.map((edge) => `${edge.rel.sourceId}->${edge.target}`)
.sort();
expect(calls).toEqual(
[
`${generateId('Function', 'src/dotted.ts:p.q.r')}->helperC`,
`${generateId('Function', 'src/dotted.ts:z.r')}->helperD`,
].sort(),
);
});
});
describe.skipIf(!hasDistWorker)('object-literal array ownership barrier (#3041)', () => {
let repoRoot: string;
let result: PipelineResult;
beforeAll(async () => {
repoRoot = writeFixture({
'src/array.ts': `function helperA(value: number) { return value; }
function helperB(value: number) { return value; }
export const handlers = [
{ handle: (value: number) => helperA(value) },
{ handle: (value: number) => helperB(value) },
];
`,
});
result = await runPipelineFromRepo(repoRoot, () => undefined, {
skipGraphPhases: true,
});
}, 60_000);
afterAll(() => removeFixture(repoRoot));
it('keeps array-contained callables distinct without inventing ownership', () => {
const falseId = generateId('Function', 'src/array.ts:handlers.handle');
const bareId = generateId('Function', 'src/array.ts:handle');
const handlerIds = [
generateId('Function', 'src/array.ts:handle@3:12'),
generateId('Function', 'src/array.ts:handle@4:12'),
].sort();
const nodeIds = new Set<string>();
result.graph.forEachNode((node) => nodeIds.add(node.id));
const calls = getRelationships(result, 'CALLS')
.filter((edge) => edge.target === 'helperA' || edge.target === 'helperB')
.map((edge) => `${edge.rel.sourceId}->${edge.target}`)
.sort();
const falseOwnership = getRelationships(result, 'HAS_METHOD').filter(
(edge) => edge.source === 'handlers' && edge.target === 'handle',
);
expect(nodeIds.has(falseId)).toBe(false);
expect(nodeIds.has(bareId)).toBe(false);
expect(handlerIds.every((id) => nodeIds.has(id))).toBe(true);
expect(calls).toEqual([`${handlerIds[0]}->helperA`, `${handlerIds[1]}->helperB`].sort());
expect(falseOwnership).toEqual([]);
});
});
describe.skipIf(!hasDistWorker)('nested array object Method identity (#3041)', () => {
let repoRoot: string;
let result: PipelineResult;
beforeAll(async () => {
repoRoot = writeFixture({
'src/nested-array.ts': `function helperA(value: number) { return value; }
function helperB(value: number) { return value; }
export const registry = {
groups: [[
{ handle(value: number) { return helperA(value); } },
{ handle(value: number) { return helperB(value); } },
]],
};
`,
});
result = await runPipelineFromRepo(repoRoot, () => undefined, {
skipGraphPhases: true,
});
}, 60_000);
afterAll(() => removeFixture(repoRoot));
it('position-qualifies shorthand methods through nested arrays and objects', () => {
const expectedIds = [
generateId('Method', 'src/nested-array.ts:handle@4:6#1'),
generateId('Method', 'src/nested-array.ts:handle@5:6#1'),
].sort();
const nodeIds = new Set<string>();
result.graph.forEachNode((node) => nodeIds.add(node.id));
const calls = getRelationships(result, 'CALLS')
.filter((edge) => edge.target === 'helperA' || edge.target === 'helperB')
.map((edge) => `${edge.rel.sourceId}->${edge.target}`)
.sort();
const ownership = getRelationships(result, 'HAS_METHOD').filter(
(edge) => edge.target === 'handle',
);
expect(expectedIds.every((id) => nodeIds.has(id))).toBe(true);
expect(calls).toEqual([`${expectedIds[0]}->helperA`, `${expectedIds[1]}->helperB`].sort());
expect(ownership).toEqual([]);
});
});
describe.skipIf(!hasDistWorker)('object-literal callable durable cache (#3041)', () => {
it('replays owner-qualified handler identities and calls without workers', async () => {
const repoRoot = writeFixture({
'src/convex.ts': `function query<T>(config: T): T { return config; }
function helperA(ctx: unknown) { return ctx; }
function helperB(ctx: unknown) { return ctx; }
export const first = query({ handler: (ctx: unknown) => helperA(ctx) });
export const second = query({ handler: (ctx: unknown) => helperB(ctx) });
`,
});
const storage = fs.mkdtempSync(path.join(os.tmpdir(), 'gnx-objlit-cache-'));
try {
const coldCache: ParseCache = {
version: PARSE_CACHE_VERSION,
entries: new Map(),
usedKeys: new Set(),
storagePath: storage,
onDiskKeys: new Set(),
};
const cold = await runPipelineFromRepo(repoRoot, () => undefined, {
skipGraphPhases: true,
parseCache: coldCache,
workerPoolSize: 1,
});
pruneCache(coldCache, coldCache.usedKeys);
const savedKeys = await saveParseCache(storage, coldCache);
await pruneAndSaveDurableParsedFileStore(
getDurableParsedFileDir(storage),
PARSE_CACHE_VERSION,
new Set(savedKeys),
);
const warmCache = await loadParseCache(storage);
const warm = await runPipelineFromRepo(repoRoot, () => undefined, {
skipGraphPhases: true,
parseCache: warmCache ?? undefined,
workerPoolSize: 1,
});
const project = (pipeline: PipelineResult) =>
getRelationships(pipeline, 'CALLS')
.filter((edge) => edge.target === 'helperA' || edge.target === 'helperB')
.map((edge) => `${edge.rel.sourceId}->${edge.target}`)
.sort();
expect(warm.usedWorkerPool).toBe(false);
expect(project(warm)).toEqual(project(cold));
expect(project(warm)).toEqual(
[
`${generateId('Function', 'src/convex.ts:first.handler')}->helperA`,
`${generateId('Function', 'src/convex.ts:second.handler')}->helperB`,
].sort(),
);
} finally {
removeFixture(repoRoot);
fs.rmSync(storage, { recursive: true, force: true });
}
}, 120_000);
});

View file

@ -128,7 +128,7 @@ describe('incremental reuse gate — schema fingerprint (U-C5, #2798)', () => {
});
});
describe('semantic (non-DDL) analyzer changes ride the runner-identity receipt (#2798)', () => {
describe('semantic and id-shape changes ride the runner-identity receipt (#2798/#3041)', () => {
it('run-analyze.ts still forces a full rebuild when the stamped runner identity differs', () => {
// The invariant the INCREMENTAL_SCHEMA_VERSION ladder used to backstop. It
// is implicit nowhere else: no other gate observes analyzer code that emits

View file

@ -23,6 +23,7 @@ const { lbugMocks } = vi.hoisted(() => ({
initLbug: vi.fn().mockResolvedValue(undefined),
executeQuery: vi.fn().mockResolvedValue([]),
executeParameterized: vi.fn().mockResolvedValue([]),
ensureVectorExtension: vi.fn().mockResolvedValue(true),
closeLbug: vi.fn().mockResolvedValue(undefined),
isLbugReady: vi.fn().mockReturnValue(true),
},
@ -684,9 +685,8 @@ describe('LocalBackend.callTool', () => {
});
it('falls back to the exact scan with a once-per-backend warning when the vector index query fails', async () => {
// The platform gate is gone (#2623 follow-up): the vector lane is always
// ATTEMPTED, and a runtime failure (extension unloadable, index absent) is
// what routes semantic search onto the exact scan.
// Once the lazy extension preflight succeeds, a runtime index-query failure
// routes semantic search onto the exact scan.
const cap = _captureLogger();
(executeQuery as any).mockImplementation(async (_repoId: string, cypher: string) => {
if (cypher.includes('COUNT(*) AS cnt')) return [{ cnt: 1 }];

View file

@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { queryConvexDispatchMetadata } from '../../src/mcp/local/convex-metadata.js';
describe('Convex dispatch metadata compatibility', () => {
it('marks a pre-property index as conservatively incomplete', async () => {
const missingProperty = async (): Promise<never> => {
throw new Error('Cannot find property convexEndpointFactory for n');
};
const result = await queryConvexDispatchMetadata(
'/tmp/old-index',
'Const:x',
'x',
'Const',
missingProperty,
);
expect(result?.staleIndex).toBe(true);
expect(result?.boundary).toContain('re-index');
});
it('marks unrelated query failures as conservatively incomplete', async () => {
const transientFailure = async (): Promise<never> => {
throw new Error('database busy');
};
const result = await queryConvexDispatchMetadata(
'/tmp/index',
'Const:x',
'x',
'Const',
transientFailure,
);
expect(result?.probeFailed).toBe(true);
expect(result?.boundary).toContain('could not be checked');
});
it('queries Function metadata without an undeclared deterministic LIMIT', async () => {
let cypher = '';
const runQuery = async (_path: string, query: string) => {
cypher = query;
return [{ factory: 'query' }];
};
await expect(
queryConvexDispatchMetadata('/tmp/index', 'Function:x', 'x', 'Function', runQuery),
).resolves.toMatchObject({ factory: 'query' });
expect(cypher).toContain('MATCH (n:Function');
expect(cypher).not.toContain('LIMIT');
});
});

Some files were not shown because too many files have changed in this diff Show more