mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-09 03:17:54 +00:00
Merge branch 'main' into main
This commit is contained in:
commit
978e6901a7
142 changed files with 19410 additions and 638 deletions
|
|
@ -6,7 +6,7 @@
|
|||
"plugins": [
|
||||
{
|
||||
"name": "gitnexus",
|
||||
"version": "1.6.9",
|
||||
"version": "1.6.10",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./gitnexus-claude-plugin"
|
||||
|
|
|
|||
|
|
@ -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
12
.gitattributes
vendored
|
|
@ -15,3 +15,15 @@
|
|||
*.so binary
|
||||
*.dll binary
|
||||
*.dylib binary
|
||||
|
||||
# TypeScript sources are always text for diff purposes. Git's binary
|
||||
# heuristic fires when EITHER blob in a pair carries a NUL, so a source
|
||||
# file that carried one on a base commit still renders as "Binary files
|
||||
# differ" — with no hunks and no inline comments — long after the byte
|
||||
# itself is gone from the working tree. A head-side guard cannot see
|
||||
# that, by construction. This does not mark the files binary or change
|
||||
# how they are stored; it only stops the heuristic from hiding a diff.
|
||||
*.ts diff
|
||||
*.tsx diff
|
||||
*.mts diff
|
||||
*.cts diff
|
||||
|
|
|
|||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
|
|
@ -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 }}'
|
||||
|
|
|
|||
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/scorecard.yml
vendored
2
.github/workflows/scorecard.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
.github/workflows/trivy.yml
vendored
4
.github/workflows/trivy.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
2
.github/workflows/workflow-lint.yml
vendored
2
.github/workflows/workflow-lint.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"mcpServers": {
|
||||
"gitnexus": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "gitnexus@1.6.9", "mcp"]
|
||||
"args": ["-y", "gitnexus@1.6.10", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
61
gitnexus-web/package-lock.json
generated
61
gitnexus-web/package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -58,9 +58,6 @@ packages: {}
|
|||
detect:
|
||||
http: true
|
||||
matching:
|
||||
bm25_threshold: 0.7
|
||||
embedding_threshold: 0.65
|
||||
max_candidates_per_step: 3
|
||||
`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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`."
|
||||
}
|
||||
|
|
|
|||
4
gitnexus/package-lock.json
generated
4
gitnexus/package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -65,6 +65,16 @@ export const WINDOWS_WEIGHTS_SEC: Readonly<Record<string, number>> = {
|
|||
'test/integration/antigravity-hook-e2e.test.ts': 7,
|
||||
'test/unit/index-lock.test.ts': 5,
|
||||
'test/unit/setup.test.ts': 5,
|
||||
// ESTIMATE, not a measurement. This file asserts almost nothing; it READS —
|
||||
// one 4893-file pass over every tracked text file, plus an 830-file pass over
|
||||
// `src/`. Measured at 2.3 s and 0.3 s per pass on a virtualised and a local
|
||||
// Linux filesystem respectively, so the cost is entirely per-file open
|
||||
// latency, which is the term Windows inflates most (NTFS plus Defender on
|
||||
// every read). Scaled from the slower Linux figure to keep the split
|
||||
// conservative rather than let the 8 s PER_FILE_OVERHEAD floor under-charge
|
||||
// a file that touches more paths than anything else here. Replace with a real
|
||||
// figure after the first green Windows matrix run.
|
||||
'test/unit/source-control-bytes.test.ts': 15,
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -208,6 +208,18 @@ const SPAWN_CLI = [
|
|||
// exposed a file-backend double-admit race here (#2658 review); the reclaim is
|
||||
// now judgment-verified so a live holder is never displaced.
|
||||
'test/integration/analyze-index-lock-concurrency.test.ts',
|
||||
// The per-group sync lock (R9), same class of guarantee one level up: real
|
||||
// child processes contend for one group's lock while this process runs a real
|
||||
// `syncGroup`, and the CLI case spawns the real command. Everything that
|
||||
// varies here is platform-owned — which backend `selectBackend()` picks
|
||||
// (Windows named pipe / Linux abstract socket / macOS file lock), kernel
|
||||
// auto-release on SIGKILL vs. the file backend's pid-liveness reclaim, and
|
||||
// `mkdir` over an occupied path. The fail-closed cases pin
|
||||
// GITNEXUS_INDEX_LOCK_BACKEND=file so the filesystem branch is exercised on
|
||||
// every OS rather than only where it is the default; no case is skipped on
|
||||
// any platform, because a skipped case turns "a sync that cannot be protected
|
||||
// does not run" into a claim that holds on Ubuntu only.
|
||||
'test/integration/group/group-sync-lock-concurrency.test.ts',
|
||||
// The three `dist/` module-load closure guards, all built on the shared
|
||||
// child-process probe in `test/helpers/module-load-probe.ts`. That probe IS
|
||||
// the platform-varying part: it spawns `process.execPath` in array form,
|
||||
|
|
@ -261,6 +273,28 @@ const FILESYSTEM = [
|
|||
'test/integration/filesystem-walker.test.ts',
|
||||
'test/integration/markdown-processor-crlf.test.ts',
|
||||
'test/integration/ignore-and-skip-e2e.test.ts',
|
||||
// Pins that the bridge pairing verdict is measured before the database is
|
||||
// opened. The property it protects is about mtime behavior across OS and
|
||||
// filesystem, and the alternative — really opening the bridge — cannot run on
|
||||
// Windows at all (in-process write→read reopen of the same bridge.lbug is a
|
||||
// documented limitation). Running it on every platform is the whole point:
|
||||
// Windows is where an unverified assumption about mtime would hurt most.
|
||||
'test/unit/group/bridge-pairing-precedes-open.test.ts',
|
||||
// The raw-control-byte guard reads every tracked text file `git ls-files`
|
||||
// reports — 4893 of them — and decides membership from the git path, which is
|
||||
// always `/`-separated no matter what the host separator is. Both halves of
|
||||
// that are platform-varying: the collector basename-matches with
|
||||
// `path.posix.basename` against `git ls-files -z` output while the reads go
|
||||
// through `path.join`, so on Windows the same string is consumed under two
|
||||
// separator conventions in one pass, and only a real windows-latest run
|
||||
// proves they agree. It is also the file-count-heaviest read loop in the
|
||||
// suite, so it is where a per-file filesystem cost (NTFS + Defender, or
|
||||
// macOS's slower stat path) would show up first. No case is skipped on any
|
||||
// platform: a guard that only holds on Ubuntu is not a guard on the file
|
||||
// whose NUL it exists to catch. Budget: the heaviest single case is one
|
||||
// 4893-file pass — 2.3 s on a slow virtualised filesystem, 0.34 s on a local
|
||||
// disk — against a 30 s testTimeout.
|
||||
'test/unit/source-control-bytes.test.ts',
|
||||
];
|
||||
|
||||
const ALL_CROSS_PLATFORM = [
|
||||
|
|
|
|||
|
|
@ -1,6 +1,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(() => {});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': '要分析的符号或文件名',
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
|
|
|||
169
gitnexus/src/core/group/completeness.ts
Normal file
169
gitnexus/src/core/group/completeness.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
201
gitnexus/src/core/group/group-lock.ts
Normal file
201
gitnexus/src/core/group/group-lock.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
/**
|
||||
* Cross-process single-writer lock for one group's persisted state (R9).
|
||||
*
|
||||
* A group sync ends by REPLACING `contracts.json` and rebuilding `bridge.lbug`
|
||||
* from a snapshot it computed minutes earlier. Two syncs of the same group that
|
||||
* overlap therefore do not merge — the second one's write simply overwrites the
|
||||
* first one's, and whichever finishes last wins with a registry assembled from
|
||||
* repo state the other run never saw. Nothing detects it afterwards: both runs
|
||||
* report success, and the group's contracts silently describe a mixture that was
|
||||
* never true at any instant. This module serializes that section so one sync at
|
||||
* a time can be inside it.
|
||||
*
|
||||
* WHERE THE LOCK LIVES. On a dedicated `sync-lock` directory INSIDE the group
|
||||
* directory — mirroring `withRegistryLock`, which locks a `registry-lock`
|
||||
* directory beside the registry rather than the registry's own directory
|
||||
* (repo-manager.ts). {@link acquireIndexLock} is NOT reentrant and its file
|
||||
* backend writes `analyze.lock` into the directory it is handed, so pointing it
|
||||
* at a directory that some other code path might also lock — or that already
|
||||
* holds a per-repo index slot — reintroduces exactly the collision the registry
|
||||
* lock's own comment warns about. `<groupDir>/sync-lock` is a namespace nothing
|
||||
* else claims: group directories live under `~/.gitnexus/groups/<name>` (or
|
||||
* `$GITNEXUS_HOME`), never under a repo's `.gitnexus[/branches/<slug>]`.
|
||||
*
|
||||
* WHY IT FAILS CLOSED, unlike the registry lock. `withRegistryLock` degrades to
|
||||
* running UNLOCKED on timeout, and that is right for it: it guards a sub-second
|
||||
* JSON read/merge/write on a latency-critical path (`augment` runs on every
|
||||
* editor tool call), and running unlocked is merely the pre-lock status quo. A
|
||||
* group sync is the opposite on every axis — it is long, expensive, operator-
|
||||
* initiated, and its lost update destroys contracts rather than a registry field.
|
||||
* A sync that cannot be protected must not run at all, and there are three
|
||||
* distinct ways it can fail to be protected; all three throw
|
||||
* {@link GroupSyncLockError}:
|
||||
*
|
||||
* 1. TIMEOUT — the holder is still alive when the ceiling elapses.
|
||||
* 2. LOCK-FREE DEGRADATION — `acquireIndexLock` answers a read-only or
|
||||
* permission-denied filesystem with a no-op handle that is byte-identical
|
||||
* to a real one at the API boundary. That is a deliberate tolerance for
|
||||
* `analyze` (an unwritable index dir rejects every write anyway, so the
|
||||
* lock is moot), but here it would hand back a handle that protects
|
||||
* nothing while the sync went on to attempt its writes. The handle now
|
||||
* carries {@link IndexLockHandle.lockFree}, so we can see it and refuse.
|
||||
* 3. ANY OTHER ACQUIRE FAILURE — e.g. `sync-lock` cannot be created because a
|
||||
* regular file already occupies the path. Silently proceeding on an error
|
||||
* we did not anticipate is the same unprotected run under another name.
|
||||
*
|
||||
* WHY THE CEILING IS PASSED EXPLICITLY. The magnitude is not the point — 10
|
||||
* minutes deliberately matches `acquireIndexLock`'s own default, because a group
|
||||
* sync is analyze-shaped and a legitimately queued second sync must be able to
|
||||
* wait out a full first one (the registry lock's 5s is sized for a sub-second
|
||||
* merge and is the wrong model here). The reason to pass it is
|
||||
* `resolveTimeoutMs`: it prefers an explicit argument over
|
||||
* `GITNEXUS_INDEX_LOCK_TIMEOUT_MS`, and that variable's `<= 0` case resolves to
|
||||
* `Number.POSITIVE_INFINITY`. Inheriting it would let an environment turn this
|
||||
* lock's fail-closed timeout into an unbounded hang.
|
||||
*
|
||||
* ACQUIRED EXACTLY ONCE, by `syncGroup`, around its whole persist section.
|
||||
* Nothing it calls beneath that point — `writeContractRegistry`,
|
||||
* `refreshPreservedBridgeMeta`, `writeBridgeUnlocked` — takes this lock; a
|
||||
* second acquisition would deadlock a non-reentrant primitive on the HAPPY
|
||||
* path, not on some edge case. `bridge-db.ts` exports the swap in both forms
|
||||
* for exactly that reason: `writeBridgeUnlocked` for the held-lock caller
|
||||
* (`syncGroup`), and the `writeBridge` wrapper, which acquires here, for direct
|
||||
* callers that are outside the region. The same split `repo-manager.ts` uses
|
||||
* for `registerRepoUnlocked` / `registerRepo`.
|
||||
*
|
||||
* SCOPE CAVEAT (recorded, not solved): the default socket backend uses Linux
|
||||
* abstract sockets, which are network-namespace-scoped. Two containers that
|
||||
* share a bind-mounted group directory but sit in separate netns will NOT
|
||||
* contend, exactly as documented for the index lock itself; forcing
|
||||
* `GITNEXUS_INDEX_LOCK_BACKEND=file` is what covers that deployment.
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import {
|
||||
acquireIndexLock,
|
||||
IndexLockTimeoutError,
|
||||
type IndexLockHandle,
|
||||
} from '../../storage/index-lock.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
/** Lock-directory name inside the group directory. Never the group dir itself. */
|
||||
export const GROUP_SYNC_LOCK_DIRNAME = 'sync-lock';
|
||||
|
||||
/** The dedicated lock namespace for one group: `<groupDir>/sync-lock`. */
|
||||
export const getGroupSyncLockDir = (groupDir: string): string =>
|
||||
path.join(groupDir, GROUP_SYNC_LOCK_DIRNAME);
|
||||
|
||||
/**
|
||||
* Wait ceiling for the group sync lock (10 min). See the module header: the
|
||||
* magnitude matches `acquireIndexLock`'s analyze-sized default on purpose; the
|
||||
* reason it is passed EXPLICITLY is to keep `GITNEXUS_INDEX_LOCK_TIMEOUT_MS`
|
||||
* (whose `<= 0` case means unbounded) from turning fail-closed into a hang.
|
||||
*/
|
||||
export const GROUP_SYNC_LOCK_TIMEOUT_MS = 600_000;
|
||||
|
||||
/** Which of the three fail-closed exits produced a {@link GroupSyncLockError}. */
|
||||
export type GroupSyncLockFailure = 'timeout' | 'lock-free' | 'unavailable';
|
||||
|
||||
/**
|
||||
* A group sync could not be protected, so it did not run. One class for all
|
||||
* three exits so both callers — the CLI command and the MCP service — have a
|
||||
* single thing to catch and report.
|
||||
*/
|
||||
export class GroupSyncLockError extends Error {
|
||||
readonly reason: GroupSyncLockFailure;
|
||||
readonly groupDir: string;
|
||||
constructor(reason: GroupSyncLockFailure, groupDir: string, message: string, cause?: unknown) {
|
||||
super(message, cause === undefined ? undefined : { cause });
|
||||
this.name = 'GroupSyncLockError';
|
||||
this.reason = reason;
|
||||
this.groupDir = groupDir;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `operation` as the only group sync touching `groupDir`, or throw
|
||||
* {@link GroupSyncLockError} without running it at all.
|
||||
*
|
||||
* The lock is released in a `finally`, so it is dropped whether the operation
|
||||
* succeeds or throws.
|
||||
*/
|
||||
export const withGroupSyncLock = async <T>(
|
||||
groupDir: string,
|
||||
operation: () => Promise<T>,
|
||||
): Promise<T> => {
|
||||
let handle: IndexLockHandle;
|
||||
// The wrapper times the acquisition itself. `IndexLockTimeoutError` carries
|
||||
// `holder` and `holderKnown` and nothing else — the elapsed wait exists only
|
||||
// inside its inherited message string, so the figure has to be measured here
|
||||
// to be reported without that message. `Date.now()` matches how the primitive
|
||||
// measures its own wait.
|
||||
const acquireStartedAt = Date.now();
|
||||
try {
|
||||
handle = await acquireIndexLock(getGroupSyncLockDir(groupDir), {
|
||||
timeoutMs: GROUP_SYNC_LOCK_TIMEOUT_MS,
|
||||
// `acquireIndexLock`'s own `log` texts name an "analyze" holder, which
|
||||
// misattributes a group-sync wait — the same reason `withRegistryLock`
|
||||
// supplies its own line instead of passing `log` through.
|
||||
onWaitStart: () =>
|
||||
logger.info(
|
||||
{ groupDir },
|
||||
'Waiting for another GitNexus process to finish syncing this group…',
|
||||
),
|
||||
});
|
||||
} catch (err) {
|
||||
// The inherited message names "another gitnexus analyze" as the holder —
|
||||
// a cause this detection path cannot establish. Nothing but a group sync
|
||||
// ever locks `<groupDir>/sync-lock` (see the module header), and on the
|
||||
// socket backend the holder is not identifiable at all. Re-word it around
|
||||
// what IS known: which group, which operation, and how long we waited.
|
||||
if (err instanceof IndexLockTimeoutError) {
|
||||
throw new GroupSyncLockError(
|
||||
'timeout',
|
||||
groupDir,
|
||||
`Timed out after ${Date.now() - acquireStartedAt}ms waiting for the sync lock on ` +
|
||||
`group "${path.basename(groupDir)}" (${getGroupSyncLockDir(groupDir)}). ` +
|
||||
// `holderKnown` is false on the socket backend and on the file
|
||||
// backend's malformed/vanished-lock timeouts, where `holder` is a
|
||||
// placeholder (`pid -1`). Presenting that as a real owner would be the
|
||||
// same unestablished claim in a new form.
|
||||
(err.holderKnown
|
||||
? `Held by pid ${err.holder.pid} on ${err.holder.hostname} ` +
|
||||
`(invocation ${err.holder.invocationId}). `
|
||||
: `The lock stayed held for the whole wait, but this lock backend ` +
|
||||
`cannot identify the holder. `) +
|
||||
`Nothing was written and this group was not synced. ` +
|
||||
`Re-run once the other sync of this group has finished.`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
throw new GroupSyncLockError(
|
||||
'unavailable',
|
||||
groupDir,
|
||||
`Could not acquire the sync lock for this group (${getGroupSyncLockDir(groupDir)}): ` +
|
||||
`${err instanceof Error ? err.message : String(err)}. Nothing was written.`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
if (handle.lockFree) {
|
||||
// A handle that owns nothing. Release it anyway (it is a no-op, but the
|
||||
// contract is that every handle is released) and refuse to run: this sync
|
||||
// would otherwise write `contracts.json` and `bridge.lbug` with no
|
||||
// protection at all against a concurrent sync doing the same.
|
||||
handle.release();
|
||||
throw new GroupSyncLockError(
|
||||
'lock-free',
|
||||
groupDir,
|
||||
`The sync lock for this group could not be created at ` +
|
||||
`${getGroupSyncLockDir(groupDir)} (read-only or permission-denied filesystem), ` +
|
||||
`so this sync cannot be protected against a concurrent one. Nothing was written. ` +
|
||||
`Make the group directory writable and re-run.`,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
handle.release();
|
||||
}
|
||||
};
|
||||
|
|
@ -6,7 +6,16 @@
|
|||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { checkStaleness } from '../git-staleness.js';
|
||||
import { loadMeta, type RepoMeta } from '../../storage/repo-manager.js';
|
||||
import {
|
||||
canonicalizePath,
|
||||
loadMeta,
|
||||
readRegistryStrict,
|
||||
registryPathEquals,
|
||||
type RegistryEntry,
|
||||
type RepoMeta,
|
||||
} from '../../storage/repo-manager.js';
|
||||
import { crossRepoCompleteness } from './completeness.js';
|
||||
import { 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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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`. ──
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
548
gitnexus/src/core/ingestion/route-extractors/nest.ts
Normal file
548
gitnexus/src/core/ingestion/route-extractors/nest.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} | ||||