mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
Merge origin/main into fix/ignore-next-build-output
Conflict in gitnexus/src/config/ignore-service.ts was purely additive: both sides inserted a new block immediately after DEFAULT_IGNORE_LIST. Kept both. - This branch: DEFAULT_IGNORED_PATH_FRAGMENTS + hasIgnoredPathFragment() - origin/main (#3043): ROOT_ARTIFACT_DIRECTORIES + isRootArtifactDirectory() Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
d867ef031c
127 changed files with 18993 additions and 1124 deletions
12
.gitattributes
vendored
12
.gitattributes
vendored
|
|
@ -15,3 +15,15 @@
|
|||
*.so binary
|
||||
*.dll binary
|
||||
*.dylib binary
|
||||
|
||||
# TypeScript sources are always text for diff purposes. Git's binary
|
||||
# heuristic fires when EITHER blob in a pair carries a NUL, so a source
|
||||
# file that carried one on a base commit still renders as "Binary files
|
||||
# differ" — with no hunks and no inline comments — long after the byte
|
||||
# itself is gone from the working tree. A head-side guard cannot see
|
||||
# that, by construction. This does not mark the files binary or change
|
||||
# how they are stored; it only stops the heuristic from hiding a diff.
|
||||
*.ts diff
|
||||
*.tsx diff
|
||||
*.mts diff
|
||||
*.cts diff
|
||||
|
|
|
|||
|
|
@ -567,7 +567,7 @@ jobs:
|
|||
NODE
|
||||
|
||||
- name: Attest build provenance (SLSA)
|
||||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
||||
with:
|
||||
subject-path: 'gitnexus/vendor/tree-sitter-*/prebuilds/**/*.node'
|
||||
|
||||
|
|
|
|||
6
.github/workflows/docker.yml
vendored
6
.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
|
||||
|
|
@ -256,7 +256,7 @@ jobs:
|
|||
# pulling from either GHCR or Docker Hub see the same provenance.
|
||||
- name: Generate build provenance attestation (GHCR)
|
||||
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
|
||||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
||||
with:
|
||||
subject-name: ghcr.io/${{ github.repository_owner }}/${{ matrix.image.slug }}
|
||||
subject-digest: ${{ steps.build.outputs.digest }}
|
||||
|
|
@ -264,7 +264,7 @@ jobs:
|
|||
|
||||
- name: Generate build provenance attestation (Docker Hub)
|
||||
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
|
||||
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
||||
with:
|
||||
subject-name: docker.io/akonlabs/${{ matrix.image.slug }}
|
||||
subject-digest: ${{ steps.build.outputs.digest }}
|
||||
|
|
|
|||
2
.github/workflows/trivy.yml
vendored
2
.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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
115
gitnexus-web/package-lock.json
generated
115
gitnexus-web/package-lock.json
generated
|
|
@ -8,15 +8,15 @@
|
|||
"name": "gitnexus-web",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@langchain/anthropic": "^1.5.1",
|
||||
"@langchain/core": "^1.2.3",
|
||||
"@langchain/anthropic": "^1.5.8",
|
||||
"@langchain/core": "^1.2.8",
|
||||
"@langchain/google-genai": "^2.2.0",
|
||||
"@langchain/langgraph": "^1.4.9",
|
||||
"@langchain/ollama": "^1.3.0",
|
||||
"@langchain/openai": "^1.5.3",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"axios": "^1.18.1",
|
||||
"axios": "^1.19.0",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.4.13",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
|
|
@ -30,13 +30,13 @@
|
|||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"langchain": "^1.5.4",
|
||||
"lru-cache": "^11.5.2",
|
||||
"lucide-react": "^1.28.0",
|
||||
"lucide-react": "^1.31.0",
|
||||
"mermaid": "^11.16.1",
|
||||
"mnemonist": "^0.40.4",
|
||||
"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",
|
||||
|
|
@ -49,7 +49,7 @@
|
|||
"devDependencies": {
|
||||
"@babel/types": "^8.0.4",
|
||||
"@playwright/test": "^1.62.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/jest-dom": "^7.0.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
|
|
@ -57,7 +57,7 @@
|
|||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vercel/node": "^5.8.23",
|
||||
"@vercel/node": "^5.10.1",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"jsdom": "^29.1.1",
|
||||
|
|
@ -74,7 +74,7 @@
|
|||
"../gitnexus-shared": {
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.3"
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
|
|
@ -98,9 +98,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.103.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.103.0.tgz",
|
||||
"integrity": "sha512-1uG7RNgoHTUxzOXqSCODKt0UTVlxWiHk/2Tt2/uQJiPW7XzBeKVuJyd3Aw6T3LPyvZV/jDTnPLX7SaM70WLLjA==",
|
||||
"version": "0.115.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.115.0.tgz",
|
||||
"integrity": "sha512-BJrFIVyjNuU8lfDyIJTvlRYzgQg+zEl78BxE7fq8esULsGz9IRQvGtW5spq3tydmtjQb/GFdooKGdGsetpx+lQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"json-schema-to-ts": "^3.1.1",
|
||||
|
|
@ -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"
|
||||
|
|
@ -1123,25 +1123,25 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@langchain/anthropic": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.5.1.tgz",
|
||||
"integrity": "sha512-j92zCCd5BFH3rHMRzc2wBmSKDoVpinof1oh8aFiAz9TWbSOc4tGU4n6bqwy/wP0GH1uO96zZHLGCHBMPgrxTNw==",
|
||||
"version": "1.5.8",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.5.8.tgz",
|
||||
"integrity": "sha512-KZWgIf+04M9XZHhgH1rVJkqw/C26DM4a4jKk4Qc4HaSbRawN2Dw5nDffna+IoaU/50ohTdyB3HOz9g8XFQYF2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.103.0",
|
||||
"@anthropic-ai/sdk": "^0.115.0",
|
||||
"zod": "^3.25.76 || ^4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": "^1.2.1"
|
||||
"@langchain/core": "^1.2.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@langchain/core": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.3.tgz",
|
||||
"integrity": "sha512-F+L5SsciykwDl7eDxacnhDTcWe1IF6jetzfkvI5PPfq6ogWHO7xcjU90SGh/3lqbbS0tgun+qF01KIqxawrCsA==",
|
||||
"version": "1.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.9.tgz",
|
||||
"integrity": "sha512-conzSEj9Zu1AyXJLXsSbgrtxtxinmI1yGqQ5CIJZSoV5rvv+yvQE/vgBnoySpBQ/bl3YPgj2FL/gbDjWykLSfg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cfworker/json-schema": "^4.0.2",
|
||||
|
|
@ -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": {
|
||||
|
|
@ -2638,13 +2641,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vercel/build-utils": {
|
||||
"version": "13.32.3",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-13.32.3.tgz",
|
||||
"integrity": "sha512-rYk9EKq8ThkBC1vz38jZ8DmmxtKBjN6EfEOEz1ORL74PLVvET/l++R0tNmPrPg3eP+GI852KdArDmJRNCY6EOw==",
|
||||
"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": {
|
||||
"@vercel/python-analysis": "0.11.1",
|
||||
"@vercel/python-analysis": "0.13.2",
|
||||
"cjs-module-lexer": "1.2.3",
|
||||
"es-module-lexer": "1.5.0"
|
||||
}
|
||||
|
|
@ -2657,9 +2660,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vercel/error-utils": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/error-utils/-/error-utils-2.2.0.tgz",
|
||||
"integrity": "sha512-WFWiRxfPzoYWYifaj4thSKvAaZZwUOqD4k5GINRIgZgCiS2E3iAJbWbIsIZmkQdTecWFHcWGA6q48CjisgpOBA==",
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/error-utils/-/error-utils-2.2.1.tgz",
|
||||
"integrity": "sha512-9DhP8jP7raLML4hGsBemxX5fXuQnu5xxMV+HjGygGbzEmVK/+KyJ3QP2Cw7PdF0uXdb9N0Qa4c3tRGH34ZX6vw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
|
|
@ -2691,9 +2694,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vercel/node": {
|
||||
"version": "5.8.23",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.8.23.tgz",
|
||||
"integrity": "sha512-wigp1yONlJwFtPuyCrp6KI1umG78VhhEspNBXe2i9UOaxjjqLAR3DKiRQ/ivjvnDzV0SN7fuLxwLj+JcG0iwcQ==",
|
||||
"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,10 +2704,10 @@
|
|||
"@edge-runtime/primitives": "4.1.0",
|
||||
"@edge-runtime/vm": "3.2.0",
|
||||
"@types/node": "20.11.0",
|
||||
"@vercel/build-utils": "13.32.3",
|
||||
"@vercel/error-utils": "2.2.0",
|
||||
"@vercel/build-utils": "14.1.1",
|
||||
"@vercel/error-utils": "2.2.1",
|
||||
"@vercel/nft": "1.10.0",
|
||||
"@vercel/static-config": "3.4.0",
|
||||
"@vercel/static-config": "3.4.1",
|
||||
"async-listen": "3.0.0",
|
||||
"cjs-module-lexer": "1.2.3",
|
||||
"edge-runtime": "2.5.9",
|
||||
|
|
@ -2739,9 +2742,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vercel/python-analysis": {
|
||||
"version": "0.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/python-analysis/-/python-analysis-0.11.1.tgz",
|
||||
"integrity": "sha512-EPPLuXJQhIDUx08H9nG76AR2HSgBquwe3OAX5s2w20M923iaWeGGVkhX/4yZ89CJfXEZgE1Aj/mX7lVHOVIcYA==",
|
||||
"version": "0.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/python-analysis/-/python-analysis-0.13.2.tgz",
|
||||
"integrity": "sha512-IEr5K2gvX143NBoQc1W4BWrdDWjZwxnIT6UrL5Y1dnyH7Cqc4AV00FIAddB1YpnIZBJwT4ZhE8QbgqBeO6C9Zw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
|
|
@ -2765,9 +2768,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vercel/static-config": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/static-config/-/static-config-3.4.0.tgz",
|
||||
"integrity": "sha512-wCq90CMUB//ggnFh77NQO1xaLFsS4LigQIqKrH6ohnr9Br/KI1FhlErx62WfCOuueWaW+LVsbLOqNXIUjK8t6A==",
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/static-config/-/static-config-3.4.1.tgz",
|
||||
"integrity": "sha512-kJKTyOg25JDRgDkHEkc+vWlvURxmSQkVKyRPO4EEGD/8HpJT+4u9Z/VGxwnCZ6zZBxYPpma283qBsHwY0gXjfw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
|
|
@ -3131,13 +3134,13 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.18.1",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz",
|
||||
"integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==",
|
||||
"version": "1.19.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz",
|
||||
"integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.5",
|
||||
"form-data": "^4.0.6",
|
||||
"https-proxy-agent": "^5.0.1",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
|
|
@ -5715,9 +5718,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.28.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.28.0.tgz",
|
||||
"integrity": "sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg==",
|
||||
"version": "1.31.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.31.0.tgz",
|
||||
"integrity": "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -18,15 +18,15 @@
|
|||
"test:e2e:report": "playwright show-report"
|
||||
},
|
||||
"dependencies": {
|
||||
"@langchain/anthropic": "^1.5.1",
|
||||
"@langchain/core": "^1.2.3",
|
||||
"@langchain/anthropic": "^1.5.8",
|
||||
"@langchain/core": "^1.2.8",
|
||||
"@langchain/google-genai": "^2.2.0",
|
||||
"@langchain/langgraph": "^1.4.9",
|
||||
"@langchain/ollama": "^1.3.0",
|
||||
"@langchain/openai": "^1.5.3",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"axios": "^1.18.1",
|
||||
"axios": "^1.19.0",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.4.13",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
|
|
@ -40,13 +40,13 @@
|
|||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"langchain": "^1.5.4",
|
||||
"lru-cache": "^11.5.2",
|
||||
"lucide-react": "^1.28.0",
|
||||
"lucide-react": "^1.31.0",
|
||||
"mermaid": "^11.16.1",
|
||||
"mnemonist": "^0.40.4",
|
||||
"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",
|
||||
|
|
@ -59,7 +59,7 @@
|
|||
"devDependencies": {
|
||||
"@babel/types": "^8.0.4",
|
||||
"@playwright/test": "^1.62.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/jest-dom": "^7.0.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
|
|
@ -67,7 +67,7 @@
|
|||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vercel/node": "^5.8.23",
|
||||
"@vercel/node": "^5.10.1",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"jsdom": "^29.1.1",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
{
|
||||
"fingerprint": "4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5",
|
||||
"fingerprint": "c4d799c5336d616955b3530ba051b7dca300d1a0e412a66741cf2f27e04c533e",
|
||||
"scaling_budget": 1.8,
|
||||
"max_ms_large": 1000,
|
||||
"_rebaselined_2856_property_is_detail": "Third and last of the bench guards this branch left red. The Property node table gained an `isDetail` BOOLEAN column (see PROPERTY_SCHEMA in src/core/lbug/schema.ts), so `streamAllCSVsToDisk` writes one more header field and one more cell per Property row — csv-generator.ts `propertyHeader` and the `node.label === 'Property'` tail. Verified to be header-only drift rather than a change in what is emitted: dumping every CSV this bench produces on `origin/main` and on this branch and diffing per-file (filename, byte length, sha256) shows the file SET is identical at 35 CSVs on both sides, 34 of the 35 are byte-identical, and the sole difference is `property.csv` growing 68 -> 77 bytes, `id,name,filePath,startLine,endLine,content,description,declaredType` -> `...,declaredType,isDetail`. The synthetic graph has no Property nodes, so no ROW moved at all. That is the check that matters here: a row routed to the wrong pair file, or a within-file reordering, is what this fingerprint exists to catch, and neither happened. Prior 69e9182ae205183ade24c3d8ad5d7292aea677144b1cbe443dd631bc25b0cafe -> 4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5. Both timing gates passed unchanged while this was red (scaling_ratio 0.783 vs budget 1.8, elapsed_ms_large 229ms vs the 1000ms backstop), so no throughput claim is being rebaselined away.",
|
||||
"_rebaselined_3040_convex_endpoint_factory": "Const and Function gained a trailing convexEndpointFactory column. A deterministic 2,400-entity emit produced the same 35 CSV files and fingerprint c4d799c5336d616955b3530ba051b7dca300d1a0e412a66741cf2f27e04c533e. Removing the new Const and Function header fields plus the new trailing empty Function cell from each of 4,800 Function rows restored the exact prior fingerprint 4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5. No file or row moved or reordered. The measured scaling ratio remained 0.826 against the 1.8 budget and elapsed_ms_large was 307.75ms against the 1000ms backstop.",
|
||||
"_note": "fingerprint = sha256 over per-file digests (filename + sha256(file bytes)), entry list sorted — binds each emitted line to its file so a row routed to the WRONG pair file changes the hash, AND catches within-file row reordering (file bytes hashed as-written). Byte-identity gate for #2203 U2/U3. NOTE: a future change that legitimately reorders emit (without changing the node/edge SET) will trip --check; regenerate then, and record WHY in a `_rebaselined_<reason>` key alongside — bench/scope-capture/baselines.json sets that convention and it is what makes a regenerated hash reviewable. scaling_budget bounds (t_large/t_small)/(LARGE/SMALL): observed ~0.95-1.05 (linear); 1.8 tolerates disk-I/O timing noise on CI while still catching an O(n^2) re-regression (~4x). max_ms_large=1000ms is a coarse absolute backstop (observed ~200ms) that catches a gross uniform slowdown the ratio gate misses; generous so CI host noise won't flake it. Regenerate via `node --import tsx bench/emit-persistence/measure.mjs`."
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -399,12 +399,9 @@
|
|||
* per parsed file, O(files) with no depth term, and the count gate in
|
||||
* import-target-index-reuse.contract.test.ts is what holds it to one build
|
||||
* per pass;
|
||||
* - PHP's leg is measured with NO composer.json — `resolutionConfig` is
|
||||
* undefined here, as it always has been — so `namespaceDirectories` only
|
||||
* ever returns the directory of an already-resolved file and the PSR-4
|
||||
* mapping branch stays unreached, exactly as `csharp` cannot reach the
|
||||
* csproj leg. Closing that is a second PHP arm on the `csharp_csproj`
|
||||
* precedent, not a parameter;
|
||||
* - PHP runs with the Composer PSR-4 configuration every production project
|
||||
* supplies. Configured hits and unmatched dependency misses share one
|
||||
* workload, so the Composer gate cannot become an unmeasured fast path;
|
||||
* - the `const` tail of PHP's leg (`candidateFiles.length === 1`) is a
|
||||
* different ANSWER, not a different cost: `function` runs the identical
|
||||
* candidate gather and `localDefs` filter and diverges only in the last two
|
||||
|
|
@ -550,13 +547,10 @@ const HEAP_LARGE = 32000;
|
|||
const HEAP_PAD = 8;
|
||||
/** The languages whose retained per-pass index carries a BUDGET — a ceiling, a
|
||||
* floor derived from `heap_reading_bytes`, and the linear-growth ratio arm.
|
||||
* All eight are measured the same way as the other nine (`retainedPassBytes`,
|
||||
* one real import through the real resolver); what this list decides is which
|
||||
* GATE a reading gets, not whether it is taken. The first five reach the shared
|
||||
* `WorkspaceFileIndex` and retained NOTHING at BASE; `csharp_csproj` is the
|
||||
* same corpus through the same index under the csproj context, and it is here
|
||||
* rather than excluded as a duplicate because after #2903 its READ PATTERN,
|
||||
* not its corpus, decides the number.
|
||||
* All arms are measured through `retainedPassBytes`, one real import through
|
||||
* the real resolver; this list decides which GATE a reading gets, not whether
|
||||
* it is taken. The configured C# arm stays here because its read pattern
|
||||
* reaches retained structures that the unconfigured arm cannot observe.
|
||||
*
|
||||
* The remaining three are `HEAP_BOUNDED`, DERIVED from this list rather than
|
||||
* written beside it, and they carry an upper bound and NO floor. That asymmetry
|
||||
|
|
@ -565,7 +559,7 @@ const HEAP_PAD = 8;
|
|||
* would gate the noise. rust reads 16 B at both scales; swift's ratio is 0.888
|
||||
* and cobol's 1.082, both outside the linearity every budgeted arm shows, so a
|
||||
* floor and a ratio arm would be measuring the measurement. See the MEMORY
|
||||
* section of the header for what re-measuring all seventeen found. */
|
||||
* section of the header for what re-measuring the full inventory found. */
|
||||
const HEAP_BUDGETED = [
|
||||
'csharp',
|
||||
'csharp_csproj',
|
||||
|
|
@ -601,7 +595,7 @@ const HEAP_BUDGETED = [
|
|||
|
||||
/**
|
||||
* The arms handed the fifth `context` argument — `{ parsedFiles, parsedImport }`
|
||||
* — because their registered hook DECLARES it. Four of seventeen, and the
|
||||
* — because their registered hook DECLARES it. Four of seventeen arms, and the
|
||||
* inventory arm at the foot of this file reconciles that claim against
|
||||
* `SCOPE_RESOLVERS` in both directions rather than trusting this line.
|
||||
*
|
||||
|
|
@ -714,6 +708,15 @@ const joinBase = (baseUrl, rest) => (baseUrl === '' ? rest : `${baseUrl}/${rest}
|
|||
*/
|
||||
const tsBaseUrlFor = (pad) =>
|
||||
pad === 0 ? '' : Array.from({ length: pad }, (_, n) => `d${n}`).join('/');
|
||||
const phpComposerConfigFor = (pad) => ({
|
||||
psr4: new Map([['App', joinBase(tsBaseUrlFor(pad), 'src/App')]]),
|
||||
authoritativePsr4: new Set(['App']),
|
||||
});
|
||||
const renderPhpComposerConfig = (config) =>
|
||||
[...config.psr4]
|
||||
.map(([namespace, directory]) => `${namespace || '<root>'}=${directory || '<root>'}`)
|
||||
.sort()
|
||||
.join(';');
|
||||
/** Keyed by LAYOUT name, so there is no `csharp_csproj` row: `buildFiles`
|
||||
* aliases that arm to `csharp` before this table is read. */
|
||||
const EXTENSION = {
|
||||
|
|
@ -919,7 +922,7 @@ function collideDir(lang, d, i) {
|
|||
`mod${d}/src/main/kotlin/com/example/models/inner/com/example/models`
|
||||
: `mod${d}/src/main/kotlin/com/example/models`;
|
||||
}
|
||||
if (lang === 'php') return `svc${d}/src/Models`;
|
||||
if (lang === 'php') return `src/App/Svc${d}/Models`;
|
||||
if (lang === 'java') {
|
||||
return d % 7 === 0
|
||||
? `svc${d}/src/main/java/com/example/model/inner/model`
|
||||
|
|
@ -1035,6 +1038,12 @@ function buildFiles(lang, fileCount, pad, shape) {
|
|||
: ext;
|
||||
files.push(`${prefix}${dir}/${stem}${suffix}`);
|
||||
}
|
||||
// One real suffix decoy makes the PHP external gate observable: with the
|
||||
// gate, Vendor0 stays unresolved; without it, suffix fallback resolves this
|
||||
// path and the exact fingerprint/external-probe result changes.
|
||||
if (layout === 'php' && files.length > 0) {
|
||||
files[files.length - 1] = `${prefix}legacy/Vendor0/Ghost/Missing.php`;
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
|
|
@ -1145,9 +1154,8 @@ function kotlinBenchmarkPackage(filePath) {
|
|||
* The owner segment is the file's own directory name (`Ns7`, `Models`, `pkg7`),
|
||||
* which is stable across the `small`, `deep` and `collide` arms — so the `deep`
|
||||
* arm differs from `small` in path DEPTH alone, exactly as it does for the path
|
||||
* set. That matters here: `directoryAliases` emits one entry per path segment,
|
||||
* so `filesByDirectory` is O(files × depth) and the depth arm is the only one
|
||||
* that can see it.
|
||||
* set. `filesByDirectory` is exact and linear in the file count; the shared
|
||||
* suffix index remains the path-depth-sensitive structure this arm measures.
|
||||
*/
|
||||
function buildParsedFiles(lang, files) {
|
||||
const parsedFiles = [];
|
||||
|
|
@ -1247,21 +1255,18 @@ function uniqueTarget(lang, { local, r, d, j, dirs }) {
|
|||
: `com.ghost${(r >>> 4) % 97}.deep.Missing`;
|
||||
}
|
||||
if (lang === 'php') {
|
||||
// Backslash-separated, the way a `use` statement is actually written; the
|
||||
// resolver normalizes them. No composer.json is threaded (the adapter's
|
||||
// `resolutionConfig` is left undefined), so every one of these lands on
|
||||
// `suffixResolve` — the leg that ran one `findIndex` over every file per
|
||||
// path part per extension, ~50 of them, and measured 96.40 ms per import at
|
||||
// 20k files before #2901.
|
||||
return local
|
||||
? `App\\Ns${d}\\File${j}`
|
||||
: (r >>> 3) % 2 === 0
|
||||
? [
|
||||
'Psr\\Log\\LoggerInterface',
|
||||
'Symfony\\Component\\Console\\Command',
|
||||
'Doctrine\\ORM\\EntityManager',
|
||||
][(r >>> 4) % 3]
|
||||
: `Vendor${(r >>> 4) % 97}\\Ghost\\Missing`;
|
||||
if (local) {
|
||||
const namespace = d % 7 === 0 ? `Ns${d}\\Sub\\Ns${d}` : `Ns${d}`;
|
||||
const leadingSeparator = (r >>> 3) % 4 === 0 ? '\\' : '';
|
||||
return `${leadingSeparator}App\\${namespace}\\File${j}`;
|
||||
}
|
||||
return (r >>> 3) % 2 === 0
|
||||
? [
|
||||
'Psr\\Log\\LoggerInterface',
|
||||
'Symfony\\Component\\Console\\Command',
|
||||
'Doctrine\\ORM\\EntityManager',
|
||||
][(r >>> 4) % 3]
|
||||
: `Vendor${(r >>> 4) % 97}\\Ghost\\Missing`;
|
||||
}
|
||||
if (lang === 'java') {
|
||||
// Java has NO in-repo-namespace gate (#2910 is filed for it), so a JDK
|
||||
|
|
@ -1451,21 +1456,17 @@ function collideTarget(lang, { local, r, d, j, dirs }) {
|
|||
: `com.ghost${(r >>> 4) % 97}.deep.Missing`;
|
||||
}
|
||||
if (lang === 'php') {
|
||||
// `Models\Mod{n}` is carried by every service, so the segment-suffix key it
|
||||
// resolves through holds one entry no matter how many files exist: PHP
|
||||
// answers from keyed maps and is collision-IMMUNE, which is what this arm
|
||||
// asserts. The local spelling still always resolves, as it does on the
|
||||
// unique layout — PHP's cascade strips leading segments, so even the
|
||||
// nested-same-name slice is reachable by a shorter suffix.
|
||||
return local
|
||||
? `App\\Models\\Mod${Math.floor(j / dirs)}`
|
||||
: (r >>> 3) % 2 === 0
|
||||
? [
|
||||
'Psr\\Log\\LoggerInterface',
|
||||
'Symfony\\Component\\Console\\Command',
|
||||
'Doctrine\\ORM\\EntityManager',
|
||||
][(r >>> 4) % 3]
|
||||
: `Vendor${(r >>> 4) % 97}\\Ghost\\Missing`;
|
||||
if (local) {
|
||||
const leadingSeparator = (r >>> 3) % 4 === 0 ? '\\' : '';
|
||||
return `${leadingSeparator}App\\Svc${j % dirs}\\Models\\Mod${Math.floor(j / dirs)}`;
|
||||
}
|
||||
return (r >>> 3) % 2 === 0
|
||||
? [
|
||||
'Psr\\Log\\LoggerInterface',
|
||||
'Symfony\\Component\\Console\\Command',
|
||||
'Doctrine\\ORM\\EntityManager',
|
||||
][(r >>> 4) % 3]
|
||||
: `Vendor${(r >>> 4) % 97}\\Ghost\\Missing`;
|
||||
}
|
||||
if (lang === 'java') {
|
||||
// Every file declares the same package despite living under different
|
||||
|
|
@ -1607,6 +1608,9 @@ function buildRepo(lang, fileCount, pad = 0, shape = 'unique') {
|
|||
imports.push([from, mintTarget(lang, { local, r, d, j, dirs })]);
|
||||
}
|
||||
}
|
||||
if (lang === 'php' && imports.length > 0) {
|
||||
imports[0] = [files[0], 'Vendor0\\Ghost\\Missing'];
|
||||
}
|
||||
return { files, imports };
|
||||
}
|
||||
|
||||
|
|
@ -1667,7 +1671,7 @@ function newPass(lang, files, pad = 0) {
|
|||
restoreBenchmarkSideChannels(lang, parsedFiles);
|
||||
return {
|
||||
allFilePaths: new Set(parsedFiles.map((f) => f.filePath)),
|
||||
config: undefined,
|
||||
config: lang === 'php' ? phpComposerConfigFor(pad) : undefined,
|
||||
parsedFiles,
|
||||
};
|
||||
}
|
||||
|
|
@ -2039,7 +2043,9 @@ const HEAP_PROBE_TARGET = {
|
|||
// (`getFilesInDir`) before answering null — the three-map read pattern.
|
||||
csharp_csproj: 'App.Missing0',
|
||||
ruby: 'gem0/missing/thing',
|
||||
php: 'Vendor0\\Ghost\\Missing',
|
||||
// A mapped-but-missing class forces the Composer mapping and suffix-index
|
||||
// read paths. The separate external probe below keeps the fast gate visible.
|
||||
php: 'App\\HeapGhost0\\AbsentHeapProbe',
|
||||
java: 'com.google.common.vendor0.Missing',
|
||||
javascript: 'vendor0/lib/missing',
|
||||
python: 'vendor0.deep.missing',
|
||||
|
|
@ -2107,11 +2113,24 @@ function measureHeap(lang) {
|
|||
GC();
|
||||
GC();
|
||||
const probe = HEAP_PROBE_TARGET[lang];
|
||||
const read = (files) => retainedPassBytes(lang, files, probe);
|
||||
const read = (files) => retainedPassBytes(lang, files, probe, lang === 'php' ? HEAP_PAD : 0);
|
||||
const small = flatten(buildFiles(lang, HEAP_SMALL, HEAP_PAD, 'unique'));
|
||||
const bytesSmall = read(small);
|
||||
const large = flatten(buildFiles(lang, HEAP_LARGE, HEAP_PAD, 'unique'));
|
||||
const bytesLarge = read(large);
|
||||
const phpGateShape =
|
||||
lang === 'php'
|
||||
? (() => {
|
||||
const externalProbe = 'Vendor0\\Ghost\\Missing';
|
||||
const config = phpComposerConfigFor(HEAP_PAD);
|
||||
const pass = newPass(lang, large, HEAP_PAD);
|
||||
return {
|
||||
resolution_config: renderPhpComposerConfig(config),
|
||||
external_probe: externalProbe,
|
||||
external_result: renderResolved(resolveOne(lang, large[0], externalProbe, pass)),
|
||||
};
|
||||
})()
|
||||
: {};
|
||||
return {
|
||||
files_small: HEAP_SMALL,
|
||||
files_large: HEAP_LARGE,
|
||||
|
|
@ -2121,6 +2140,7 @@ function measureHeap(lang) {
|
|||
bytes_large: bytesLarge,
|
||||
mib_large: Number((bytesLarge / 1024 / 1024).toFixed(2)),
|
||||
ratio: Number((bytesLarge / bytesSmall / (HEAP_LARGE / HEAP_SMALL)).toFixed(3)),
|
||||
...phpGateShape,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -2213,10 +2233,11 @@ const CONTEXT_PROBE = {
|
|||
function measureContext(lang) {
|
||||
const { from, target, parsedFiles } = CONTEXT_PROBE[lang];
|
||||
const allFilePaths = new Set(parsedFiles.map((f) => f.filePath));
|
||||
const config = lang === 'php' ? phpComposerConfigFor(0) : undefined;
|
||||
const answer = (files) => {
|
||||
restoreBenchmarkSideChannels(lang, files ?? []);
|
||||
return renderResolved(
|
||||
resolveOne(lang, from, target, { allFilePaths, config: undefined, parsedFiles: files }),
|
||||
resolveOne(lang, from, target, { allFilePaths, config, parsedFiles: files }),
|
||||
);
|
||||
};
|
||||
return {
|
||||
|
|
@ -2249,11 +2270,11 @@ if (CHECK && GC === null) {
|
|||
/**
|
||||
* Every arm, and the registered language each one exercises.
|
||||
*
|
||||
* This used to be a hand-written list of seventeen strings under a comment
|
||||
* This used to be a hand-written list of language strings under a comment
|
||||
* claiming it was "every language in `SCOPE_RESOLVERS`" — a claim nothing in
|
||||
* the file could check, because the file never imported the registry. Adding a
|
||||
* resolver to `pipeline/registry.ts` is two lines, neither of which is this
|
||||
* one, so a seventeenth registered language would have shipped ungated and
|
||||
* one, so a newly registered language would have shipped ungated and
|
||||
* printed PASS. That is not a hypothetical failure mode: JavaScript reached
|
||||
* `suffixResolve` with no index at all and measured 25 972 µs per import at
|
||||
* 8000 files (PR #2911) for exactly as long as nothing gated it.
|
||||
|
|
@ -2265,10 +2286,9 @@ if (CHECK && GC === null) {
|
|||
* uses ten files away, and the same "one row per language" table
|
||||
* `bench/cfg/measure.mjs` keeps.
|
||||
*
|
||||
* The mapping is many-to-one on purpose: `csharp` and `csharp_csproj` are two
|
||||
* arms over one registered resolver, differing only in whether `csharpConfigs`
|
||||
* is supplied, because the no-csproj arm returns before it can reach the leg
|
||||
* #2902 indexed.
|
||||
* The mapping is many-to-one only for C#: the configured arm reaches the
|
||||
* csproj branch that the default arm cannot observe. PHP's sole arm carries
|
||||
* its production Composer configuration directly.
|
||||
*/
|
||||
const LANG_REGISTRY = {
|
||||
go: SupportedLanguages.Go,
|
||||
|
|
@ -2457,7 +2477,7 @@ const SCALE_SHAPE = {
|
|||
'one of them alone moves nothing in the others.',
|
||||
};
|
||||
/** The same, for the heap arm — the four inputs that decide what it measures.
|
||||
* Asserted for all seventeen, budgeted tier and bounded tier alike, and it is
|
||||
* Asserted for all seventeen arms, budgeted tier and bounded tier alike, and it is
|
||||
* the bounded tier that needs it most: a bound is a single comparison, so a
|
||||
* probe swapped for one that reaches less is a bound over a smaller workload
|
||||
* and there is no floor beside it to notice.
|
||||
|
|
@ -2474,6 +2494,13 @@ const HEAP_SHAPE = {
|
|||
'ceiling, floor, bound and ratio passing over an arm that changed workload. Deterministic: ' +
|
||||
'a re-run will not change it.',
|
||||
};
|
||||
const PHP_HEAP_SHAPE = {
|
||||
fields: [...HEAP_SHAPE.fields, 'resolution_config', 'external_probe', 'external_result'],
|
||||
why:
|
||||
HEAP_SHAPE.why +
|
||||
' PHP also pins the Composer mapping and a suffix-matchable external decoy so the mapped ' +
|
||||
'index path and the external fast gate remain separate observable arms.',
|
||||
};
|
||||
/** The same, for the `context` arm. All three fields are exact strings, not
|
||||
* bounds: this arm has no measurement noise at all — it resolves one import
|
||||
* two ways over a three-file corpus — so anything less than equality would be
|
||||
|
|
@ -2496,7 +2523,7 @@ const CONTEXT_SHAPE = {
|
|||
* a fifth parameter. */
|
||||
const armShapes = (lang) => [
|
||||
...SCALES.map((scale) => [scale, SCALE_SHAPE]),
|
||||
['heap', HEAP_SHAPE],
|
||||
['heap', lang === 'php' ? PHP_HEAP_SHAPE : HEAP_SHAPE],
|
||||
...(CONTEXT_LANGS.includes(lang) ? [['context', CONTEXT_SHAPE]] : []),
|
||||
];
|
||||
|
||||
|
|
|
|||
480
gitnexus/package-lock.json
generated
480
gitnexus/package-lock.json
generated
|
|
@ -218,18 +218,6 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
|
|
@ -240,17 +228,6 @@
|
|||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
|
|
@ -1466,29 +1443,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
||||
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tybys/wasm-util": "^0.10.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.139.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
|
||||
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
|
||||
"version": "0.144.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz",
|
||||
"integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
|
|
@ -1568,9 +1526,9 @@
|
|||
"optional": true
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1585,9 +1543,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1602,9 +1560,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
|
||||
"integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1619,9 +1577,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
|
||||
"integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1636,9 +1594,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
|
||||
"integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz",
|
||||
"integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -1653,16 +1611,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz",
|
||||
"integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1673,16 +1628,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
|
||||
"integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz",
|
||||
"integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1693,16 +1645,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz",
|
||||
"integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1713,16 +1662,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz",
|
||||
"integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1733,16 +1679,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz",
|
||||
"integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1753,16 +1696,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
|
||||
"integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz",
|
||||
"integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1773,9 +1713,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1789,40 +1729,10 @@
|
|||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
|
||||
"integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "1.11.1",
|
||||
"@emnapi/runtime": "1.11.1",
|
||||
"@napi-rs/wasm-runtime": "^1.1.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
|
||||
"integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz",
|
||||
"integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1837,9 +1747,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
|
||||
"integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz",
|
||||
"integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1883,17 +1793,6 @@
|
|||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
|
|
@ -2077,14 +1976,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz",
|
||||
"integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz",
|
||||
"integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"ast-v8-to-istanbul": "^1.0.0",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
|
|
@ -2098,8 +1997,8 @@
|
|||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "4.1.10",
|
||||
"vitest": "4.1.10"
|
||||
"@vitest/browser": "4.1.11",
|
||||
"vitest": "4.1.11"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
|
|
@ -2108,16 +2007,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
|
||||
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
|
||||
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
|
|
@ -2126,13 +2025,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
|
||||
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
|
||||
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.10",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
|
|
@ -2153,9 +2052,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
|
||||
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
|
||||
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -2166,13 +2065,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
|
||||
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
|
||||
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.10",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
|
|
@ -2180,14 +2079,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
|
||||
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
|
||||
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
|
|
@ -2196,9 +2095,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
|
||||
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
|
||||
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
|
|
@ -2206,13 +2105,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
|
||||
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
|
||||
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
|
|
@ -3678,9 +3577,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.3.tgz",
|
||||
"integrity": "sha512-n+mUVyUX5bVv7G/G2zyIHOhdxfuU1dY2NOFzTQUWiMUbFss8b57NFlgCCaggU78wSw5KVS9cllzeLyzyR+n5nw==",
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.3.0.tgz",
|
||||
"integrity": "sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -3751,9 +3650,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
|
|
@ -3767,23 +3666,23 @@
|
|||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.32.0",
|
||||
"lightningcss-darwin-arm64": "1.32.0",
|
||||
"lightningcss-darwin-x64": "1.32.0",
|
||||
"lightningcss-freebsd-x64": "1.32.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.32.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.32.0",
|
||||
"lightningcss-linux-arm64-musl": "1.32.0",
|
||||
"lightningcss-linux-x64-gnu": "1.32.0",
|
||||
"lightningcss-linux-x64-musl": "1.32.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.32.0",
|
||||
"lightningcss-win32-x64-msvc": "1.32.0"
|
||||
"lightningcss-android-arm64": "1.33.0",
|
||||
"lightningcss-darwin-arm64": "1.33.0",
|
||||
"lightningcss-darwin-x64": "1.33.0",
|
||||
"lightningcss-freebsd-x64": "1.33.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.33.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.33.0",
|
||||
"lightningcss-linux-arm64-musl": "1.33.0",
|
||||
"lightningcss-linux-x64-gnu": "1.33.0",
|
||||
"lightningcss-linux-x64-musl": "1.33.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.33.0",
|
||||
"lightningcss-win32-x64-msvc": "1.33.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
|
||||
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -3802,9 +3701,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
|
||||
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -3823,9 +3722,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
|
||||
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -3844,9 +3743,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
|
||||
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -3865,9 +3764,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
|
||||
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
|
||||
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -3886,16 +3785,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
|
||||
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3910,16 +3806,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
|
||||
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3934,16 +3827,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
|
||||
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3958,16 +3848,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
|
||||
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3982,9 +3869,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
|
||||
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -4003,9 +3890,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
|
||||
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -4265,9 +4152,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -4293,9 +4180,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "8.9.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.1.tgz",
|
||||
"integrity": "sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg==",
|
||||
"version": "8.9.2",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz",
|
||||
"integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
|
|
@ -4611,9 +4498,9 @@
|
|||
"optional": true
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.23",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
||||
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
|
||||
"version": "8.5.26",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -4631,7 +4518,7 @@
|
|||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.16",
|
||||
"nanoid": "^3.3.17",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
|
|
@ -4790,13 +4677,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
|
||||
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz",
|
||||
"integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.139.0",
|
||||
"@oxc-project/types": "=0.144.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
|
|
@ -4806,21 +4693,20 @@
|
|||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.1.5",
|
||||
"@rolldown/binding-darwin-arm64": "1.1.5",
|
||||
"@rolldown/binding-darwin-x64": "1.1.5",
|
||||
"@rolldown/binding-freebsd-x64": "1.1.5",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.1.5",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-x64-musl": "1.1.5",
|
||||
"@rolldown/binding-openharmony-arm64": "1.1.5",
|
||||
"@rolldown/binding-wasm32-wasi": "1.1.5",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.1.5",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.1.5"
|
||||
"@rolldown/binding-android-arm64": "1.2.4",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.4",
|
||||
"@rolldown/binding-darwin-x64": "1.2.4",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.4",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.4",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.4",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.4",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.4",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.4",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.4",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.4",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.4",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.4",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/router": {
|
||||
|
|
@ -5635,9 +5521,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz",
|
||||
"integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==",
|
||||
"version": "14.0.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz",
|
||||
"integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
|
|
@ -5657,16 +5543,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.1.4",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
|
||||
"integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==",
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
|
||||
"integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"lightningcss": "^1.33.0",
|
||||
"picomatch": "^4.0.5",
|
||||
"postcss": "^8.5.16",
|
||||
"rolldown": "~1.1.4",
|
||||
"postcss": "^8.5.25",
|
||||
"rolldown": "~1.2.1",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
|
|
@ -5683,7 +5569,7 @@
|
|||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.3.0",
|
||||
"@vitejs/devtools": "^0.4.0",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
|
|
@ -5735,19 +5621,19 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
|
||||
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
|
||||
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.10",
|
||||
"@vitest/mocker": "4.1.10",
|
||||
"@vitest/pretty-format": "4.1.10",
|
||||
"@vitest/runner": "4.1.10",
|
||||
"@vitest/snapshot": "4.1.10",
|
||||
"@vitest/spy": "4.1.10",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"@vitest/expect": "4.1.11",
|
||||
"@vitest/mocker": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/runner": "4.1.11",
|
||||
"@vitest/snapshot": "4.1.11",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
|
|
@ -5775,12 +5661,12 @@
|
|||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.10",
|
||||
"@vitest/browser-preview": "4.1.10",
|
||||
"@vitest/browser-webdriverio": "4.1.10",
|
||||
"@vitest/coverage-istanbul": "4.1.10",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"@vitest/ui": "4.1.10",
|
||||
"@vitest/browser-playwright": "4.1.11",
|
||||
"@vitest/browser-preview": "4.1.11",
|
||||
"@vitest/browser-webdriverio": "4.1.11",
|
||||
"@vitest/coverage-istanbul": "4.1.11",
|
||||
"@vitest/coverage-v8": "4.1.11",
|
||||
"@vitest/ui": "4.1.11",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.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,7 @@
|
|||
// gitnexus/src/cli/group.ts
|
||||
import { createRequire } from 'node:module';
|
||||
import type { Command } from 'commander';
|
||||
import type { RegistryWriteOutcome } from '../core/group/sync.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
|
|
@ -120,16 +121,42 @@ export function registerGroupCommands(program: Command): void {
|
|||
indexStale: boolean;
|
||||
contractsStale: boolean;
|
||||
missing: boolean;
|
||||
/**
|
||||
* Optional here on purpose: a payload produced before the split
|
||||
* carries no such key, and an absent one must degrade to the
|
||||
* label this command has always printed rather than to the new
|
||||
* one — an unrecorded cause is not evidence of a cause.
|
||||
*/
|
||||
unresolvable?: boolean;
|
||||
unresolvableReason?: string;
|
||||
commitsBehind?: number;
|
||||
}
|
||||
>;
|
||||
missingRepos?: string[];
|
||||
unreadableRepos?: string[];
|
||||
};
|
||||
|
||||
console.log(' Repo index / contracts staleness:');
|
||||
for (const [repoPath, row] of Object.entries(st.repos || {})) {
|
||||
if (row.missing) {
|
||||
console.log(` ${repoPath.padEnd(25)} MISSING (not in registry or unreadable)`);
|
||||
// Two different facts with two different remedies: a repo the
|
||||
// registry never heard of is fixed by indexing it, while an entry
|
||||
// the resolver choked on is fixed by repairing the registry.
|
||||
// Printing "no entry in the registry" for the second one states a
|
||||
// cause that was never measured, and points at the wrong repair.
|
||||
if (row.unresolvable) {
|
||||
// The reason can be multi-line — an ambiguous registry names
|
||||
// every colliding clone. Fold it onto this row's line rather
|
||||
// than truncating it: those paths are what the operator acts on,
|
||||
// and a table row that swallows half its own explanation is the
|
||||
// failure this label exists to stop.
|
||||
const why = (row.unresolvableReason ?? 'the registry entry could not be resolved')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
console.log(` ${repoPath.padEnd(25)} UNRESOLVABLE (${why})`);
|
||||
continue;
|
||||
}
|
||||
console.log(` ${repoPath.padEnd(25)} MISSING (no entry in the registry)`);
|
||||
continue;
|
||||
}
|
||||
const idx = row.indexStale
|
||||
|
|
@ -138,6 +165,26 @@ export function registerGroupCommands(program: Command): void {
|
|||
const ctr = row.contractsStale ? ' CONTRACTS_STALE' : '';
|
||||
console.log(` ${repoPath.padEnd(25)} ${idx}${ctr}`);
|
||||
}
|
||||
// `undefined` and `[]` are different answers here: a registry written
|
||||
// before this was tracked has no opinion, while an empty array is a
|
||||
// measurement. Printing nothing for both would let an unmeasured sync
|
||||
// read as evidence that every index opened cleanly.
|
||||
//
|
||||
// `undefined` covers two ways of not knowing — the field is absent, or
|
||||
// it held something that was not a list of repo paths and `getStatus`
|
||||
// declined to guess. Naming only the first would make a corrupt
|
||||
// registry read as a merely old one, which is the same shape of wrong
|
||||
// answer this command exists to stop giving.
|
||||
const unreadable = st.unreadableRepos;
|
||||
if (unreadable === undefined) {
|
||||
console.log(
|
||||
`\n Last sync unreadable repos: not recorded` +
|
||||
`\n (the registry predates this field, or its value could not be read)` +
|
||||
`\n Re-run \`gitnexus group sync\` to record it.`,
|
||||
);
|
||||
} else if (unreadable.length > 0) {
|
||||
console.log(`\n Last sync unreadable repos: ${unreadable.join(', ')}`);
|
||||
}
|
||||
if ((st.missingRepos || []).length > 0) {
|
||||
console.log(`\n Last sync missing repos: ${st.missingRepos!.join(', ')}`);
|
||||
}
|
||||
|
|
@ -158,30 +205,93 @@ export function registerGroupCommands(program: Command): void {
|
|||
const { getGroupDir, getDefaultGitnexusDir } = await import('../core/group/storage.js');
|
||||
const { loadGroupConfig } = await import('../core/group/config-parser.js');
|
||||
const { syncGroup } = await import('../core/group/sync.js');
|
||||
const { GroupSyncLockError } = await import('../core/group/group-lock.js');
|
||||
|
||||
const groupDir = getGroupDir(getDefaultGitnexusDir(), name);
|
||||
const config = await loadGroupConfig(groupDir);
|
||||
|
||||
console.log(`Syncing group "${name}" (${Object.keys(config.repos).length} repos)...\n`);
|
||||
|
||||
const result = await syncGroup(config, {
|
||||
groupDir,
|
||||
allowStale: Boolean(opts.allowStale),
|
||||
verbose: Boolean(opts.verbose),
|
||||
skipEmbeddings: Boolean(opts.skipEmbeddings),
|
||||
exactOnly: Boolean(opts.exactOnly),
|
||||
});
|
||||
let result: Awaited<ReturnType<typeof syncGroup>>;
|
||||
try {
|
||||
result = await syncGroup(config, {
|
||||
groupDir,
|
||||
allowStale: Boolean(opts.allowStale),
|
||||
verbose: Boolean(opts.verbose),
|
||||
skipEmbeddings: Boolean(opts.skipEmbeddings),
|
||||
exactOnly: Boolean(opts.exactOnly),
|
||||
});
|
||||
} catch (err) {
|
||||
// A sync that could not take the group's lock did NOT run and wrote
|
||||
// nothing (R9 fails closed). That is an operator-actionable outcome, not
|
||||
// a crash, so report it as a failed command rather than letting it
|
||||
// surface as an unhandled rejection with a stack trace — commander's
|
||||
// async actions have no error handler, so an uncaught throw here would
|
||||
// print exactly that.
|
||||
if (!(err instanceof GroupSyncLockError)) throw err;
|
||||
logger.error(`⚠️ Did not sync group "${name}": ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
// Repos we could not read are the most likely explanation for a small
|
||||
// or empty contract count, so they are reported before the counts —
|
||||
// otherwise a run that read nothing looks exactly like a clean run.
|
||||
if (result.unreadableRepos.length > 0) {
|
||||
// No "re-run with GITNEXUS_LOG_LEVEL=warn" hint: the default level is
|
||||
// `info`, and pino emits `warn` (40) at `info` (30), so the reason was
|
||||
// already printed by this same run — raising the level to `warn` would
|
||||
// only suppress the surrounding `info` output.
|
||||
console.log(
|
||||
`\n ⚠️ Could not extract contracts from: ${result.unreadableRepos.join(', ')}` +
|
||||
`\n None of their contracts are included in this sync (the warning above says why),` +
|
||||
`\n or check \`gitnexus doctor\` in the affected repo.`,
|
||||
);
|
||||
}
|
||||
if (result.missingRepos.length > 0) {
|
||||
console.log(
|
||||
`\n ⚠️ Not found in the registry: ${result.missingRepos.join(', ')}` +
|
||||
`\n Index them with \`gitnexus analyze\`, or remove them from group.yaml.`,
|
||||
);
|
||||
}
|
||||
console.log(`\nMatching cascade:`);
|
||||
const exactLinks = result.crossLinks.filter((l) => l.matchType === 'exact');
|
||||
console.log(` exact: ${exactLinks.length} cross-links (confidence 1.0)`);
|
||||
console.log(` unmatched: ${result.unmatched.length} contracts`);
|
||||
console.log(
|
||||
`\nWrote contracts.json (${result.contracts.length} contracts, ${result.crossLinks.length} cross-links)`,
|
||||
);
|
||||
// Driven by what actually happened to the file. This line used to be
|
||||
// unconditional, so a run that deliberately preserved the previous
|
||||
// registry still announced `Wrote contracts.json (0 contracts, 0
|
||||
// cross-links)` — a confident false statement about persisted state, on
|
||||
// the exact path this command exists to make legible.
|
||||
// Exhaustive by construction: a `Record` keyed on the union means a
|
||||
// new outcome fails the build here instead of printing nothing, which
|
||||
// is what previously pushed a distinct state into `preserved` and made
|
||||
// this summary false on one of the two branches it then covered.
|
||||
const OUTCOME_LINE: Record<RegistryWriteOutcome, string | null> = {
|
||||
written:
|
||||
`\nWrote contracts.json (${result.contracts.length} contracts, ` +
|
||||
`${result.crossLinks.length} cross-links)`,
|
||||
preserved:
|
||||
`\nKept the previous contracts.json — no repo in this group could be read.` +
|
||||
`\n Its contracts and cross-links are unchanged; only the unreadable/missing` +
|
||||
`\n repo lists were refreshed to describe THIS run. Fix the repos above and re-run.`,
|
||||
superseded:
|
||||
`\nDid NOT touch contracts.json — no repo in this group could be read, and another` +
|
||||
`\n sync replaced the file while this one waited for the group lock. That sync's` +
|
||||
`\n result stands and this run's repo lists were NOT recorded: they describe a` +
|
||||
`\n group state older than what is on disk. Fix the repos above and re-run.`,
|
||||
'no-prior-registry':
|
||||
`\nDid NOT write contracts.json — no repo in this group could be read,` +
|
||||
`\n and there is no previous contracts.json to fall back on. Fix the repos` +
|
||||
`\n above and re-run.`,
|
||||
// Nothing to say: the caller asked for no write.
|
||||
'not-attempted': null,
|
||||
};
|
||||
const line = OUTCOME_LINE[result.registryOutcome];
|
||||
if (line) console.log(line);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -370,7 +480,7 @@ export function registerGroupCommands(program: Command): void {
|
|||
return;
|
||||
}
|
||||
|
||||
const { contracts, crossLinks } = raw as {
|
||||
const { contracts, crossLinks, truncated, unreadableRepos, missingRepos } = raw as {
|
||||
contracts: Array<{
|
||||
role: string;
|
||||
contractId: string;
|
||||
|
|
@ -384,10 +494,19 @@ export function registerGroupCommands(program: Command): void {
|
|||
confidence: number;
|
||||
contractId: string;
|
||||
}>;
|
||||
truncated?: boolean;
|
||||
unreadableRepos?: string[];
|
||||
missingRepos?: string[];
|
||||
};
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ contracts, crossLinks }, null, 2));
|
||||
// The whole payload, not a re-serialized subset. Destructuring the two
|
||||
// fields this command happens to print and rebuilding an object from
|
||||
// them dropped everything else the service returned — which is how the
|
||||
// completeness fields were invisible here while the MCP tool carried
|
||||
// them. Printing `raw` means a field added to the service reaches
|
||||
// `--json` without a matching edit in this file.
|
||||
console.log(JSON.stringify(raw, null, 2));
|
||||
} else {
|
||||
console.log(`Contracts (${contracts.length}):`);
|
||||
for (const c of contracts) {
|
||||
|
|
@ -399,6 +518,19 @@ export function registerGroupCommands(program: Command): void {
|
|||
` ${l.from.repo} -> ${l.to.repo} [${l.matchType}, conf=${l.confidence}] ${l.contractId}`,
|
||||
);
|
||||
}
|
||||
if (truncated) {
|
||||
// Counts above are a floor, not a census. Name the repos when the
|
||||
// registry recorded them, and say so plainly when it did not — a
|
||||
// listing that cannot say what it is missing is still incomplete.
|
||||
const absent = [...(unreadableRepos ?? []), ...(missingRepos ?? [])];
|
||||
console.log(
|
||||
absent.length > 0
|
||||
? `\n⚠️ This listing is incomplete: the last sync could not account for ${absent.join(', ')}.` +
|
||||
`\n Contracts from those repos are absent, so the counts above are a lower bound.`
|
||||
: `\n⚠️ This listing is incomplete: the last sync did not record which repos it could` +
|
||||
`\n read, so the counts above are a lower bound. Re-run group sync.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await backend.dispose().catch(() => {});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
@ -92,8 +96,9 @@ const DEFAULT_IGNORE_LIST = new Set([
|
|||
|
||||
// Generated/Compiled
|
||||
'.generated',
|
||||
'generated',
|
||||
'auto-generated',
|
||||
// Bare `generated/` can contain tracked source-of-truth code. Build output
|
||||
// remains covered by .gitignore/.gitnexusignore and the unambiguous names.
|
||||
'monaco-workers', // Monaco editor web-worker bundles generated for browser runtime
|
||||
'.terraform',
|
||||
'.serverless',
|
||||
|
|
@ -136,6 +141,13 @@ function hasIgnoredPathFragment(normalizedPath: string): boolean {
|
|||
normalizedPath.endsWith(`/${fragment}`),
|
||||
);
|
||||
}
|
||||
// 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
|
||||
|
|
@ -321,6 +333,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
|
||||
|
|
@ -365,10 +381,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;
|
||||
}
|
||||
|
||||
|
|
@ -380,6 +394,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.
|
||||
|
|
@ -532,8 +560,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.
|
||||
|
|
|
|||
107
gitnexus/src/core/group/REVIEW-FINDINGS-MAP.md
Normal file
107
gitnexus/src/core/group/REVIEW-FINDINGS-MAP.md
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
# Review findings → commits (PR #3012)
|
||||
|
||||
Every finding raised in review of this PR, and the commit that closes it. The
|
||||
Definition of Done claims each finding has exactly one commit and that reverting
|
||||
that commit reintroduces that finding and no other; this is what makes the claim
|
||||
checkable without the reviewer's report in hand.
|
||||
|
||||
**Not under `docs/`** — that path is gitignored, so a map written there would
|
||||
never reach the PR and nobody but its author could perform the audit. It lives
|
||||
beside the code it describes, as `PIPELINE.md` does.
|
||||
|
||||
## Revert contract
|
||||
|
||||
Revertability is **dependency-aware**. Where one commit extracts a helper that
|
||||
later commits consume, reverting the helper alone does not build. The contract
|
||||
is: reverting a commit reintroduces its own finding and no other _finding_, with
|
||||
its prerequisite commits retained.
|
||||
|
||||
One coupled set exists:
|
||||
|
||||
| Set | Commits | Why coupled |
|
||||
| -------------------------- | --------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| Shared completeness helper | `4c203ac7b` ← `79f6f5bcb`, `0fe6fc9d4`, `dbc3953b0` | The three consumers call `crossRepoCompleteness`; reverting it alone breaks the build. |
|
||||
|
||||
## Primary findings
|
||||
|
||||
| # | Finding | Commit |
|
||||
| --- | -------------------------------------------------------------------------------- | ----------- |
|
||||
| 1 | Malformed `meta.json` crashes cross-repo impact and leaks the bridge handle | `27b0069f2` |
|
||||
| 2 | Unreadable repos still contribute contracts through deferred manifest resolution | `7037e8441` |
|
||||
| 3 | Strict read accepts a registry row that cannot identify a repo | `5245b22d7` |
|
||||
| 4 | Unstamped bridge metadata is trusted without any check | `94f2a8757` |
|
||||
| 5 | A subgroup-scoped query is marked incomplete by repos it excluded | `79f6f5bcb` |
|
||||
| 6 | The preserved registry and the bridge disagree about the same sync | `4676abf03` |
|
||||
| 7 | Three surfaces compute completeness three different ways | `4c203ac7b` |
|
||||
| 8 | `group_contracts` has no channel for its own completeness | `0fe6fc9d4` |
|
||||
| 9 | `group status` cannot tell a missing entry from an unreadable registry | `a12b846c9` |
|
||||
| 10 | The sync summary describes a write that did not happen that way | `5a668455c` |
|
||||
| 11 | The total-failure log promises preservation where there is nothing to preserve | `c4b356b29` |
|
||||
| 12 | The bridge-failure warning promises a truncation the code never reports | `1df79bb9a` |
|
||||
| 13 | Two concurrent syncs of one group lose each other's writes | `4f07359bf` |
|
||||
| 14 | The bridge swap needs the lock its caller already holds | `3b6215862` |
|
||||
| 15 | The byte guard misses most tracked text files, and all extensionless ones | `07bf8be75` |
|
||||
| 16 | The byte guard reads the vendored grammar tree it does not need to judge | `3ef831a0a` |
|
||||
| 17 | The strict-read test cannot see which registry read ran | `eccc3c682` |
|
||||
| 18 | The CLI branches this PR introduced have no assertions | `535d2ad29` |
|
||||
| 19 | The MCP payloads have no assertions | `2c253b4a8` |
|
||||
| 20 | Corrupt-registry errors quote the file's bytes, credentials included | `24ba2a537` |
|
||||
| 21 | The mtime pairing's limits are recorded nowhere a reader will look | `ca0aca106` |
|
||||
| 22 | The bridge-input docstring narrows what `unreadableRepos` means | `8c930f470` |
|
||||
| 23 | The strict-read docstring's call-site count is wrong | `a95838954` |
|
||||
| 24 | Contract staging crashes on the engine's argument limit | `57eac7558` |
|
||||
| 25 | The sync tool's description names two of three reachable outcomes | `8bfd1a6ab` |
|
||||
| 26 | The impact tool and status resource do not explain incompleteness | `dbc3953b0` |
|
||||
| 27 | A lock timeout blames an `analyze` it cannot establish | `2d2a0119e` |
|
||||
| 28 | A losing sync downgrades the one that beat it to the lock | `e407f05cf` |
|
||||
|
||||
## Findings raised in review and deliberately not implemented as suggested
|
||||
|
||||
| Finding | Suggested fix | What shipped, and why |
|
||||
| ---------------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Unstamped metadata is trusted | Treat every absent stamp as incomplete | Rejected. It would mark every pre-existing bridge a lower bound until re-synced — a repo-wide regression traded for a narrow window. The write-order pairing in `94f2a8757` is the narrower fix. |
|
||||
| Stale bridge signal after a failed write | Re-stamp the metadata so the warning's promise becomes true | Rejected. Re-stamping recreates the metadata/database mis-pairing that stamping exists to prevent. `1df79bb9a` corrects the warning instead. |
|
||||
| Strict row gate | Require all three fields non-blank | Narrowed to `name` and `storagePath`. This gate rejects the whole registry, which is machine-wide, so a field tightened past what identification needs lets one blank value break every group sync on the machine. |
|
||||
|
||||
## Found during execution, not in the review
|
||||
|
||||
| What | Commit |
|
||||
| --------------------------------------------------------------------------------------------- | ----------- |
|
||||
| A half-written bridge stamp read as a verified match (found by the repo's own contract check) | `066f2d802` |
|
||||
| `readBridgeMeta`'s widened return type blocked the merge on contract drift | `a9d281dd4` |
|
||||
| `group contracts --json` discarded every field it did not re-serialize | `b7753575d` |
|
||||
| `sync.ts` renders as a binary diff because the base blob carries a NUL | `1667c24b4` |
|
||||
|
||||
## Corrections to the plan, found while executing it
|
||||
|
||||
Recorded because each was a claim in the plan that the code contradicted.
|
||||
|
||||
| Claim | Reality |
|
||||
| ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| The strict gate should require the fields "the resolution path consumes" | `defaultResolveHandle` **does** consume `path`. The distinction is what _identifies_ the repo. |
|
||||
| Pass the trace's two endpoint repos as the scope predicate | A destination trace declares no `to`. Narrowing to `from` would report an unreadable provider as "no outgoing link". |
|
||||
| Filter the incomplete set by the subgroup prefix | The query's own repo must stay in scope, or an unreadable origin becomes a confident "nothing depends on this". |
|
||||
| `group status`'s third failure mode is a row that resolves but cannot be opened | Unreachable — `loadMeta` returns `null` on every error and `checkStaleness` catches everything. The reachable case is `resolveRepo` throwing. |
|
||||
| The mtime rule can only demote pairs already broken | False. `cp -r` and `rsync` without `-t` demote an intact pair. Recorded at the code in `ca0aca106`. |
|
||||
| `.scm` files are "edited constantly" here | Every tracked `.scm` is vendored. This repo writes tree-sitter queries inline in TypeScript. |
|
||||
|
||||
## Residual risks, recorded rather than closed
|
||||
|
||||
- **Credentials in the registry.** HTTPS remote URLs are persisted with their
|
||||
userinfo intact. `24ba2a537` stops one channel echoing them; it does not stop
|
||||
them being written. Pre-existing, tracked separately.
|
||||
- **`readRegistryFile`'s read error.** The ENOENT-guarded outer catch still
|
||||
rethrows the raw `fs.readFile` error into `unresolvableReason`. Node embeds
|
||||
the path, not file contents, so no registry bytes leak — but it is the one
|
||||
remaining foreign error object on that path.
|
||||
- **Abstract-socket lock scope.** Linux abstract sockets are
|
||||
network-namespace-scoped, so two containers sharing a bind-mounted group
|
||||
directory do not contend unless the file backend is forced. Recorded at
|
||||
`group-lock.ts`.
|
||||
- **Scope filter at depth > 1.** The declared-scope intersection is sound only
|
||||
while `MAX_SUPPORTED_CROSS_DEPTH` is 1. At depth 2 an out-of-scope repo can
|
||||
sit between two in-scope ones. Recorded at the intersection site.
|
||||
- **R14 is unmet on this PR.** `.gitattributes` makes TypeScript diffs render as
|
||||
text, and it works locally — but GitHub resolves the attribute from the base
|
||||
side, which does not carry it. `sync.ts` renders as binary in this PR's web
|
||||
view and will render as text for every PR after this one merges.
|
||||
|
|
@ -5,12 +5,14 @@ import lbug from '@ladybugdb/core';
|
|||
import type { LbugValue } from '@ladybugdb/core';
|
||||
import type { BridgeHandle, BridgeMeta, StoredContract, CrossLink, RepoSnapshot } from './types.js';
|
||||
import { BRIDGE_SCHEMA_QUERIES, BRIDGE_SCHEMA_VERSION } from './bridge-schema.js';
|
||||
import { recordedRepoList } from './completeness.js';
|
||||
import {
|
||||
closeLbugConnection,
|
||||
openLbugConnection,
|
||||
type LbugConnectionHandle,
|
||||
} from '../lbug/lbug-config.js';
|
||||
import { dedupeContracts, dedupeCrossLinks } from './normalization.js';
|
||||
import { withGroupSyncLock } from './group-lock.js';
|
||||
import { createLogger } from '../logger.js';
|
||||
import { retryRename, writeFileAtomic } from '../../storage/fs-atomic.js';
|
||||
|
||||
|
|
@ -650,13 +652,296 @@ export async function writeBridgeMeta(groupDir: string, meta: BridgeMeta): Promi
|
|||
await writeFileAtomic(path.join(groupDir, 'meta.json'), JSON.stringify(meta, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Does `meta` still describe the `bridge.lbug` sitting next to it?
|
||||
*
|
||||
* `writeBridge` stamps the database's size and mtime into the metadata it
|
||||
* writes, so a metadata file left over from an earlier sync cannot match a
|
||||
* database that was replaced after it. Callers whose answer depends on the
|
||||
* metadata being true of THIS database (cross-repo impact reads completeness
|
||||
* from it) must not treat a mismatch as fact.
|
||||
*
|
||||
* When BOTH halves of the stamp are absent the metadata predates stamping, and
|
||||
* it is judged on the write order of the two files instead — see
|
||||
* {@link unstampedMetaPairsByWriteOrder}. Failing every unstamped metadata
|
||||
* closed would mark all pre-existing bridges as incomplete until re-synced,
|
||||
* trading a narrow window for a repo-wide regression; accepting them all hands
|
||||
* back "verified" for the very window this pairing exists to catch.
|
||||
*
|
||||
* A stamp is a PAIR, so exactly one half present is rejected rather than waved
|
||||
* through. That is not the legacy shape: something wrote a stamp and did not
|
||||
* finish, which is the very condition stamping was added to detect. Joining the
|
||||
* two `undefined` checks with `||` returned "verified" for precisely the shape
|
||||
* that most deserves suspicion.
|
||||
*
|
||||
* Returns `false` when the database itself cannot be stat'd, on either path,
|
||||
* since metadata describing a file that is not there describes nothing.
|
||||
*
|
||||
* The checks are ORDERED by how strong their evidence is, strongest first, and
|
||||
* each later one is reached only because every earlier one had nothing to say.
|
||||
* `provenanceUnknown` therefore comes first: a metadata file whose own writer
|
||||
* says it cannot vouch for the database beside it has settled the question, and
|
||||
* neither the stamp nor the write-order heuristic may overturn that.
|
||||
*
|
||||
* The marker is not decoration. `refreshPreservedBridgeMeta` rewrites this file
|
||||
* atomically without touching the database, which leaves `meta.mtime` newer —
|
||||
* the write order a paired write produces, and the one the unstamped branch
|
||||
* ACCEPTS. Reading the marker after that branch (or not at all) hands back
|
||||
* "verified" for a pair the same code path had just found broken.
|
||||
*/
|
||||
export async function bridgeMetaMatchesFile(groupDir: string, meta: BridgeMeta): Promise<boolean> {
|
||||
if (meta.provenanceUnknown) return false;
|
||||
const stampedSize = meta.bridgeSize !== undefined;
|
||||
const stampedMtime = meta.bridgeMtimeMs !== undefined;
|
||||
if (!stampedSize && !stampedMtime) return unstampedMetaPairsByWriteOrder(groupDir);
|
||||
if (!stampedSize || !stampedMtime) return false;
|
||||
try {
|
||||
const stat = await fsp.stat(path.join(groupDir, 'bridge.lbug'));
|
||||
return stat.size === meta.bridgeSize && stat.mtimeMs === meta.bridgeMtimeMs;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Could the unstamped `meta.json` plausibly have been written by the sync that
|
||||
* put this `bridge.lbug` beside it?
|
||||
*
|
||||
* `writeBridge` renames the database into place and writes the metadata AFTER,
|
||||
* so `meta.mtime >= db.mtime` holds for any pair written together — including
|
||||
* pairs written by builds from before the stamp existed, which is what makes
|
||||
* this usable as back-compat rather than a repo-wide "re-sync everything".
|
||||
* The only way to reach a database strictly NEWER than the metadata beside it
|
||||
* is a swap whose metadata write did not land: the stale-meta-beside-a-new-
|
||||
* database window, whose completeness `runGroupImpact` would otherwise spend as
|
||||
* fact.
|
||||
*
|
||||
* This is a HEURISTIC ON WRITE ORDER, not proof of provenance. It answers "were
|
||||
* these two written in the order a successful sync writes them?", and treats
|
||||
* that as a proxy for "do these two belong together". It is wrong in two
|
||||
* directions, and neither is theoretical:
|
||||
* - FALSE ACCEPT, from a non-monotonic wall clock. `mtimeMs` is realtime, not
|
||||
* monotonic, so an NTP step backwards, a VM snapshot restore or container
|
||||
* clock skew between the database write and the metadata write can leave a
|
||||
* genuinely mis-paired set reading as ordered. Anything that touches the
|
||||
* stale metadata after a swap does the same — a restore from backup, an
|
||||
* editor save, a copy that preserves only the database's times. The STAMP
|
||||
* is what actually closes this; a pair that has one never reaches here.
|
||||
*
|
||||
* Coarse filesystem mtime granularity is NOT this hazard, despite looking
|
||||
* like it: it collapses a pair written together to equal times, and equal
|
||||
* is accepted, which is the correct verdict for that pair.
|
||||
*
|
||||
* - FALSE REJECT, from anything that rewrites the database's mtime after the
|
||||
* metadata's — `cp -r`, `rsync` without `-t`, a machine move, a restore
|
||||
* that replays files in directory order. An intact legacy pair is then
|
||||
* demoted to a lower bound and stays there until the next successful sync
|
||||
* re-stamps it; there is no other recovery, because nothing on the read
|
||||
* path can distinguish it from the swap window it is imitating.
|
||||
*
|
||||
* This direction is the safe one — it degrades an answer to a floor rather
|
||||
* than vouching for one — but it is a real, reachable cost, not a
|
||||
* theoretical one, and it is NOT true that the rule can only ever demote
|
||||
* pairs that were already broken.
|
||||
*
|
||||
* Equality counts as paired. On a filesystem with coarse mtime granularity both
|
||||
* writes land in the same tick, and demanding a strictly newer metadata file
|
||||
* would reject every legacy bridge there for a reason that is about the
|
||||
* filesystem rather than about the bridge.
|
||||
*
|
||||
* A timestamp that cannot be measured is no match, the same convention the
|
||||
* read-only handle cache applies to a bridge it could not stat: a comparison
|
||||
* that could not be made is not a comparison that succeeded.
|
||||
*/
|
||||
async function unstampedMetaPairsByWriteOrder(groupDir: string): Promise<boolean> {
|
||||
try {
|
||||
const [dbStat, metaStat] = await Promise.all([
|
||||
fsp.stat(path.join(groupDir, 'bridge.lbug')),
|
||||
fsp.stat(path.join(groupDir, 'meta.json')),
|
||||
]);
|
||||
return metaStat.mtimeMs >= dbStat.mtimeMs;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read `meta.json`, validating the SHAPE of what it holds.
|
||||
*
|
||||
* The read and the parse have always been guarded — an absent or unparseable
|
||||
* file answers `version: 0`, which every caller already treats as "no
|
||||
* provenance". What was not guarded is a file that parses into something that
|
||||
* is not this shape: `runGroupImpact` spread both repo lists directly into a
|
||||
* `Set`, so a non-iterable there threw a TypeError out of the whole cross-repo
|
||||
* query, from a point where the bridge lease had been taken and not yet
|
||||
* released. A malformed file is a reason to answer "provenance unknown", never
|
||||
* a reason to crash the question.
|
||||
*/
|
||||
export async function readBridgeMeta(groupDir: string): Promise<BridgeMeta> {
|
||||
const unreadable: BridgeMeta = { version: 0, generatedAt: '', missingRepos: [] };
|
||||
let parsed: unknown;
|
||||
try {
|
||||
const content = await fsp.readFile(path.join(groupDir, 'meta.json'), 'utf-8');
|
||||
return JSON.parse(content) as BridgeMeta;
|
||||
parsed = JSON.parse(content);
|
||||
} catch {
|
||||
return { version: 0, generatedAt: '', missingRepos: [] };
|
||||
return unreadable;
|
||||
}
|
||||
// `JSON.parse` succeeds on `null`, `7` and `[]` too, and none of them are
|
||||
// metadata. Reading `.version` off the first of those is a thrown TypeError;
|
||||
// reading it off the others silently yields `undefined`, which passes the
|
||||
// version gate as if the bridge had been vouched for.
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return unreadable;
|
||||
|
||||
const raw = parsed as Partial<BridgeMeta>;
|
||||
const missingRepos = recordedRepoList(raw.missingRepos);
|
||||
const unreadableRepos = recordedRepoList(raw.unreadableRepos);
|
||||
// Each list is judged on its own: a file whose `unreadableRepos` is garbage
|
||||
// can still carry a `missingRepos` that was genuinely measured, and throwing
|
||||
// that away would turn one unknown into two.
|
||||
const repoListsUnreadable =
|
||||
(raw.missingRepos !== undefined && missingRepos === undefined) ||
|
||||
(raw.unreadableRepos !== undefined && unreadableRepos === undefined);
|
||||
|
||||
const meta: BridgeMeta = {
|
||||
...raw,
|
||||
// A version that is not a number cannot be compared against
|
||||
// BRIDGE_SCHEMA_VERSION; `0` is this file's existing word for "provenance
|
||||
// unknown", which is exactly what such a file gives us.
|
||||
// `0` is this file's word for "no provenance". A version that is not a
|
||||
// positive integer is not a schema version, and letting one through splits
|
||||
// the four gates that read this field: `ensureBridgeReady` and
|
||||
// `openBridgeDbReadOnly` both compare `> 0 && !== CURRENT` and would open
|
||||
// the bridge, `bridgeExists` compares `=== 0 || === CURRENT` and would say
|
||||
// it is not there, and `bridgeProvenanceUnknown` compares `=== 0` and would
|
||||
// call the answer complete. Normalizing here keeps all four agreeing
|
||||
// instead of teaching each one the same new case.
|
||||
version:
|
||||
Number.isInteger(raw.version) && (raw.version as number) > 0 ? (raw.version as number) : 0,
|
||||
generatedAt: typeof raw.generatedAt === 'string' ? raw.generatedAt : '',
|
||||
missingRepos: missingRepos ?? [],
|
||||
};
|
||||
// Absent, not empty. `unreadableRepos` is optional and "not recorded" is a
|
||||
// distinct state from "measured none", so an unusable value is dropped rather
|
||||
// than carried through — `repoListsUnreadable` is what records that something
|
||||
// was there and could not be read.
|
||||
if (unreadableRepos) meta.unreadableRepos = unreadableRepos;
|
||||
else delete meta.unreadableRepos;
|
||||
if (repoListsUnreadable) meta.repoListsUnreadable = true;
|
||||
return meta;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* refreshPreservedBridgeMeta */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* What a refresh did to `meta.json`.
|
||||
*
|
||||
* - `restamped` — the pair still matched, so the lists were refreshed
|
||||
* and the stamp re-taken from the database on disk.
|
||||
* - `provenance-unknown` — the pair did NOT match (or there is no database to
|
||||
* match), so the lists were refreshed and the metadata
|
||||
* marked as unable to vouch for the file beside it.
|
||||
* - `no-bridge` — neither `meta.json` nor `bridge.lbug` exists, so
|
||||
* there is no pair to keep honest and nothing written.
|
||||
*/
|
||||
export type PreservedBridgeMetaOutcome = 'restamped' | 'provenance-unknown' | 'no-bridge';
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fsp.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring `meta.json`'s diagnostic lists up to date with a sync that PRESERVED
|
||||
* the bridge instead of rebuilding it, without ever making the metadata claim
|
||||
* more about the database than it did before.
|
||||
*
|
||||
* `syncGroup`'s total-failure path keeps the previous run's contracts and
|
||||
* deliberately leaves `bridge.lbug` alone — the contracts that bridge holds are
|
||||
* the ones being preserved. But `runGroupImpact` reads completeness from
|
||||
* `meta.json`, not from `contracts.json`, so leaving the metadata alone too left
|
||||
* the two files telling different stories: the registry said "this sync could
|
||||
* not read svc/users" while a cross-repo query answered "complete, nothing
|
||||
* depends on this" (R4/R6).
|
||||
*
|
||||
* The refresh is the whole difficulty. It rewrites `meta.json` atomically, so
|
||||
* the file's mtime becomes now while the database's stays old — which is the
|
||||
* write order a paired write produces, and precisely what
|
||||
* `unstampedMetaPairsByWriteOrder` accepts. Three rules follow, and each of them
|
||||
* is load-bearing:
|
||||
*
|
||||
* 1. Ask `bridgeMetaMatchesFile` FIRST, on the file as it stands. After the
|
||||
* write the question is unanswerable, because the write is what destroys
|
||||
* the evidence.
|
||||
* 2. Re-stamp only when that answer was yes. Re-stamping a pair that already
|
||||
* failed would MANUFACTURE the provenance the failure just denied — the
|
||||
* same metadata/database mis-pairing stamping exists to prevent (KTD6).
|
||||
* 3. When it was no, record `provenanceUnknown` explicitly and carry the
|
||||
* existing stamp fields through verbatim. Writing "no stamp" instead is
|
||||
* worse, not better: an unstamped file is judged on the two file times,
|
||||
* and this write has just put them in the accepting order.
|
||||
*
|
||||
* Nothing here opens, reads, or writes the database. The only `stat` of it
|
||||
* happens on the branch where the pair was just verified.
|
||||
*
|
||||
* NOT SPLIT into locked/unlocked halves the way {@link writeBridge} is, and
|
||||
* deliberately. Its one caller is `syncGroup`'s preserve branch, which is
|
||||
* already inside `withGroupSyncLock` — so this write is ALREADY serialized
|
||||
* against every other sync of the group, and taking the lock here would be the
|
||||
* second acquisition of a non-reentrant primitive that the split exists to
|
||||
* avoid. An acquiring wrapper would therefore have zero production callers,
|
||||
* and no test calls this function at all: it would be dead code standing in for
|
||||
* a guarantee the caller already provides. If a caller outside the critical
|
||||
* section ever appears, it needs the same treatment `writeBridge` got — a
|
||||
* wrapper, not a lock moved down here.
|
||||
*/
|
||||
export async function refreshPreservedBridgeMeta(
|
||||
groupDir: string,
|
||||
diagnostics: { missingRepos: string[]; unreadableRepos: string[] },
|
||||
): Promise<PreservedBridgeMetaOutcome> {
|
||||
const dbPath = path.join(groupDir, 'bridge.lbug');
|
||||
const [metaOnDisk, dbOnDisk] = await Promise.all([
|
||||
fileExists(path.join(groupDir, 'meta.json')),
|
||||
fileExists(dbPath),
|
||||
]);
|
||||
// Nothing on either side of the pair. `readBridgeMeta` already answers
|
||||
// `version: 0` — provenance unknown — for an absent file, so a file written
|
||||
// here would say what the absence already says while inventing state for a
|
||||
// bridge that has never existed.
|
||||
if (!metaOnDisk && !dbOnDisk) return 'no-bridge';
|
||||
|
||||
const existing = await readBridgeMeta(groupDir);
|
||||
const paired = await bridgeMetaMatchesFile(groupDir, existing);
|
||||
|
||||
const refreshed: BridgeMeta = { ...existing, ...diagnostics };
|
||||
// NEVER PERSISTED (see `BridgeMeta`): both are things a READER computes ABOUT
|
||||
// a file, and this is the first code in the repo that reads metadata and
|
||||
// writes it back. `pairedWithDatabase` is the poisonous one — persisted, it
|
||||
// would tell every future reader that the pair had been verified.
|
||||
delete refreshed.repoListsUnreadable;
|
||||
delete refreshed.pairedWithDatabase;
|
||||
|
||||
if (paired) {
|
||||
const stat = await fsp.stat(dbPath).catch(() => null);
|
||||
if (stat) {
|
||||
refreshed.bridgeSize = stat.size;
|
||||
refreshed.bridgeMtimeMs = stat.mtimeMs;
|
||||
await writeBridgeMeta(groupDir, refreshed);
|
||||
return 'restamped';
|
||||
}
|
||||
// The database disappeared between the pairing check and this stat. There
|
||||
// is nothing left to stamp, so fall through and say so rather than write a
|
||||
// stamp describing a file that is gone.
|
||||
}
|
||||
|
||||
refreshed.provenanceUnknown = true;
|
||||
await writeBridgeMeta(groupDir, refreshed);
|
||||
return 'provenance-unknown';
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
|
@ -668,6 +953,22 @@ export interface WriteBridgeInput {
|
|||
crossLinks: CrossLink[];
|
||||
repoSnapshots: Record<string, RepoSnapshot>;
|
||||
missingRepos: string[];
|
||||
/**
|
||||
* Repos this sync could not extract from — see
|
||||
* `ContractRegistry.unreadableRepos` for the full definition, which this
|
||||
* field carries unchanged.
|
||||
*
|
||||
* Deliberately not restated here. The narrower wording this once had ("whose
|
||||
* index could not be opened") described one of the two causes and silently
|
||||
* excluded the other, an extractor that threw partway through — so the same
|
||||
* field meant one thing on the registry, another on the bridge input, and a
|
||||
* third on the result. One definition, referenced twice, cannot drift.
|
||||
*
|
||||
* Recorded in meta.json so cross-repo impact can tell "nothing depends on
|
||||
* this" from "we could not look": the bridge built here is missing every
|
||||
* contract those repos own.
|
||||
*/
|
||||
unreadableRepos?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -702,7 +1003,33 @@ function errMessage(err: unknown): string {
|
|||
}
|
||||
}
|
||||
|
||||
export async function writeBridge(
|
||||
/**
|
||||
* Rebuild `bridge.lbug` and its `meta.json`, ASSUMING THE CALLER ALREADY HOLDS
|
||||
* THE GROUP SYNC LOCK for `groupDir` (R9).
|
||||
*
|
||||
* PRECONDITION — the group lock is held. There is exactly one production call
|
||||
* site, `syncGroup` in sync.ts, and it is already inside
|
||||
* `withGroupSyncLock(groupDir, …)` when it gets here. Enforced by this comment
|
||||
* rather than by a type, matching `registerRepoUnlocked` / `withRegistryLock`
|
||||
* in repo-manager.ts, which splits the same shape for the same reason.
|
||||
*
|
||||
* WHY THE SPLIT EXISTS AT ALL. The swap this function performs — old database
|
||||
* aside, temp database into place, then `meta.json` written as a SECOND
|
||||
* operation — is the write two concurrent syncs can interleave into a pairing
|
||||
* that never existed: one sync's metadata beside the other's database. That
|
||||
* needs mutual exclusion. But taking the lock HERE would be a second
|
||||
* acquisition of a non-reentrant primitive inside a region that already holds
|
||||
* it, and it would hang every single sync on the happy path, not some rare
|
||||
* interleave. So the exclusion is the caller's, and this function only states
|
||||
* the precondition. {@link writeBridge} is the acquiring wrapper for callers
|
||||
* who are not already inside that region.
|
||||
*
|
||||
* SCOPE — writer-writer only. The reader-side promotion of a leftover
|
||||
* `bridge.lbug.bak` runs on ordinary reads, outside anybody's critical section;
|
||||
* `bridgeMetaMatchesFile` remains the reader's defense there and is not
|
||||
* replaced by this lock.
|
||||
*/
|
||||
export async function writeBridgeUnlocked(
|
||||
groupDir: string,
|
||||
input: WriteBridgeInput,
|
||||
): Promise<WriteBridgeReport> {
|
||||
|
|
@ -962,11 +1289,39 @@ export async function writeBridge(
|
|||
}
|
||||
await removeLbugFile(bakPath);
|
||||
|
||||
// 4. Write meta.json
|
||||
// 4. Write the new meta.json, STAMPED WITH THE FILE IT DESCRIBES.
|
||||
//
|
||||
// meta.json carries the bridge's completeness, and since #3011 that is
|
||||
// load-bearing: `runGroupImpact` folds `unreadableRepos ∪ missingRepos`
|
||||
// into its truncation fields. The swap above and this write are two
|
||||
// operations, so a sync that stops between them leaves the previous sync's
|
||||
// meta beside a new database — and reading that as fact is a confidently
|
||||
// wrong answer about the one thing this channel exists to make legible.
|
||||
//
|
||||
// Deleting the old meta before the swap would decide which way that window
|
||||
// fails, but at an unacceptable price: the rename of the old database is
|
||||
// wrapped in a catch that also swallows a FAILED rename (a held read-only
|
||||
// handle does this on Windows), so `writeBridge` can throw with the old,
|
||||
// perfectly good database still in place — and its metadata already gone,
|
||||
// unrecoverably, for as long as the swap keeps failing.
|
||||
//
|
||||
// So destroy nothing and pair the two instead: record the size and mtime of
|
||||
// the database this metadata describes, and let readers check that the pair
|
||||
// still belongs together (`bridgeMetaMatchesFile`). A stale meta cannot match
|
||||
// a freshly renamed database, and a sync that fails before the swap leaves a
|
||||
// matching pair untouched.
|
||||
const finalStat = await fsp.stat(finalPath);
|
||||
await writeBridgeMeta(groupDir, {
|
||||
version: BRIDGE_SCHEMA_VERSION,
|
||||
generatedAt: new Date().toISOString(),
|
||||
bridgeSize: finalStat.size,
|
||||
bridgeMtimeMs: finalStat.mtimeMs,
|
||||
missingRepos: input.missingRepos,
|
||||
// Persisted whenever the caller supplied it, `[]` included: an empty list
|
||||
// is the measurement "this sync accounted for every repo", and it is a
|
||||
// different claim from a bridge that never recorded the field. Omitted
|
||||
// only when the caller passed nothing to record.
|
||||
...(input.unreadableRepos ? { unreadableRepos: input.unreadableRepos } : {}),
|
||||
});
|
||||
|
||||
return report;
|
||||
|
|
@ -982,6 +1337,33 @@ export async function writeBridge(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild `bridge.lbug` and its `meta.json` as the only writer of `groupDir`.
|
||||
*
|
||||
* The acquiring half of the split described on {@link writeBridgeUnlocked}: for
|
||||
* callers that are NOT already inside the group's critical section, this takes
|
||||
* the group sync lock around the whole swap and releases it afterwards. Two
|
||||
* concurrent calls therefore run one after the other, so the `meta.json` left
|
||||
* on disk is stamped for the `bridge.lbug` left on disk instead of for the
|
||||
* loser's, which is the pairing the swap-plus-metadata sequence would otherwise
|
||||
* let them interleave into.
|
||||
*
|
||||
* NOT used by `syncGroup`, and it must not be: that path already holds this
|
||||
* lock, and `acquireIndexLock` is not reentrant, so routing it here would make
|
||||
* every ordinary sync wait out the full `GROUP_SYNC_LOCK_TIMEOUT_MS` ceiling
|
||||
* against itself. It calls {@link writeBridgeUnlocked} directly.
|
||||
*
|
||||
* Fails closed exactly as `withGroupSyncLock` does: if the lock cannot be
|
||||
* acquired, a `GroupSyncLockError` is thrown and NOTHING is written —
|
||||
* `bridge.lbug` and `meta.json` are left as they were.
|
||||
*/
|
||||
export async function writeBridge(
|
||||
groupDir: string,
|
||||
input: WriteBridgeInput,
|
||||
): Promise<WriteBridgeReport> {
|
||||
return withGroupSyncLock(groupDir, () => writeBridgeUnlocked(groupDir, input));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* openBridgeDbReadOnly */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
|
|
|||
137
gitnexus/src/core/group/completeness.ts
Normal file
137
gitnexus/src/core/group/completeness.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
/**
|
||||
* The one computation of "is this cross-repo answer complete?" (KTD10), and the
|
||||
* truncation vocabulary it speaks.
|
||||
*
|
||||
* A LEAF MODULE, deliberately, and that is the whole reason it exists apart from
|
||||
* `cross-impact.ts`. Three surfaces need this fold — impact, trace, and the
|
||||
* contract listing — but `cross-impact.ts` statically imports `bridge-db.ts`,
|
||||
* and through it the native LadybugDB binding. `service.ts` therefore had to
|
||||
* reach the fold through `await import('./cross-impact.js')`, which loaded that
|
||||
* entire module graph on the first `group_contracts` of every process — 44-51ms
|
||||
* and 8.4MB of RSS to run a `Set` union and a ternary, once per CLI invocation.
|
||||
*
|
||||
* Nothing here imports anything but types. Keep it that way: the moment this
|
||||
* file gains a runtime import, every consumer pays for it again.
|
||||
*/
|
||||
import type { GroupImpactTruncationReason } from './types.js';
|
||||
|
||||
/**
|
||||
* A union rather than `Pick<GroupImpactResult, ...>` so the two states are
|
||||
* distinguishable by their `truncated` discriminant: a caller that folds these
|
||||
* fields into its own result (see `crossRepoCompleteness`) can then read
|
||||
* `truncationReason` on the truncated branch without a fallback for a value
|
||||
* that cannot be absent there.
|
||||
*/
|
||||
export type TruncationFields =
|
||||
| { truncated: false }
|
||||
| {
|
||||
truncated: true;
|
||||
truncationReason: GroupImpactTruncationReason;
|
||||
riskEpistemic: 'lower-bound';
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the truncation fields every `runGroupImpact` return path shares.
|
||||
*
|
||||
* `riskEpistemic` must follow `truncated` mechanically: it is the marker that
|
||||
* tells a caller the `risk` value is a floor rather than a verdict, and
|
||||
* `mergeRisk` can only under-report once a crossing is dropped. Attaching it at
|
||||
* each return let two of the four paths set `truncated` without it, so a
|
||||
* truncated result read as complete — deriving it in one place is what keeps
|
||||
* the invariant from drifting again (#2787).
|
||||
*/
|
||||
export function truncationFields(
|
||||
truncated: boolean,
|
||||
// Only read on the truncated branch, so the not-truncated call sites omit it
|
||||
// rather than passing a reason that is thrown away.
|
||||
reasonIfTruncated: GroupImpactTruncationReason = 'partial',
|
||||
): TruncationFields {
|
||||
if (!truncated) return { truncated: false };
|
||||
return { truncated: true, truncationReason: reasonIfTruncated, riskEpistemic: 'lower-bound' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything a caller needs in order to say whether a cross-repo answer is
|
||||
* complete — deliberately WITHOUT naming where any of it came from.
|
||||
*
|
||||
* `BridgeMeta` is not in this signature, and must not be: `groupContracts`
|
||||
* answers the same question from `contracts.json` (via
|
||||
* `loadContractRegistryResilient`) and never opens a bridge at all, so
|
||||
* `version` / `repoListsUnreadable` / `pairedWithDatabase` do not exist on that
|
||||
* path. Each caller computes its own `provenanceUnknown` from whatever
|
||||
* provenance IT has and passes the boolean in.
|
||||
*/
|
||||
export interface CrossRepoCompletenessInput {
|
||||
/**
|
||||
* Repos the sync could not extract from, and repos it found no entry for.
|
||||
* Two independent diagnostics with one consequence — none of those repos'
|
||||
* contracts are in the artifact — so they are folded into one set.
|
||||
*/
|
||||
unreadableRepos?: readonly string[];
|
||||
missingRepos?: readonly string[];
|
||||
/** Computed by the caller; see `bridgeProvenanceUnknown` for the bridge one. */
|
||||
provenanceUnknown: boolean;
|
||||
/**
|
||||
* The query's DECLARED scope, not the set of repos the walk happened to
|
||||
* reach: the subgroup filter for an impact query, the two endpoint repos for
|
||||
* a trace, every member for a query that names none. An incomplete repo the
|
||||
* caller never asked about cannot make the caller's answer a floor, and
|
||||
* marking it anyway is how the marker stops meaning anything. Passing the
|
||||
* predicate in — rather than a repo list, or a subgroup — is what keeps
|
||||
* narrowing a scope a call-site change.
|
||||
*/
|
||||
inScope: (repoPath: string) => boolean;
|
||||
}
|
||||
|
||||
/** The structured triple, plus the in-scope repos that produced it. */
|
||||
export type CrossRepoCompleteness = TruncationFields & {
|
||||
/**
|
||||
* In-scope repos absent from the artifact, deduped, in first-seen order.
|
||||
* Empty on a provenance-unknown answer: nothing was measured there, and
|
||||
* inventing names out of an unreadable value is not a measurement.
|
||||
*/
|
||||
incompleteRepos: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The ONE computation of "is this cross-repo answer complete?" (KTD10).
|
||||
*
|
||||
* Three surfaces can return a partial cross-repo answer — impact, trace, and
|
||||
* the contract listing — and each used to decide for itself, in its own
|
||||
* vocabulary, which is how two of them ended up saying it in prose only. The
|
||||
* answer is the same structured triple `GroupImpactResult` already carries, so
|
||||
* an agent reading any of them learns "complete" vs "floor" the same way.
|
||||
*
|
||||
* `truncationFields` derives `riskEpistemic` from `truncated` mechanically, and
|
||||
* is reused here rather than re-implemented for the same reason it exists: the
|
||||
* marker that says "this is a floor, not a verdict" may never drift away from
|
||||
* the flag that says the answer was cut short (#2787).
|
||||
*/
|
||||
export function crossRepoCompleteness(input: CrossRepoCompletenessInput): CrossRepoCompleteness {
|
||||
const incompleteRepos = [
|
||||
...new Set([...(input.unreadableRepos ?? []), ...(input.missingRepos ?? [])]),
|
||||
].filter((repoPath) => input.inScope(repoPath));
|
||||
return {
|
||||
...truncationFields(input.provenanceUnknown || incompleteRepos.length > 0, 'incomplete-sync'),
|
||||
incompleteRepos,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A recorded repo list is an array of strings. Anything else — a bare string, an
|
||||
* object, an array of objects — is a value we could not read, which is "not
|
||||
* recorded", not "none".
|
||||
*
|
||||
* ONE definition, deliberately. This gate is the predicate the whole
|
||||
* absent-vs-empty-vs-populated distinction rests on, and it applies to the same
|
||||
* two lists on both the registry and the bridge metadata. It lived in two files
|
||||
* verbatim, which meant tightening it — say, to reject blank strings — would
|
||||
* have fixed one surface and silently left the other.
|
||||
*
|
||||
* `Array.isArray` alone is not enough: only an array of strings survives
|
||||
* `cli/group.ts`'s `.join(', ')` as repo paths rather than as `[object Object]`.
|
||||
*/
|
||||
export function recordedRepoList(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
return value.every((entry) => typeof entry === 'string') ? (value as string[]) : undefined;
|
||||
}
|
||||
|
|
@ -7,11 +7,11 @@ import fsp from 'node:fs/promises';
|
|||
import path from 'node:path';
|
||||
import type {
|
||||
BridgeHandle,
|
||||
BridgeMeta,
|
||||
ContractType,
|
||||
CrossRepoImpact,
|
||||
GroupConfig,
|
||||
GroupImpactResult,
|
||||
GroupImpactTruncationReason,
|
||||
MatchType,
|
||||
OutOfScopeLink,
|
||||
} from './types.js';
|
||||
|
|
@ -24,12 +24,23 @@ import {
|
|||
} from './group-path-utils.js';
|
||||
import { getGroupDir } from './storage.js';
|
||||
import {
|
||||
bridgeMetaMatchesFile,
|
||||
closeBridgeDb,
|
||||
getCachedBridgeReadOnly,
|
||||
queryBridge,
|
||||
readBridgeMeta,
|
||||
} from './bridge-db.js';
|
||||
import { BRIDGE_SCHEMA_VERSION } from './bridge-schema.js';
|
||||
// Re-exported so the three surfaces keep one import site for the vocabulary,
|
||||
// while the fold itself stays in a leaf module no native binding reaches.
|
||||
export {
|
||||
truncationFields,
|
||||
crossRepoCompleteness,
|
||||
type TruncationFields,
|
||||
type CrossRepoCompleteness,
|
||||
type CrossRepoCompletenessInput,
|
||||
} from './completeness.js';
|
||||
import { truncationFields, crossRepoCompleteness } from './completeness.js';
|
||||
import { compareCodeUnits } from '../../lib/utils.js';
|
||||
|
||||
// High limit for the local phase of group impact so collectImpactSymbolUids
|
||||
|
|
@ -381,23 +392,30 @@ export function mergeRisk(localRisk: string, cross: CrossRepoImpact[]): string {
|
|||
}
|
||||
|
||||
/**
|
||||
* Build the truncation fields every `runGroupImpact` return path shares.
|
||||
* Is this bridge's metadata unable to say where its contents came from?
|
||||
*
|
||||
* `riskEpistemic` must follow `truncated` mechanically: it is the marker that
|
||||
* tells a caller the `risk` value is a floor rather than a verdict, and
|
||||
* `mergeRisk` can only under-report once a crossing is dropped. Attaching it at
|
||||
* each return let two of the four paths set `truncated` without it, so a
|
||||
* truncated result read as complete — deriving it in one place is what keeps
|
||||
* the invariant from drifting again (#2787).
|
||||
* The three reads are all about a `BridgeMeta` and stay OUT of
|
||||
* `crossRepoCompleteness` on purpose (see its doc): they are how a caller that
|
||||
* opened a bridge computes `provenanceUnknown`, not how every caller does.
|
||||
*
|
||||
* - `version === 0` — no readable meta.json at all (`readBridgeMeta` answers
|
||||
* that for both "absent" and "unparseable");
|
||||
* - `repoListsUnreadable` — a meta.json that parsed but whose repo lists are
|
||||
* not repo lists. A value we could not read is not a measurement of zero,
|
||||
* so it may not be spent as one;
|
||||
* - `pairedWithDatabase === false` — a meta.json that does not describe the
|
||||
* database sitting beside it, which is what a sync interrupted between the
|
||||
* swap and the metadata write leaves behind. Measured by
|
||||
* `ensureBridgeReady` BEFORE the database is opened and carried on the
|
||||
* meta; this only reads the answer (#3012).
|
||||
*
|
||||
* Treating any of them as complete is the fail-open the completeness channel
|
||||
* exists to close.
|
||||
*/
|
||||
function truncationFields(
|
||||
truncated: boolean,
|
||||
// Only read on the truncated branch, so the not-truncated call sites omit it
|
||||
// rather than passing a reason that is thrown away.
|
||||
reasonIfTruncated: GroupImpactTruncationReason = 'partial',
|
||||
): Pick<GroupImpactResult, 'truncated' | 'truncationReason' | 'riskEpistemic'> {
|
||||
if (!truncated) return { truncated: false };
|
||||
return { truncated: true, truncationReason: reasonIfTruncated, riskEpistemic: 'lower-bound' };
|
||||
export function bridgeProvenanceUnknown(meta: BridgeMeta): boolean {
|
||||
return (
|
||||
meta.version === 0 || meta.repoListsUnreadable === true || meta.pairedWithDatabase === false
|
||||
);
|
||||
}
|
||||
|
||||
function addCrossImpact(cross: CrossRepoImpact[], candidate: CrossRepoImpact): void {
|
||||
|
|
@ -418,7 +436,7 @@ function addCrossImpact(cross: CrossRepoImpact[], candidate: CrossRepoImpact): v
|
|||
|
||||
export async function ensureBridgeReady(
|
||||
groupDir: string,
|
||||
): Promise<{ handle: BridgeHandle } | { error: string }> {
|
||||
): Promise<{ handle: BridgeHandle; meta: BridgeMeta } | { error: string }> {
|
||||
const meta = await readBridgeMeta(groupDir);
|
||||
if (meta.version > 0 && meta.version !== BRIDGE_SCHEMA_VERSION) {
|
||||
return {
|
||||
|
|
@ -433,6 +451,13 @@ export async function ensureBridgeReady(
|
|||
error: `No bridge.lbug in this group directory. Run gitnexus group sync (schema ${BRIDGE_SCHEMA_VERSION}).`,
|
||||
};
|
||||
}
|
||||
// Pair the metadata to the database BEFORE opening it, and carry the answer.
|
||||
// An unstamped pair is judged on the two files' write order, so any open that
|
||||
// touched `bridge.lbug`'s mtime would silently convert "legacy but intact"
|
||||
// into "provenance unknown" for every pre-stamp bridge on that platform. This
|
||||
// ordering removes the question rather than betting on the answer.
|
||||
meta.pairedWithDatabase = await bridgeMetaMatchesFile(groupDir, meta);
|
||||
|
||||
// Use the cached read-only handle if available — avoids reopening the same
|
||||
// bridge.lbug in a long-lived MCP server, which fails on Windows because
|
||||
// the OS handle isn't fully released before the next open races in.
|
||||
|
|
@ -442,7 +467,7 @@ export async function ensureBridgeReady(
|
|||
error: `Could not open bridge.lbug read-only (schema ${BRIDGE_SCHEMA_VERSION}). Run gitnexus group sync.`,
|
||||
};
|
||||
}
|
||||
return { handle };
|
||||
return { handle, meta };
|
||||
}
|
||||
|
||||
function rowToNeighbor(r: Record<string, unknown>): BridgeNeighborRow | null {
|
||||
|
|
@ -641,6 +666,25 @@ export async function runGroupImpact(
|
|||
if ('error' in bridgePrep) return { error: bridgePrep.error };
|
||||
|
||||
const handle = bridgePrep.handle;
|
||||
// Repos the sync that built this bridge could not account for. Their
|
||||
// contracts — and every cross-link touching them — are simply absent from
|
||||
// bridge.lbug, and nothing else in this walk can notice that: the only
|
||||
// incompleteness channel on the result is `truncationFields`, driven by
|
||||
// fan-out state. Without folding these in, a query about a symbol whose one
|
||||
// downstream consumer lives in an unreadable repo returns
|
||||
// `{ cross: [], truncated: false }` — "complete: nothing depends on this" —
|
||||
// which is a wrong answer, not an empty one, for a tool an agent uses to
|
||||
// license a delete or a rename.
|
||||
//
|
||||
// The metadata read that answers it (`bridgeProvenanceUnknown`) happens
|
||||
// INSIDE the `try` below, and the flag is initialized fail-closed here only
|
||||
// because it outlives that block. The lease taken by `ensureBridgeReady` is
|
||||
// released by the `finally` and nowhere else, so work done between the lease
|
||||
// and the `try` is work whose every throw leaks a refcount the cached handle
|
||||
// can never get back — which is how a malformed meta.json used to wedge the
|
||||
// handle as well as crash the query. (The repo lists are folded in after the
|
||||
// `finally`, where a throw can no longer strand the lease.)
|
||||
let provenanceUnknown = true;
|
||||
const cross: CrossRepoImpact[] = [];
|
||||
const outOfScope: OutOfScopeLink[] = [];
|
||||
const truncatedRepos: string[] = [];
|
||||
|
|
@ -650,6 +694,8 @@ export async function runGroupImpact(
|
|||
let fanoutTimedOut = false;
|
||||
|
||||
try {
|
||||
provenanceUnknown = bridgeProvenanceUnknown(bridgePrep.meta);
|
||||
|
||||
const neighbors = await resolveBridgeNeighbors(handle, {
|
||||
localRepo: repoPath,
|
||||
uids,
|
||||
|
|
@ -782,7 +828,45 @@ export async function runGroupImpact(
|
|||
const localSum = (local as { summary?: Record<string, number> })?.summary || {};
|
||||
const localRisk = String((local as { risk?: string }).risk ?? 'LOW');
|
||||
const localPartial = Boolean((local as { partial?: boolean }).partial);
|
||||
const truncated = truncatedRepos.length > 0 || localPartial;
|
||||
// The bridge's own incompleteness, in the shared vocabulary, read through
|
||||
// what this query DECLARED. The fan-out above already drops every neighbour
|
||||
// outside `subgroup`, so an incomplete repo the query excluded could not have
|
||||
// contributed a crossing to this answer — marking the answer a floor because
|
||||
// of it makes the marker fire on results it does not describe, which is how a
|
||||
// caller learns to ignore it. An unscoped query passes `subgroup: undefined`,
|
||||
// which `repoInSubgroup` answers true for, so the intersection is the whole
|
||||
// set and that path is byte-for-byte the old behaviour.
|
||||
//
|
||||
// The declared scope is the subgroup PLUS the query's own repo (`exact`
|
||||
// reuses the one membership helper for the equality, rather than growing a
|
||||
// second notion of it): the walk starts from `repoPath`'s contracts in the
|
||||
// bridge, so if THAT is the repo the sync could not read there are no
|
||||
// crossings to find for any scope, and a subgroup excluding it must not turn
|
||||
// that vacuum into a confident "complete".
|
||||
//
|
||||
// Declared scope, not traversed scope: an incomplete repo's contracts are
|
||||
// absent from the bridge by definition, so it is never in the set the walk
|
||||
// reached — filtering on what was traversed would empty the intersection on
|
||||
// every query and silently restore the fail-open.
|
||||
//
|
||||
// Sound only while `MAX_SUPPORTED_CROSS_DEPTH` is 1. At depth 2+ an
|
||||
// out-of-scope repo can sit BETWEEN two in-scope ones, so dropping it would
|
||||
// convert a genuine lower bound into a confident complete answer; widen this
|
||||
// predicate in the same change that raises the depth.
|
||||
const bridge = crossRepoCompleteness({
|
||||
unreadableRepos: bridgePrep.meta.unreadableRepos,
|
||||
missingRepos: bridgePrep.meta.missingRepos,
|
||||
provenanceUnknown,
|
||||
inScope: (candidate) =>
|
||||
repoInSubgroup(candidate, subgroup) || repoInSubgroup(candidate, repoPath, true),
|
||||
});
|
||||
// One predicate, read twice below. Written out at both sites, a third runtime
|
||||
// cause added to the flag and forgotten at the reason would label a
|
||||
// retry-able answer `incomplete-sync` — telling the operator to re-sync for
|
||||
// something a retry fixes. That reason-vs-flag drift is what `truncationFields`
|
||||
// exists to prevent.
|
||||
const runtimeTruncated = truncatedRepos.length > 0 || localPartial;
|
||||
const truncated = runtimeTruncated || bridge.truncated;
|
||||
|
||||
const result: GroupImpactResult = {
|
||||
local,
|
||||
|
|
@ -794,8 +878,17 @@ export async function runGroupImpact(
|
|||
// and under-reporting a blast radius is the unsafe direction (an agent told
|
||||
// LOW proceeds; told CRITICAL it stops). Marking the floor keeps the
|
||||
// warning intact while making the incompleteness legible.
|
||||
...truncationFields(truncated, fanoutTimedOut ? 'timeout' : 'partial'),
|
||||
truncatedRepos: [...new Set(truncatedRepos)],
|
||||
// Runtime limits first — they are what the caller can retry. 'incomplete-sync'
|
||||
// is the remaining cause once nothing was merely cut short, and its remedy is
|
||||
// a different one: re-run `gitnexus group sync`, not the query. Computed
|
||||
// inline because `truncationFields` reads the reason ONLY on the truncated
|
||||
// branch — naming it in a variable invited reading it on the complete path,
|
||||
// where it would say 'incomplete-sync' about a complete result.
|
||||
...truncationFields(
|
||||
truncated,
|
||||
fanoutTimedOut ? 'timeout' : runtimeTruncated ? 'partial' : 'incomplete-sync',
|
||||
),
|
||||
truncatedRepos: [...new Set([...truncatedRepos, ...bridge.incompleteRepos])],
|
||||
summary: {
|
||||
direct: localSum.direct ?? 0,
|
||||
processes_affected: localSum.processes_affected ?? 0,
|
||||
|
|
|
|||
|
|
@ -25,16 +25,29 @@
|
|||
|
||||
import { GroupNotFoundError, loadGroupConfig } from './config-parser.js';
|
||||
import { getGroupDir } from './storage.js';
|
||||
import { ensureBridgeReady, MAX_SUPPORTED_CROSS_DEPTH } from './cross-impact.js';
|
||||
import {
|
||||
bridgeProvenanceUnknown,
|
||||
crossRepoCompleteness,
|
||||
ensureBridgeReady,
|
||||
MAX_SUPPORTED_CROSS_DEPTH,
|
||||
} from './cross-impact.js';
|
||||
import type { CrossRepoCompleteness } from './completeness.js';
|
||||
import { truncationFields } from './completeness.js';
|
||||
import { compareCodeUnits } from '../../lib/utils.js';
|
||||
import { closeBridgeDb, queryBridge } from './bridge-db.js';
|
||||
import { repoInSubgroup } from './group-path-utils.js';
|
||||
import type {
|
||||
GroupPdgFlowHop,
|
||||
GroupRepoHandle,
|
||||
GroupSymbolResolution,
|
||||
GroupToolPort,
|
||||
} from './service.js';
|
||||
import type { BridgeHandle, GroupConfig } from './types.js';
|
||||
import type {
|
||||
BridgeHandle,
|
||||
BridgeMeta,
|
||||
GroupConfig,
|
||||
GroupImpactTruncationReason,
|
||||
} from './types.js';
|
||||
|
||||
// ── Result types (discriminated on `status`) ─────────────────────────────
|
||||
|
||||
|
|
@ -77,7 +90,29 @@ export interface GroupTraceEndpoint {
|
|||
repo: string;
|
||||
}
|
||||
|
||||
export interface GroupTraceOkResult {
|
||||
/**
|
||||
* The incompleteness vocabulary, verbatim from `GroupImpactResult` (KTD10).
|
||||
*
|
||||
* A cross-repo trace and a cross-repo impact can both be cut short by the same
|
||||
* two kinds of cause — a runtime limit inside this walk, or a bridge that never
|
||||
* held part of the group — and an agent must not have to learn a second
|
||||
* vocabulary (or parse a `notes` string) to tell "no path exists" from "we
|
||||
* could not have seen the path". Every field here means exactly what it means
|
||||
* on `GroupImpactResult`; `notes` stays a human-readable ADDITION to them,
|
||||
* never the machine-readable channel.
|
||||
*/
|
||||
export interface GroupTraceCompleteness {
|
||||
/** True when this answer is a floor rather than a verdict. */
|
||||
truncated?: boolean;
|
||||
/** Why, when `truncated` — runtime limit ('partial'/'timeout') before structure. */
|
||||
truncationReason?: GroupImpactTruncationReason;
|
||||
/** Set with `truncated`: the answer under-reports, it never over-reports. */
|
||||
riskEpistemic?: 'lower-bound';
|
||||
/** In-scope repos absent from the bridge; omitted when none were measured. */
|
||||
truncatedRepos?: string[];
|
||||
}
|
||||
|
||||
export interface GroupTraceOkResult extends GroupTraceCompleteness {
|
||||
status: 'ok';
|
||||
group: string;
|
||||
from: GroupTraceEndpoint;
|
||||
|
|
@ -89,7 +124,6 @@ export interface GroupTraceOkResult {
|
|||
edges: TraceEdge[];
|
||||
/** Present only when PDG enrichment ran for at least one segment. */
|
||||
dataFlow?: SegmentDataFlow[];
|
||||
truncated?: boolean;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
|
|
@ -101,23 +135,23 @@ export interface GroupTraceCandidate {
|
|||
startLine: number;
|
||||
}
|
||||
|
||||
export interface GroupTraceNotFoundResult {
|
||||
/**
|
||||
* `truncated: true` here means the answer is NOT authoritative — either the
|
||||
* crossing cap (`MAX_CROSSINGS_TO_TRY`) was hit so a connecting ContractLink
|
||||
* ranked beyond it may have been skipped, or the bridge itself never held part
|
||||
* of the group. Both read as "unknown", not as "no path exists";
|
||||
* `truncationReason` says which.
|
||||
*/
|
||||
export interface GroupTraceNotFoundResult extends GroupTraceCompleteness {
|
||||
status: 'not_found';
|
||||
group: string;
|
||||
role?: 'from' | 'to';
|
||||
query?: string;
|
||||
/**
|
||||
* True when the answer is NOT authoritative: the crossing cap
|
||||
* (`MAX_CROSSINGS_TO_TRY`) was hit, so a connecting ContractLink ranked beyond
|
||||
* the cap may have been skipped. A consumer should treat this as "unknown",
|
||||
* not "no path exists".
|
||||
*/
|
||||
truncated?: boolean;
|
||||
notes: string[];
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
export interface GroupTraceAmbiguousResult {
|
||||
export interface GroupTraceAmbiguousResult extends GroupTraceCompleteness {
|
||||
status: 'ambiguous';
|
||||
group: string;
|
||||
role: 'from' | 'to';
|
||||
|
|
@ -187,6 +221,54 @@ export const TRACE_NOTES = {
|
|||
'The candidates are listed; trace from the exact calling function or pass `to_uid`.',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Fold this bridge's completeness into the runtime-truncation flag a trace call
|
||||
* site already computed, and answer in the shared vocabulary.
|
||||
*
|
||||
* Precedence mirrors `runGroupImpact`: a runtime limit wins the reason, because
|
||||
* it is the cause the caller can act on (narrow the query, raise maxDepth),
|
||||
* while `'incomplete-sync'` needs a different remedy — `gitnexus group sync` —
|
||||
* and would otherwise mask it.
|
||||
*
|
||||
* Returns `{}` — not `{ truncated: false }` — when the answer is complete, so a
|
||||
* clean trace result keeps the exact shape it has always had.
|
||||
*/
|
||||
function traceCompleteness(
|
||||
bridge: CrossRepoCompleteness,
|
||||
runtimeTruncated: boolean,
|
||||
): GroupTraceCompleteness {
|
||||
const repos = bridge.incompleteRepos.length > 0 ? { truncatedRepos: bridge.incompleteRepos } : {};
|
||||
// Through `truncationFields`, not hand-written: `riskEpistemic` must follow
|
||||
// `truncated` mechanically, and a third writer of that pair is how the
|
||||
// invariant drifts (#2787). The bridge branch re-spreads the helper's own
|
||||
// output rather than naming its fields.
|
||||
if (runtimeTruncated) return { ...truncationFields(true, 'partial'), ...repos };
|
||||
if (!bridge.truncated) return {};
|
||||
const { incompleteRepos: _incompleteRepos, ...fields } = bridge;
|
||||
return { ...fields, ...repos };
|
||||
}
|
||||
|
||||
/**
|
||||
* The trace's declared scope for `crossRepoCompleteness`.
|
||||
*
|
||||
* A symbol-to-symbol trace asks about exactly two repos, so an unreadable third
|
||||
* member cannot make its answer a floor. A DESTINATION trace declares no `to`
|
||||
* at all — the call may land in any member — so every repo is in scope there,
|
||||
* which is why the predicate is built per call site rather than derived from
|
||||
* the endpoints inside the helper.
|
||||
*/
|
||||
function bridgeCompletenessFor(
|
||||
meta: BridgeMeta,
|
||||
inScope: (repoPath: string) => boolean,
|
||||
): CrossRepoCompleteness {
|
||||
return crossRepoCompleteness({
|
||||
unreadableRepos: meta.unreadableRepos,
|
||||
missingRepos: meta.missingRepos,
|
||||
provenanceUnknown: bridgeProvenanceUnknown(meta),
|
||||
inScope,
|
||||
});
|
||||
}
|
||||
|
||||
/** Repo-relative path equality, tolerant of a leading "./" / "/" or a repo prefix. */
|
||||
function sameFile(a: string, b: string): boolean {
|
||||
if (!a || !b) return false;
|
||||
|
|
@ -873,6 +955,23 @@ async function stitchCrossRepo(
|
|||
if (p.pdg) notes.push(TRACE_NOTES.pdgRequested);
|
||||
|
||||
try {
|
||||
// Inside the `try`, like `runGroupImpact`'s equivalent: the lease taken by
|
||||
// `ensureBridgeReady` is released by this block's `finally` and nowhere
|
||||
// else, so anything computed between the lease and the `try` is work whose
|
||||
// every throw would strand a refcount the cached handle never gets back.
|
||||
//
|
||||
// Declared scope = the two endpoint repos. Whether either of them is a repo
|
||||
// this bridge could not read decides whether "no ContractLink connects
|
||||
// them" is a verdict or a floor.
|
||||
const bridge = bridgeCompletenessFor(
|
||||
bridgePrep.meta,
|
||||
// `repoInSubgroup(..., exact)` rather than `===`: it normalizes separators
|
||||
// and strips trailing slashes, which bare equality does not, so the same
|
||||
// group.yaml spelling cannot be in scope for impact and out of scope here.
|
||||
(repoPath) =>
|
||||
repoInSubgroup(repoPath, fromEp.member.repoPath, true) ||
|
||||
repoInSubgroup(repoPath, toEp.member.repoPath, true),
|
||||
);
|
||||
const { crossings, truncated: crossingsTruncated } = await listCrossingsBetween(
|
||||
handle,
|
||||
fromEp.member.repoPath,
|
||||
|
|
@ -883,6 +982,10 @@ async function stitchCrossRepo(
|
|||
return {
|
||||
status: 'not_found',
|
||||
group: p.name,
|
||||
// No crossings at all is exactly the answer a bridge that never held an
|
||||
// endpoint's repo produces, so it is the one that most needs the floor
|
||||
// marker. (Nothing was capped: there were zero rows to cap.)
|
||||
...traceCompleteness(bridge, false),
|
||||
notes,
|
||||
suggestion:
|
||||
'The endpoints live in different repos with no ContractLink between them. ' +
|
||||
|
|
@ -1016,6 +1119,13 @@ async function stitchCrossRepo(
|
|||
hopCount: edges.length,
|
||||
hops: [...hopsA, ...hopsB],
|
||||
edges,
|
||||
// A found path is still an answer from this bridge: if its provenance is
|
||||
// unknown, or an endpoint's repo never made it in, the path may be stale
|
||||
// and it is certainly not the only one. An incompleteness channel that
|
||||
// fires only on the empty answer teaches an agent that a non-empty one
|
||||
// is always complete. The crossing cap is NOT folded in here — a path
|
||||
// that connected is not a capped search — so this site passes `false`.
|
||||
...traceCompleteness(bridge, false),
|
||||
notes,
|
||||
...(dataFlow.length > 0 ? { dataFlow } : {}),
|
||||
};
|
||||
|
|
@ -1028,7 +1138,7 @@ async function stitchCrossRepo(
|
|||
return {
|
||||
status: 'not_found',
|
||||
group: p.name,
|
||||
...(crossingsTruncated ? { truncated: true } : {}),
|
||||
...traceCompleteness(bridge, crossingsTruncated),
|
||||
notes,
|
||||
suggestion: crossingsTruncated
|
||||
? `No connecting crossing among the ${MAX_CROSSINGS_TO_TRY} highest-confidence ` +
|
||||
|
|
@ -1099,6 +1209,12 @@ async function stitchToDestination(
|
|||
if (p.crossDepthClamped) notes.push(TRACE_NOTES.crossDepthClamped);
|
||||
|
||||
try {
|
||||
// Inside the `try` for the lease reason above `stitchCrossRepo`'s copy. A
|
||||
// destination trace declares NO `to`: the call may land in any member, so
|
||||
// every repo is in the query's scope and no incomplete one can be filtered
|
||||
// out. An unreadable provider repo is precisely how "no outgoing
|
||||
// ContractLink leaves this repo" becomes a wrong answer, not an empty one.
|
||||
const bridge = bridgeCompletenessFor(bridgePrep.meta, () => true);
|
||||
const { crossings, truncated } = await listCrossingsFrom(handle, fromEp.member.repoPath);
|
||||
if (crossings.length === 0) {
|
||||
notes.push(TRACE_NOTES.destinationNoLink);
|
||||
|
|
@ -1107,6 +1223,8 @@ async function stitchToDestination(
|
|||
group: p.name,
|
||||
role: 'to',
|
||||
query: p.from_uid ?? p.from,
|
||||
// Zero rows to cap, so only the bridge's own completeness can speak.
|
||||
...traceCompleteness(bridge, false),
|
||||
notes,
|
||||
suggestion: 'Pass a `to` symbol for a symbol-to-symbol trace, or run group_sync.',
|
||||
};
|
||||
|
|
@ -1224,7 +1342,9 @@ async function stitchToDestination(
|
|||
hopCount: edgesA.length + 1,
|
||||
hops: [...hopsA, providerHop],
|
||||
edges: [...edgesA, boundaryEdge],
|
||||
...(truncated ? { truncated: true } : {}),
|
||||
// The cap already marked this result; the bridge's completeness folds
|
||||
// into the same fields rather than beside them.
|
||||
...traceCompleteness(bridge, truncated),
|
||||
notes: resultNotes,
|
||||
};
|
||||
};
|
||||
|
|
@ -1240,6 +1360,8 @@ async function stitchToDestination(
|
|||
group: p.name,
|
||||
role: 'to',
|
||||
candidates: candidatesFrom(precise),
|
||||
// The candidate LIST is what an incomplete bridge shortens here.
|
||||
...traceCompleteness(bridge, truncated),
|
||||
notes: [...notes, TRACE_NOTES.destinationMultiple],
|
||||
};
|
||||
}
|
||||
|
|
@ -1255,6 +1377,7 @@ async function stitchToDestination(
|
|||
group: p.name,
|
||||
role: 'to',
|
||||
candidates: candidatesFrom(fileLevel),
|
||||
...traceCompleteness(bridge, truncated),
|
||||
notes: [...notes, TRACE_NOTES.destinationAmbiguousFile],
|
||||
};
|
||||
}
|
||||
|
|
@ -1265,7 +1388,7 @@ async function stitchToDestination(
|
|||
group: p.name,
|
||||
role: 'to',
|
||||
query: p.from_uid ?? p.from,
|
||||
...(truncated ? { truncated: true } : {}),
|
||||
...traceCompleteness(bridge, truncated),
|
||||
notes,
|
||||
suggestion: 'Trace from the function that issues the HTTP request, or pass a `to` symbol.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -28,6 +28,13 @@ import {
|
|||
REQUEST_LINE_CONFIDENCE,
|
||||
EXCHANGE_CONFIDENCE,
|
||||
} from './spring-consumer-shared.js';
|
||||
import {
|
||||
extractJavaModuleConstants,
|
||||
foldJavaOperands,
|
||||
isJavaConstantFile,
|
||||
parseJavaConstOperands,
|
||||
type RepoConstants,
|
||||
} from '../../../ingestion/route-extractors/java-const-resolver.js';
|
||||
import {
|
||||
extractStaticPathExpression,
|
||||
inferOkHttpMethod,
|
||||
|
|
@ -165,6 +172,34 @@ const JAVA_ROUTE_ANNOTATION_PATTERNS = compilePatterns({
|
|||
key: (identifier) @key
|
||||
value: [(string_literal) @value (element_value_array_initializer (string_literal) @value)]))))
|
||||
name: (identifier) @member) @node
|
||||
(class_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: [(identifier) (scoped_identifier)] @ann
|
||||
arguments: (annotation_argument_list [(identifier) @value_expr (field_access) @value_expr (binary_expression) @value_expr])))) @node
|
||||
(class_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: [(identifier) (scoped_identifier)] @ann
|
||||
arguments: (annotation_argument_list
|
||||
(element_value_pair
|
||||
key: (identifier) @key
|
||||
value: [(identifier) @value_expr (field_access) @value_expr (binary_expression) @value_expr]))))) @node
|
||||
(method_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: [(identifier) (scoped_identifier)] @ann
|
||||
arguments: (annotation_argument_list [(identifier) @value_expr (field_access) @value_expr (binary_expression) @value_expr])))
|
||||
name: (identifier) @member) @node
|
||||
(method_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: [(identifier) (scoped_identifier)] @ann
|
||||
arguments: (annotation_argument_list
|
||||
(element_value_pair
|
||||
key: (identifier) @key
|
||||
value: [(identifier) @value_expr (field_access) @value_expr (binary_expression) @value_expr]))))
|
||||
name: (identifier) @member) @node
|
||||
]
|
||||
`,
|
||||
},
|
||||
|
|
@ -469,6 +504,12 @@ interface MethodRouteAnnotation {
|
|||
rawPath: string;
|
||||
/** OpenFeign's single effective verb; null means its contract is invalid/ambiguous. */
|
||||
feignHttpMethod?: string | null;
|
||||
/**
|
||||
* Non-literal path operands (constant ref or `+`-concat), captured when the
|
||||
* annotation value is not a string literal. Resolved against the repo-wide
|
||||
* Java constant map in scan(); a failed fold drops the route (skip floor).
|
||||
*/
|
||||
pathOperands?: readonly import('../../../ingestion/route-extractors/constant-resolver.js').Operand[];
|
||||
}
|
||||
|
||||
interface RequestLineAnnotation {
|
||||
|
|
@ -484,6 +525,16 @@ interface RouteAnnotationScan {
|
|||
feignPrefixByInterfaceId: Map<number, string[]>;
|
||||
/** Spring HTTP Interface `@HttpExchange(url|value)` type-level prefixes per class/interface node id. */
|
||||
httpExchangePrefixByTypeId: Map<number, string[]>;
|
||||
/**
|
||||
* Class node ids whose `@RequestMapping` prefix is a constant reference or
|
||||
* concat rather than a literal. Folding a TYPE-level prefix would need the
|
||||
* repo constant map inside `scanRouteAnnotations`, which has no access to it,
|
||||
* so `scan()` suppresses every method route under such a class instead of
|
||||
* emitting it with the prefix silently dropped (a wrong path, not a missing
|
||||
* one). Ingestion's `extractSpringRoutes` applies the identical rule — R4
|
||||
* parity.
|
||||
*/
|
||||
typesWithUnfoldablePrefix: Set<number>;
|
||||
/** Resolved Spring shortcut/`@RequestMapping` routes — paths × verbs yield one entry each. */
|
||||
methodRoutes: MethodRouteAnnotation[];
|
||||
/** One entry per OpenFeign `@RequestLine` whose value parses to a verb + path. */
|
||||
|
|
@ -511,6 +562,7 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
|
|||
// feeds the OpenFeign *consumer* path in scan(). An interface carrying both
|
||||
// `@RequestMapping` and `@FeignClient(path)` lands a different value in each.
|
||||
const prefixByTypeId = new Map<number, string[]>();
|
||||
const typesWithUnfoldablePrefix = new Set<number>();
|
||||
const feignPrefixByInterfaceId = new Map<number, string[]>();
|
||||
const httpExchangePrefixByTypeId = new Map<number, string[]>();
|
||||
const methodRoutes: MethodRouteAnnotation[] = [];
|
||||
|
|
@ -527,7 +579,10 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
|
|||
const annNode = captures.ann;
|
||||
const node = captures.node;
|
||||
const valueNode = captures.value;
|
||||
if (!annNode || !node || !valueNode) continue;
|
||||
// A non-literal annotation value (constant ref / `+`-concat) is captured
|
||||
// as @value_expr instead of @value — one of the two must be present.
|
||||
const valueExprNode = captures.value_expr;
|
||||
if (!annNode || !node || (!valueNode && !valueExprNode)) continue;
|
||||
// Discrimination is on the trailing segment only (`simpleName`), so a
|
||||
// non-Spring annotation whose last segment collides with a route annotation
|
||||
// (e.g. `@com.evil.GetMapping("/x")`) is treated as a route. This is the
|
||||
|
|
@ -550,7 +605,7 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
|
|||
const feignHttpMethod =
|
||||
httpMethods.length === 1 ? (httpMethods[0] === '*' ? 'GET' : httpMethods[0]) : null;
|
||||
if (!isRouteMemberKey(keyNode)) continue;
|
||||
const rawPath = unquoteLiteral(valueNode.text);
|
||||
const rawPath = valueNode ? unquoteLiteral(valueNode.text) : null;
|
||||
if (rawPath !== null) {
|
||||
for (const httpMethod of httpMethods) {
|
||||
methodRoutes.push({
|
||||
|
|
@ -561,10 +616,33 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
|
|||
feignHttpMethod,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Non-literal path (a constant reference or `+`-concatenation).
|
||||
// Defer to scan(): the fold needs the repo-wide constant map built
|
||||
// by prepareRepo. Capture the operand list now; resolution happens
|
||||
// in scan() against JavaRepoContext, and an unresolvable operand
|
||||
// list leaves the route skipped (KTD5 skip floor).
|
||||
const operands = parseJavaConstOperands(valueExprNode);
|
||||
if (operands !== null) {
|
||||
for (const httpMethod of httpMethods) {
|
||||
methodRoutes.push({
|
||||
methodNode: node,
|
||||
methodName: captures.member?.text ?? null,
|
||||
httpMethod,
|
||||
rawPath: '',
|
||||
feignHttpMethod,
|
||||
pathOperands: operands,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (ann === 'RequestLine') {
|
||||
// Feign packs verb + path in one literal; its only named argument is `value`.
|
||||
if (keyNode && keyNode.text !== 'value') continue;
|
||||
// A constant-valued `@RequestLine` arrives as @value_expr, not @value —
|
||||
// `valueNode` is undefined in that shape. Skip rather than dereference
|
||||
// (constant folding for Feign verb+path literals is out of scope here).
|
||||
if (!valueNode) continue;
|
||||
const raw = unquoteLiteral(valueNode.text);
|
||||
const parsed = raw !== null ? parseRequestLine(raw) : null;
|
||||
if (parsed) {
|
||||
|
|
@ -579,7 +657,7 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
|
|||
// `url` or `value` attribute (or positionally); other attributes
|
||||
// (`accept`, `contentType`, …) are not routes.
|
||||
if (keyNode && keyNode.text !== 'url' && keyNode.text !== 'value') continue;
|
||||
const rawPath = unquoteLiteral(valueNode.text);
|
||||
const rawPath = valueNode ? unquoteLiteral(valueNode.text) : null;
|
||||
if (rawPath !== null) {
|
||||
exchangeRoutes.push({
|
||||
methodNode: node,
|
||||
|
|
@ -596,6 +674,11 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
|
|||
// — on an interface — an OpenFeign `@FeignClient(path = "...")` prefix.
|
||||
if (ann === 'RequestMapping') {
|
||||
if (!isRouteMemberKey(keyNode)) continue;
|
||||
if (!valueNode) {
|
||||
// Constant-valued class prefix — see `typesWithUnfoldablePrefix`.
|
||||
typesWithUnfoldablePrefix.add(node.id);
|
||||
continue;
|
||||
}
|
||||
const prefix = unquoteLiteral(valueNode.text);
|
||||
if (prefix !== null) {
|
||||
pushPrefix(prefixByTypeId, node.id, prefix);
|
||||
|
|
@ -606,13 +689,13 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
|
|||
} else if (ann === 'FeignClient' && node.type === 'interface_declaration') {
|
||||
// Feign's `name`/`value` identify a service, not a path — only `path` is a prefix.
|
||||
if (!keyNode || keyNode.text !== 'path') continue;
|
||||
const prefix = unquoteLiteral(valueNode.text);
|
||||
const prefix = valueNode ? unquoteLiteral(valueNode.text) : null;
|
||||
if (prefix !== null) pushPrefix(feignPrefixByInterfaceId, node.id, prefix);
|
||||
} else if (ann === 'HttpExchange') {
|
||||
// Spring HTTP Interface type-level prefix: the path lives in `url`/`value`
|
||||
// (or positionally). Applies to its `@(Get|...)Exchange` consumer methods.
|
||||
if (keyNode && keyNode.text !== 'url' && keyNode.text !== 'value') continue;
|
||||
const prefix = unquoteLiteral(valueNode.text);
|
||||
const prefix = valueNode ? unquoteLiteral(valueNode.text) : null;
|
||||
if (prefix !== null) pushPrefix(httpExchangePrefixByTypeId, node.id, prefix);
|
||||
}
|
||||
}
|
||||
|
|
@ -662,6 +745,7 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
|
|||
|
||||
return {
|
||||
prefixByTypeId,
|
||||
typesWithUnfoldablePrefix,
|
||||
feignPrefixByInterfaceId,
|
||||
httpExchangePrefixByTypeId,
|
||||
methodRoutes: constrainedMethodRoutes,
|
||||
|
|
@ -707,9 +791,20 @@ function collectImplementedInterfaces(typeNode: Parser.SyntaxNode): string[] {
|
|||
}
|
||||
|
||||
function collectSpringTypes(filePath: string, tree: Parser.Tree): SharedSpringType[] {
|
||||
const { prefixByTypeId, methodRoutes } = scanRouteAnnotations(tree);
|
||||
const { prefixByTypeId, typesWithUnfoldablePrefix, methodRoutes } = scanRouteAnnotations(tree);
|
||||
const routesByMethodId = new Map<number, Array<{ method: string; path: string }>>();
|
||||
for (const route of methodRoutes) {
|
||||
// Constant-valued class prefix: no single prefix string exists here, so the
|
||||
// inheritance view would publish this route unprefixed. Skip — same rule as
|
||||
// scan() and as ingestion (R4 parity).
|
||||
const owner = findEnclosingClass(route.methodNode);
|
||||
if (owner && typesWithUnfoldablePrefix.has(owner.id)) continue;
|
||||
// A constant-referencing route still carries `rawPath: ''` here — folding
|
||||
// happens in scan() against the repo constant map, which this
|
||||
// inheritance-view collector has no access to. Emitting it as an empty
|
||||
// path would publish `POST /`-shaped noise into the shared type view;
|
||||
// skip instead (ingestion keeps the same skip floor — R4 parity).
|
||||
if (route.pathOperands) continue;
|
||||
const routes = routesByMethodId.get(route.methodNode.id) ?? [];
|
||||
routes.push({ method: route.httpMethod, path: route.rawPath });
|
||||
routesByMethodId.set(route.methodNode.id, routes);
|
||||
|
|
@ -781,8 +876,62 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
|||
content,
|
||||
);
|
||||
},
|
||||
scan(tree) {
|
||||
prepareRepo(args) {
|
||||
// Build the repo-wide Java string-constant map once per extract() run
|
||||
// (mirrors the Python binding's cost-gated pre-pass). A cheap content
|
||||
// gate keeps literal-only repos at zero parses: only files containing a
|
||||
// `static final String` declaration are parsed for constants.
|
||||
try {
|
||||
// The orchestrator hands over a bare Parser (no language set yet);
|
||||
// bind Java explicitly — Python's prepareRepo does the same — otherwise
|
||||
// parseSourceSafe spins to its 15 s budget per file.
|
||||
args.parser.setLanguage(Java);
|
||||
} catch {
|
||||
// fall through: a parser that rejects binding cannot produce a constant
|
||||
// map; per-file try/catch below then skips everything harmlessly.
|
||||
}
|
||||
const constants = new Map<
|
||||
string,
|
||||
import('../../../ingestion/route-extractors/constant-resolver.js').ModuleConstants
|
||||
>();
|
||||
for (const rel of args.files) {
|
||||
if (!rel.endsWith('.java')) continue;
|
||||
try {
|
||||
const src = args.readFile(rel);
|
||||
// Cheap content gate: only constant-DEFINITION candidates get parsed
|
||||
// here (~hundreds of files). Import-only files (every controller)
|
||||
// are deliberately NOT parsed in this pass — scan() lazily extracts
|
||||
// the importing file's own import table from the tree it already
|
||||
// holds when a constant-referencing route actually needs the fold.
|
||||
// A gate that also matched `import ...;` would parse the entire
|
||||
// repository here (tens of thousands of files) just to build import
|
||||
// tables the fold can derive per-file on demand.
|
||||
//
|
||||
// The predicate is the SHARED one the ingestion provider uses, so the
|
||||
// two subsystems agree on which files define constants. Its previous
|
||||
// local spelling missed `final static String` and lowercase interface
|
||||
// names, and admitted an interface that ingestion's gate rejected.
|
||||
if (!src || !isJavaConstantFile(src)) {
|
||||
continue;
|
||||
}
|
||||
const tree = args.parseSource(args.parser, src);
|
||||
if (!tree) continue;
|
||||
const mc = extractJavaModuleConstants(tree);
|
||||
if (mc.literals.size > 0 || mc.exprs.size > 0 || mc.imports.size > 0) {
|
||||
constants.set(rel, mc);
|
||||
}
|
||||
} catch {
|
||||
// Per-file resilience: one unreadable/oversized/ill-formed file must
|
||||
// not forfeit the whole repo's constant map (a missing constants
|
||||
// class only degrades refs that pointed at it).
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return { constants };
|
||||
},
|
||||
scan(tree, repoContext, fileRel) {
|
||||
const out: HttpDetection[] = [];
|
||||
const javaCtx = repoContext as { constants: RepoConstants } | undefined;
|
||||
|
||||
// ─── Spring providers + OpenFeign consumers (one query pass) ────
|
||||
// `scanRouteAnnotations` resolves every route-defining annotation —
|
||||
|
|
@ -790,6 +939,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
|||
// `@RequestLine`s — from a single `matches()` pass over the tree.
|
||||
const {
|
||||
prefixByTypeId,
|
||||
typesWithUnfoldablePrefix,
|
||||
feignPrefixByInterfaceId,
|
||||
httpExchangePrefixByTypeId,
|
||||
methodRoutes,
|
||||
|
|
@ -802,7 +952,48 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
|||
// class is a Spring *provider*. A mapping on a non-Feign interface has no
|
||||
// enclosing class and is dropped here — interface→controller inheritance is
|
||||
// handled by `scanProject`.
|
||||
// Lazy per-file constants view. prepareRepo only indexes constant-
|
||||
// DEFINING files (cheap gate); an importing controller is absent from
|
||||
// that map. When a route actually references a constant, extract THIS
|
||||
// file's import table from the tree scan() already holds (zero extra
|
||||
// parses) and overlay it for the fold. Files whose routes are all
|
||||
// literal — the overwhelming majority — never pay this cost.
|
||||
let foldConstants: RepoConstants | undefined;
|
||||
const getFoldConstants = (): RepoConstants | undefined => {
|
||||
if (foldConstants !== undefined) return foldConstants;
|
||||
foldConstants = javaCtx?.constants;
|
||||
if (!javaCtx?.constants || !fileRel) return foldConstants;
|
||||
if (javaCtx.constants.has(fileRel)) return foldConstants;
|
||||
try {
|
||||
const mc = extractJavaModuleConstants(tree);
|
||||
if (mc.imports.size > 0) {
|
||||
const merged = new Map(javaCtx.constants);
|
||||
merged.set(fileRel, mc);
|
||||
foldConstants = merged;
|
||||
}
|
||||
} catch {
|
||||
// fold falls back to the repo-wide map (imports stay unresolved)
|
||||
}
|
||||
return foldConstants;
|
||||
};
|
||||
|
||||
for (const route of methodRoutes) {
|
||||
// A constant-valued CLASS prefix cannot be folded here, so every method
|
||||
// route under such a class is suppressed rather than emitted at a wrong
|
||||
// (unprefixed) path — the same rule `classesWithArrayPrefix` already
|
||||
// encodes for the array form, and the same rule ingestion applies.
|
||||
const owner = findEnclosingClass(route.methodNode);
|
||||
if (owner && typesWithUnfoldablePrefix.has(owner.id)) continue;
|
||||
// Non-literal route path: fold the operand list against the repo-wide
|
||||
// constant map. Skip (never a guessed path) when the fold fails or the
|
||||
// repo context is absent (context-less fallback scanning).
|
||||
if (route.pathOperands && javaCtx && fileRel) {
|
||||
const resolved = foldJavaOperands(fileRel, route.pathOperands, getFoldConstants()!);
|
||||
if (resolved === null) continue;
|
||||
route.rawPath = resolved;
|
||||
} else if (route.pathOperands) {
|
||||
continue;
|
||||
}
|
||||
const enclosingInterface = findEnclosingInterface(route.methodNode);
|
||||
if (enclosingInterface && hasAnnotation(enclosingInterface, 'FeignClient')) {
|
||||
if (!route.feignHttpMethod) continue;
|
||||
|
|
|
|||
|
|
@ -9,15 +9,28 @@ import {
|
|||
type LanguagePatterns,
|
||||
type PatternSpec,
|
||||
} from '../tree-sitter-scanner.js';
|
||||
import type { HttpDetection, HttpLanguagePlugin } from './types.js';
|
||||
import type { HttpDetection, HttpLanguagePlugin, RepoContext } from './types.js';
|
||||
import { MAX_FOLD_LENGTH } from '../../../ingestion/route-extractors/constant-resolver.js';
|
||||
import {
|
||||
DATA_ROUTE_TABLE_SOURCE,
|
||||
scanDataRouteTables,
|
||||
} from '../../../ingestion/route-extractors/data-route-table.js';
|
||||
import { extractNestRoutes } from '../../../ingestion/route-extractors/nest.js';
|
||||
import { normalizeExtractedRoutePath } from '../../../ingestion/route-extractors/route-path.js';
|
||||
import {
|
||||
buildJsRepoFacts,
|
||||
extractJsModuleFacts,
|
||||
isAxiosNamespace,
|
||||
isHttpClientRef,
|
||||
resolveJsPathExpression,
|
||||
type JsModuleFacts,
|
||||
type JsRepoFacts,
|
||||
} from '../../../ingestion/route-extractors/js-const-resolver.js';
|
||||
|
||||
/**
|
||||
* Node.js / TypeScript HTTP plugin family. Handles:
|
||||
* - 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
|
||||
|
|
@ -32,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>> = {
|
||||
|
|
@ -98,15 +85,28 @@ const FETCH_WITH_OPTIONS_SPEC: PatternSpec<Record<string, never>> = {
|
|||
`,
|
||||
};
|
||||
|
||||
// ─── Consumer: axios.get/post/... ────────────────────────────────────
|
||||
const AXIOS_SPEC: PatternSpec<Record<string, never>> = {
|
||||
// ─── Consumer: <httpClient>.get/post/... ─────────────────────────────
|
||||
// Widened from a literal `axios` receiver with a literal path. Application
|
||||
// code satisfies neither: it calls through a configured instance
|
||||
// (`const api = axios.create({ baseURL })`, imported at the call site under
|
||||
// whatever name the app chose) and passes the path by reference from a shared
|
||||
// route table (`api.get(API_ROUTE_PATH.LINKS)`). The query therefore matches
|
||||
// ANY identifier receiver with an HTTP-verb method and ANY first argument;
|
||||
// `scanBundle` admits a match only after PROVING the receiver is an axios
|
||||
// instance and resolving the argument to a path.
|
||||
//
|
||||
// The proof gate is load-bearing, not belt-and-braces: EXPRESS_SPEC above
|
||||
// matches `router.get('/x', handler)` / `app.post(...)` as PROVIDERS. A
|
||||
// receiver admitted on spelling alone would re-emit every Express route in the
|
||||
// repo as a consumer of itself, on both sides of every cross-repo pair.
|
||||
const HTTP_CLIENT_SPEC: PatternSpec<Record<string, never>> = {
|
||||
meta: {},
|
||||
query: `
|
||||
(call_expression
|
||||
function: (member_expression
|
||||
object: (identifier) @obj (#eq? @obj "axios")
|
||||
object: (identifier) @obj
|
||||
property: (property_identifier) @http_method (#match? @http_method "^(get|post|put|delete|patch)$"))
|
||||
arguments: (arguments . [(string) (template_string)] @path))
|
||||
arguments: (arguments . (_) @path))
|
||||
`,
|
||||
};
|
||||
|
||||
|
|
@ -153,12 +153,10 @@ 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>>;
|
||||
axios: CompiledPatterns<Record<string, never>>;
|
||||
httpClient: CompiledPatterns<Record<string, never>>;
|
||||
jqueryShorthand: CompiledPatterns<Record<string, never>>;
|
||||
jqueryAjax: CompiledPatterns<Record<string, never>>;
|
||||
axiosObject: CompiledPatterns<Record<string, never>>;
|
||||
|
|
@ -172,12 +170,10 @@ 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'),
|
||||
axios: mk(AXIOS_SPEC, 'axios'),
|
||||
httpClient: mk(HTTP_CLIENT_SPEC, 'http-client'),
|
||||
jqueryShorthand: mk(JQUERY_SHORTHAND_SPEC, 'jquery-shorthand'),
|
||||
jqueryAjax: mk(JQUERY_AJAX_SPEC, 'jquery-ajax'),
|
||||
axiosObject: mk(AXIOS_OBJECT_SPEC, 'axios-object'),
|
||||
|
|
@ -188,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
|
||||
|
|
@ -237,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
|
||||
|
|
@ -309,12 +216,22 @@ function findDecoratedMethod(decoratorNode: Parser.SyntaxNode): Parser.SyntaxNod
|
|||
*/
|
||||
function buildImportMap(tree: Parser.Tree): Map<string, { name: string; module: string }> {
|
||||
const map = new Map<string, { name: string; module: string }>();
|
||||
const walk = (node: Parser.SyntaxNode): void => {
|
||||
// Both walks are explicit-stack, not recursive. They visit EVERY node of the
|
||||
// file, so their depth is the source's nesting depth — and `scan` may not
|
||||
// throw: a `RangeError` here escapes to `sync.ts`, which records the repo as
|
||||
// an unexplained "missing repo" and drops every contract of every kind for
|
||||
// it, silently. A file nesting template substitutions ~4 000 deep (well
|
||||
// inside what tree-sitter will parse) was enough.
|
||||
const stack: Parser.SyntaxNode[] = [tree.rootNode];
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop() as Parser.SyntaxNode;
|
||||
if (node.type === 'import_statement') {
|
||||
const sourceNode = node.childForFieldName('source');
|
||||
const module = sourceNode ? unquoteLiteral(sourceNode.text) : null;
|
||||
if (module !== null) {
|
||||
const collect = (n: Parser.SyntaxNode): void => {
|
||||
const inner: Parser.SyntaxNode[] = [node];
|
||||
while (inner.length > 0) {
|
||||
const n = inner.pop() as Parser.SyntaxNode;
|
||||
if (n.type === 'import_specifier') {
|
||||
const nameNode = n.childForFieldName('name');
|
||||
const aliasNode = n.childForFieldName('alias');
|
||||
|
|
@ -323,81 +240,266 @@ function buildImportMap(tree: Parser.Tree): Map<string, { name: string; module:
|
|||
map.set(local.text, { name: nameNode.text, module });
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < n.namedChildCount; i++) {
|
||||
const c = n.namedChild(i);
|
||||
if (c) collect(c);
|
||||
}
|
||||
};
|
||||
collect(node);
|
||||
for (const c of n.namedChildren) inner.push(c);
|
||||
}
|
||||
}
|
||||
// An import statement cannot contain another one, and the inner loop has
|
||||
// already visited its whole subtree.
|
||||
continue;
|
||||
}
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const c = node.namedChild(i);
|
||||
if (c) walk(c);
|
||||
}
|
||||
};
|
||||
walk(tree.rootNode);
|
||||
for (const c of node.namedChildren) stack.push(c);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection[] {
|
||||
// ─── Repo pre-pass: cross-file route tables + HTTP client instances ──
|
||||
//
|
||||
// Both halves of a real consumer call live in files OTHER than the call site:
|
||||
// the client is created in `lib/axios.config.ts` and the path in
|
||||
// `shared/api-routes.ts`. A per-file scan cannot see either, which is why the
|
||||
// literal-only patterns matched almost nothing on application code. The
|
||||
// pre-pass builds a repo-wide fact map once so `scan` can resolve both.
|
||||
|
||||
interface NodeRepoContext {
|
||||
readonly facts: JsRepoFacts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared across the three JS/TS plugins.
|
||||
*
|
||||
* JS, TS and TSX are distinct `HttpLanguagePlugin` objects, and the
|
||||
* orchestrator caches `prepareRepo` output per plugin NAME — so a polyglot
|
||||
* frontend would build this identical map three times. All three are called
|
||||
* with the same memoized file-list array within one extraction run, so keying
|
||||
* on that array's identity collapses the work to a single pass. A later run
|
||||
* passes a different array and correctly rebuilds; the weak key lets the old
|
||||
* map be collected with it.
|
||||
*/
|
||||
const REPO_CONTEXT_BY_FILE_LIST = new WeakMap<readonly string[], NodeRepoContext>();
|
||||
|
||||
/**
|
||||
* Skip ceiling for the pre-pass, mirroring the analyzer's default
|
||||
* `--max-file-size`. A minified bundle is megabytes on one line and defines no
|
||||
* route table a human wrote; parsing it costs far more than it can return.
|
||||
*/
|
||||
const MAX_PREPASS_FILE_BYTES = 512 * 1024;
|
||||
|
||||
/** Repo-relative path in the same POSIX form the fact map is keyed by. */
|
||||
function normalizeRel(rel: string): string {
|
||||
return rel.replace(/\\/g, '/').replace(/^\.\//, '');
|
||||
}
|
||||
|
||||
/** The grammar a JS/TS-family file should be parsed with, or null if not one. */
|
||||
function grammarForFile(rel: string): unknown | null {
|
||||
const lower = rel.toLowerCase();
|
||||
if (lower.endsWith('.tsx')) return TypeScript.tsx;
|
||||
if (/\.[cm]?ts$/.test(lower)) return TypeScript.typescript;
|
||||
if (/\.[cm]?jsx?$/.test(lower)) return JavaScript;
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildNodeRepoContext(args: {
|
||||
files: string[];
|
||||
readFile: (rel: string) => string | null;
|
||||
parseSource: (parser: Parser, src: string) => Parser.Tree | null;
|
||||
}): NodeRepoContext {
|
||||
const cached = REPO_CONTEXT_BY_FILE_LIST.get(args.files);
|
||||
if (cached) return cached;
|
||||
|
||||
const byFile = new Map<string, JsModuleFacts>();
|
||||
const parsers = new Map<unknown, Parser>();
|
||||
const parserFor = (language: unknown): Parser => {
|
||||
let parser = parsers.get(language);
|
||||
if (!parser) {
|
||||
parser = new Parser();
|
||||
parser.setLanguage(language as Parameters<Parser['setLanguage']>[0]);
|
||||
parsers.set(language, parser);
|
||||
}
|
||||
return parser;
|
||||
};
|
||||
|
||||
// Cost gate, in the spirit of the sibling `python.ts` pre-pass: every fact
|
||||
// this map holds exists to prove a receiver is an axios instance or to fold a
|
||||
// path for one. A repo where the string `axios` appears nowhere can prove no
|
||||
// receiver, so every parse below is dead work — and parsing is the expensive
|
||||
// half (measured 4.36 s / +258 MB RSS over 827 TypeScript files, on top of
|
||||
// the parse `getScanInput` already does).
|
||||
// Only the file's identity is carried between the passes, never its text: a
|
||||
// large monorepo's whole source tree held in one array at once is the shape
|
||||
// that produced the analyzer's scale problems, and the second read is cheap
|
||||
// beside the parse it gates.
|
||||
const eligible: Array<{ rel: string; language: unknown }> = [];
|
||||
let sawAxios = false;
|
||||
for (const rel of args.files) {
|
||||
const language = grammarForFile(rel);
|
||||
if (language === null) continue;
|
||||
const content = args.readFile(rel);
|
||||
// `MAX_PREPASS_FILE_BYTES` is a BYTE ceiling; `String.length` counts UTF-16
|
||||
// code units, which under-counts every multi-byte source.
|
||||
if (content === null || Buffer.byteLength(content, 'utf8') > MAX_PREPASS_FILE_BYTES) continue;
|
||||
if (!sawAxios && content.includes('axios')) sawAxios = true;
|
||||
eligible.push({ rel, language });
|
||||
}
|
||||
|
||||
if (sawAxios) {
|
||||
for (const { rel, language } of eligible) {
|
||||
try {
|
||||
const content = args.readFile(rel);
|
||||
if (content === null) continue;
|
||||
// `parseSource` belongs INSIDE the guard: `safe-parse.ts` throws
|
||||
// `ParseTimeoutError` and makes catching it a per-caller obligation, and
|
||||
// `prepareRepo` is contractually non-throwing. One escape here left the
|
||||
// fact map unwritten for the WHOLE repo — and, because the orchestrator
|
||||
// caches per plugin NAME, made all three JS/TS plugins re-walk it and
|
||||
// fail the same way before falling back to literal-only scanning.
|
||||
const tree = args.parseSource(parserFor(language), content);
|
||||
if (!tree) continue;
|
||||
byFile.set(normalizeRel(rel), extractJsModuleFacts(tree));
|
||||
} catch {
|
||||
// One malformed file must never abort the pre-pass — it simply stays
|
||||
// unresolved, exactly as it is without this pass at all.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ctx: NodeRepoContext = { facts: buildJsRepoFacts(byFile) };
|
||||
REPO_CONTEXT_BY_FILE_LIST.set(args.files, ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** The repo facts to resolve against, or null when there was no pre-pass. */
|
||||
function resolveFactsFor(
|
||||
repoContext: RepoContext | undefined,
|
||||
fileRel: string | undefined,
|
||||
): JsRepoFacts | null {
|
||||
const ctx = repoContext as NodeRepoContext | undefined;
|
||||
if (!ctx || fileRel === undefined) return null;
|
||||
return ctx.facts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a folded first argument is plausibly a URL path.
|
||||
*
|
||||
* The query now captures ANY first argument, and "it folded to a string" is not
|
||||
* "it is a path" — `normalizeConsumerPath` is a canonicalizer, not a validator,
|
||||
* and it happily turns non-paths into contracts that exact-match real provider
|
||||
* routes:
|
||||
*
|
||||
* api.get(CONFIG.TIMEOUT) // "5000" -> http::GET::/{param}
|
||||
* api.post(MSG.ERROR) // "Could not reach the …" -> http::POST::/could not reach the server
|
||||
*
|
||||
* `/{param}` matches every one-segment provider route in the group, and
|
||||
* `matching.exclude_links_param_only_paths` defaults to `false`. A path whose
|
||||
* leading term is an unresolved placeholder is refused for the same reason —
|
||||
* nothing pins where it starts. (`resolveJsPathExpression` already refuses those
|
||||
* it folded itself; this also covers the literal fallback below.)
|
||||
*/
|
||||
function looksLikeHttpPath(path: string): boolean {
|
||||
if (path === '') return false;
|
||||
if (/^https?:\/\//i.test(path)) return true;
|
||||
// A `${…}` term is a runtime value that `normalizeConsumerPath` rewrites to
|
||||
// `{param}`; its SOURCE text can be any expression (`${draft ? 'a' : 'b'}`,
|
||||
// `${id ?? ''}`), so the checks below have to run against the normalized
|
||||
// shape. Testing the raw source dropped every partially folded path whose
|
||||
// unresolved term happened to contain a space.
|
||||
const shape = path.replace(/\$\{[^}]+\}/g, '{param}');
|
||||
if (/\s/.test(shape)) return false;
|
||||
if (shape.startsWith('{param}')) return false;
|
||||
// An all-digit string is a path only when it is written as one. A leading
|
||||
// slash is that evidence: `client.get('/123')` is a route whose segment the
|
||||
// consumer normalizer reads as `{param}`, while a bare `"5000"` folded out of
|
||||
// `CONFIG.TIMEOUT` is a timeout that would match every one-segment provider.
|
||||
if (!shape.startsWith('/')) return !/^\d+$/.test(shape);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The path a consumer call's first argument denotes.
|
||||
*
|
||||
* Prefers full resolution against the repo facts; falls back to the raw
|
||||
* literal for a string/template node so a repo with no pre-pass (or an
|
||||
* unresolvable reference) behaves exactly as it did before.
|
||||
*
|
||||
* `fileKey` is already `normalizeRel`-ed by the caller — see `scanBundle`.
|
||||
*
|
||||
* `legacyShape` marks the exact combination this pattern matched BEFORE it was
|
||||
* widened: the literal receiver `axios` with a string or template-string first
|
||||
* argument. That combination keeps its old output verbatim, so this PR adds
|
||||
* detections without removing any — `axios.get(`${API_BASE}/users`)` still
|
||||
* yields `/{param}/users`. Everything the widened query NEWLY admits (any other
|
||||
* receiver, or any non-literal argument) has to clear the gates.
|
||||
*/
|
||||
function resolveConsumerPath(
|
||||
pathNode: Parser.SyntaxNode,
|
||||
facts: JsRepoFacts | null,
|
||||
fileKey: string | undefined,
|
||||
legacyShape: boolean,
|
||||
): string | null {
|
||||
if (facts && fileKey !== undefined) {
|
||||
const resolved = resolveJsPathExpression(fileKey, pathNode, facts);
|
||||
if (resolved !== null && looksLikeHttpPath(resolved)) return resolved;
|
||||
}
|
||||
// The fallback is deliberately gated on node TYPE: `unquoteLiteral` returns
|
||||
// unrecognized input unchanged, so handing it a `member_expression` would
|
||||
// yield the literal text `API_ROUTE_PATH.LINKS` as if it were a URL path.
|
||||
if (pathNode.type !== 'string' && pathNode.type !== 'template_string') return null;
|
||||
const literal = unquoteLiteral(pathNode.text);
|
||||
// The fold bails past `MAX_FOLD_LENGTH`; the raw source it falls back to has
|
||||
// no such bound and lands in `contractId` and `meta.path` all the same.
|
||||
if (literal === null || literal.length > MAX_FOLD_LENGTH) return null;
|
||||
return legacyShape || looksLikeHttpPath(literal) ? literal : null;
|
||||
}
|
||||
|
||||
function scanBundle(
|
||||
bundle: NodePatternBundle,
|
||||
tree: Parser.Tree,
|
||||
repoContext?: RepoContext,
|
||||
fileRel?: string,
|
||||
): HttpDetection[] {
|
||||
const out: HttpDetection[] = [];
|
||||
// Repo-wide constant / HTTP-client facts, when the orchestrator ran the
|
||||
// `prepareRepo` pre-pass. Absent for a bare `scan(tree)` call, in which case
|
||||
// every cross-file resolution below floors to the literal-only behavior.
|
||||
const facts = resolveFactsFor(repoContext, fileRel);
|
||||
// The fact map is keyed by `normalizeRel(rel)`. Normalizing at ONE place and
|
||||
// using that value for every read keeps the two sides in step: the receiver
|
||||
// gate used to read the raw `fileRel`, and `isHttpClientRef` cannot tell a key
|
||||
// miss from "not a client", so any non-POSIX path (glob v13 has no
|
||||
// `posix: true` and its walker joins with the platform separator; graph rows
|
||||
// are a second unnormalized source) silently returned zero consumers.
|
||||
const fileKey = fileRel === undefined ? undefined : normalizeRel(fileRel);
|
||||
// Local-binding → { declared export name, module } for the file's named
|
||||
// imports, so an express handler that is an imported (possibly aliased)
|
||||
// symbol resolves to the real definition rather than its local alias text.
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
|
@ -471,22 +573,57 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection
|
|||
});
|
||||
}
|
||||
|
||||
// Consumer: axios.<verb>(url)
|
||||
for (const match of runCompiledPatterns(bundle.axios, tree)) {
|
||||
// Consumer: <httpClient>.<verb>(url) — `axios` itself, or any receiver the
|
||||
// repo pre-pass proves is an axios instance.
|
||||
for (const match of runCompiledPatterns(bundle.httpClient, tree)) {
|
||||
const methodNode = match.captures.http_method;
|
||||
const pathNode = match.captures.path;
|
||||
if (!methodNode || !pathNode) continue;
|
||||
const path = unquoteLiteral(pathNode.text);
|
||||
if (path === null) continue;
|
||||
out.push({
|
||||
role: 'consumer',
|
||||
framework: 'axios',
|
||||
method: methodNode.text.toUpperCase(),
|
||||
path,
|
||||
name: null,
|
||||
line: pathNode.startPosition.row + 1,
|
||||
confidence: 0.7,
|
||||
});
|
||||
const objNode = match.captures.obj;
|
||||
if (!methodNode || !pathNode || !objNode) continue;
|
||||
|
||||
// Receiver gate. `axios.get(...)` needs no proof; anything else must be
|
||||
// traced to an `axios.create(...)` binding, or it is not ours to claim.
|
||||
const receiver = objNode.text;
|
||||
|
||||
// Cross-file resolution is the only work in this file that walks a
|
||||
// repo-wide graph, and `HttpLanguagePlugin.scan` may not throw: a single
|
||||
// hostile call site must cost its own detection, not the repo's whole
|
||||
// contract set (`sync.ts` catches a throw here as an unexplained "missing
|
||||
// repo", silently, for every contract type).
|
||||
try {
|
||||
// The receiver is admitted when it IS the axios module — the bare
|
||||
// spelling this pattern trusted before it was widened, or a declared
|
||||
// import/require of 'axios' under any name — or when it traces to an
|
||||
// `axios.create(...)` instance. Nothing else.
|
||||
const isModule =
|
||||
facts === null || fileKey === undefined
|
||||
? receiver === 'axios'
|
||||
: isAxiosNamespace(fileKey, receiver, facts);
|
||||
if (!isModule) {
|
||||
if (!facts || fileKey === undefined) continue;
|
||||
if (!isHttpClientRef(fileKey, receiver, facts)) continue;
|
||||
}
|
||||
|
||||
const path = resolveConsumerPath(
|
||||
pathNode,
|
||||
facts,
|
||||
fileKey,
|
||||
isModule && (pathNode.type === 'string' || pathNode.type === 'template_string'),
|
||||
);
|
||||
if (path === null) continue;
|
||||
|
||||
out.push({
|
||||
role: 'consumer',
|
||||
framework: 'axios',
|
||||
method: methodNode.text.toUpperCase(),
|
||||
path,
|
||||
name: null,
|
||||
line: pathNode.startPosition.row + 1,
|
||||
confidence: 0.7,
|
||||
});
|
||||
} catch {
|
||||
// Unresolvable is the same outcome as unresolved — skip this call site.
|
||||
}
|
||||
}
|
||||
|
||||
// Consumer: jQuery shorthand $.get(url) / $.post(url, ...)
|
||||
|
|
@ -574,17 +711,20 @@ function scanBundle(bundle: NodePatternBundle, tree: Parser.Tree): HttpDetection
|
|||
export const JAVASCRIPT_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
name: 'javascript-http',
|
||||
language: JavaScript,
|
||||
scan: (tree) => scanBundle(JAVASCRIPT_BUNDLE, tree),
|
||||
prepareRepo: buildNodeRepoContext,
|
||||
scan: (tree, repoContext, fileRel) => scanBundle(JAVASCRIPT_BUNDLE, tree, repoContext, fileRel),
|
||||
};
|
||||
|
||||
export const TYPESCRIPT_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
name: 'typescript-http',
|
||||
language: TypeScript.typescript,
|
||||
scan: (tree) => scanBundle(TYPESCRIPT_BUNDLE, tree),
|
||||
prepareRepo: buildNodeRepoContext,
|
||||
scan: (tree, repoContext, fileRel) => scanBundle(TYPESCRIPT_BUNDLE, tree, repoContext, fileRel),
|
||||
};
|
||||
|
||||
export const TSX_HTTP_PLUGIN: HttpLanguagePlugin = {
|
||||
name: 'tsx-http',
|
||||
language: TypeScript.tsx,
|
||||
scan: (tree) => scanBundle(TSX_BUNDLE, tree),
|
||||
prepareRepo: buildNodeRepoContext,
|
||||
scan: (tree, repoContext, fileRel) => scanBundle(TSX_BUNDLE, tree, repoContext, fileRel),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 { recordedRepoList } from './completeness.js';
|
||||
import { GroupNotFoundError, loadGroupConfig } from './config-parser.js';
|
||||
import {
|
||||
fileMatchesServicePrefix,
|
||||
|
|
@ -222,6 +231,34 @@ function isCrossLink(raw: unknown): raw is CrossLink {
|
|||
return typeof o.contractId === 'string' && typeof o.type === 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the global registry hold a row for this configured group member?
|
||||
*
|
||||
* Consulted only once resolution has ALREADY failed, to choose which of the
|
||||
* two failures `group status` reports. It mirrors the two tiers
|
||||
* `LocalBackend.resolveRepo` matches a bare group-config value on — the
|
||||
* registry `name`, case-insensitively, and the repo `path` — and deliberately
|
||||
* stops short of its hashed-id and partial-name tiers: those exist to be
|
||||
* generous about what an operator typed, while this predicate only decides
|
||||
* between two labels, and a looser match here would relabel a genuine registry
|
||||
* miss as an unresolvable row. That is the same conflation this reporting
|
||||
* exists to remove, pointed the other way.
|
||||
*/
|
||||
function registryIdentifies(entries: RegistryEntry[], registryName: string): boolean {
|
||||
const wantedName = registryName.toLowerCase();
|
||||
// Path equality goes through the registry's own rule rather than a local
|
||||
// `resolve` + platform-case compare. `canonicalizePath` also follows symlinks,
|
||||
// so a row registered through one and looked up through the other still
|
||||
// matches — and there is one definition of registry path identity instead of
|
||||
// a third, weaker copy of it living in a group module nobody would grep.
|
||||
const wantedPath = canonicalizePath(registryName);
|
||||
return entries.some((entry) => {
|
||||
if (typeof entry.name === 'string' && entry.name.toLowerCase() === wantedName) return true;
|
||||
if (typeof entry.path !== 'string') return false;
|
||||
return registryPathEquals(canonicalizePath(entry.path), wantedPath);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadContractRegistryResilient(
|
||||
groupDir: string,
|
||||
): Promise<
|
||||
|
|
@ -288,6 +325,8 @@ async function loadContractRegistryResilient(
|
|||
}
|
||||
}
|
||||
|
||||
// Bound once: the gate is a full array scan and the ternary below used it twice.
|
||||
const recordedUnreadable = recordedRepoList(base.unreadableRepos);
|
||||
const registry: ContractRegistry = {
|
||||
version: typeof base.version === 'number' ? base.version : 0,
|
||||
generatedAt: typeof base.generatedAt === 'string' ? base.generatedAt : '',
|
||||
|
|
@ -295,7 +334,20 @@ async function loadContractRegistryResilient(
|
|||
base.repoSnapshots && typeof base.repoSnapshots === 'object' && base.repoSnapshots !== null
|
||||
? (base.repoSnapshots as Record<string, { indexedAt: string; lastCommit: string }>)
|
||||
: {},
|
||||
missingRepos: Array.isArray(base.missingRepos) ? (base.missingRepos as string[]) : [],
|
||||
// Same gate as `groupStatus` uses on the same field, for the same reason:
|
||||
// `Array.isArray` alone waves through `[{repo:'x'}]`, and `groupContracts`
|
||||
// now returns this list AND folds it into its completeness answer, so a
|
||||
// value we could not read would be reported as a repo name. `missingRepos`
|
||||
// has always been required, so — unlike `unreadableRepos` below — there is
|
||||
// no "not recorded" state to preserve: an unreadable value degrades to empty.
|
||||
missingRepos: recordedRepoList(base.missingRepos) ?? [],
|
||||
// Spread, not `?? []`. `ContractRegistry.unreadableRepos` documents absence
|
||||
// as "not recorded", and a registry written before the field existed has no
|
||||
// opinion about which indexes were readable. Normalizing that to `[]` hands
|
||||
// the caller "the last sync found none unreadable" — an unmeasured state
|
||||
// rendered as a clean result, which is the same conflation this whole
|
||||
// change removes.
|
||||
...(recordedUnreadable ? { unreadableRepos: recordedUnreadable } : {}),
|
||||
contracts,
|
||||
crossLinks,
|
||||
};
|
||||
|
|
@ -347,18 +399,34 @@ export class GroupService {
|
|||
// MCP server startup entirely and off every non-sync group call. The CLI
|
||||
// already does exactly this at `cli/group.ts`'s sync command.
|
||||
const { syncGroup } = await import('./sync.js');
|
||||
const result = await syncGroup(config, {
|
||||
groupDir,
|
||||
exactOnly: Boolean(params.exactOnly),
|
||||
skipEmbeddings: Boolean(params.skipEmbeddings),
|
||||
allowStale: Boolean(params.allowStale),
|
||||
verbose: Boolean(params.verbose),
|
||||
});
|
||||
const { GroupSyncLockError } = await import('./group-lock.js');
|
||||
let result: Awaited<ReturnType<typeof syncGroup>>;
|
||||
try {
|
||||
result = await syncGroup(config, {
|
||||
groupDir,
|
||||
exactOnly: Boolean(params.exactOnly),
|
||||
skipEmbeddings: Boolean(params.skipEmbeddings),
|
||||
allowStale: Boolean(params.allowStale),
|
||||
verbose: Boolean(params.verbose),
|
||||
});
|
||||
} catch (err) {
|
||||
// Fails closed (R9): this sync could not be protected against a concurrent
|
||||
// one, so it did not run and wrote nothing. Return it through the same
|
||||
// error channel a missing group uses — NEVER as a success payload of zeroes,
|
||||
// which an agent would read as "the group genuinely has no contracts".
|
||||
if (!(err instanceof GroupSyncLockError)) throw err;
|
||||
return { error: err.message };
|
||||
}
|
||||
return {
|
||||
contracts: result.contracts.length,
|
||||
crossLinks: result.crossLinks.length,
|
||||
unmatched: result.unmatched.length,
|
||||
missingRepos: result.missingRepos,
|
||||
unreadableRepos: result.unreadableRepos,
|
||||
// An agent that calls group_sync and then group_contracts a moment later
|
||||
// can otherwise see contract counts that disagree with this payload, with
|
||||
// nothing here explaining why the write was skipped.
|
||||
registryOutcome: result.registryOutcome,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -386,7 +454,38 @@ export class GroupService {
|
|||
);
|
||||
contracts = contracts.filter((c) => !matchedIds.has(`${c.repo}::${c.contractId}`));
|
||||
}
|
||||
const out: Record<string, unknown> = { contracts, crossLinks: registry.crossLinks };
|
||||
// `loadContractRegistryResilient` already applied `recordedRepoList` to
|
||||
// both: `undefined` here is "the last sync recorded no opinion" (a registry
|
||||
// written before the field existed, or a value we could not read), which is
|
||||
// NOT the same answer as the measured empty list.
|
||||
const { unreadableRepos, missingRepos } = registry;
|
||||
// `incompleteRepos` is dropped on this surface only because the two lists it
|
||||
// is derived from are returned verbatim right below; the truncation triple is
|
||||
// the part that has no other channel here.
|
||||
const { incompleteRepos: _incompleteRepos, ...truncation } = crossRepoCompleteness({
|
||||
unreadableRepos,
|
||||
missingRepos,
|
||||
// An unrecorded `unreadableRepos` means this listing cannot say which
|
||||
// repos the sync failed to read — so it cannot claim to be complete.
|
||||
provenanceUnknown: unreadableRepos === undefined,
|
||||
// A contract LISTING declares no scope to intersect with: it is the whole
|
||||
// registry, so every configured repo is in scope by construction. The
|
||||
// `type`/`repo`/`unmatchedOnly` filters above narrow which rows are shown,
|
||||
// not which repos the sync had to read to produce them.
|
||||
inScope: () => true,
|
||||
});
|
||||
const out: Record<string, unknown> = {
|
||||
contracts,
|
||||
crossLinks: registry.crossLinks,
|
||||
missingRepos,
|
||||
// Omitted rather than `[]` when the registry never recorded it — the same
|
||||
// convention `skippedCorrupt` follows below, and the difference between
|
||||
// "the sync measured zero unreadable repos" and "the sync never said".
|
||||
...(unreadableRepos ? { unreadableRepos } : {}),
|
||||
// The structured triple, verbatim from the impact surface (KTD10):
|
||||
// `truncated` always, `truncationReason` + `riskEpistemic` with it.
|
||||
...truncation,
|
||||
};
|
||||
if (skippedCorrupt > 0) out.skippedCorrupt = skippedCorrupt;
|
||||
return out;
|
||||
}
|
||||
|
|
@ -573,17 +672,80 @@ export class GroupService {
|
|||
}
|
||||
const registry = await readContractRegistry(groupDir);
|
||||
|
||||
/**
|
||||
* The STRICT global-registry read, deliberately — this is the one caller
|
||||
* that has to tell "the registry says nothing about this repo" apart from
|
||||
* "the registry could not be read at all", and only the strict mode can.
|
||||
* `readRegistry`'s `catch { return [] }` collapses a malformed registry
|
||||
* into an empty one, which is indistinguishable from a genuine absence and
|
||||
* would report every configured repo as having no entry — the exact
|
||||
* conflation the two labels below exist to remove.
|
||||
*
|
||||
* The consequence is accepted knowingly: the strict read rejects the WHOLE
|
||||
* registry when any single row fails to identify a repo, so one malformed
|
||||
* row renders every member of the group unresolvable, including members
|
||||
* whose own rows are fine. That is the honest verdict — a registry the
|
||||
* resolver cannot trust row-wise cannot be trusted about any row — and it
|
||||
* is reported as an unresolved state, never as a clean one.
|
||||
*
|
||||
* ENOENT is not a failure in either mode: no registry file genuinely means
|
||||
* nothing has been registered yet, so every repo is legitimately missing.
|
||||
*/
|
||||
let registryEntries: RegistryEntry[] | null = null;
|
||||
let registryReadError: string | null = null;
|
||||
try {
|
||||
registryEntries = await readRegistryStrict();
|
||||
} catch (err) {
|
||||
registryReadError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
const repoStatuses: Record<
|
||||
string,
|
||||
{
|
||||
indexStale: boolean;
|
||||
contractsStale: boolean;
|
||||
/**
|
||||
* Unchanged meaning: this repo has no usable status. It stays `true`
|
||||
* for BOTH failures below, so a consumer written before the split
|
||||
* still sees every unusable repo flagged. Reporting an unresolvable
|
||||
* repo as `missing: false` would hand that consumer `indexStale:
|
||||
* false` for a repo nothing was ever read from — a false all-clear.
|
||||
*/
|
||||
missing: boolean;
|
||||
/**
|
||||
* Which failure `missing` means: `false` is a genuine registry miss,
|
||||
* `true` is an entry the resolver could not turn into a repo. Additive
|
||||
* — always present on every row, so an agent can branch on it without
|
||||
* having to treat an absent key as either answer.
|
||||
*/
|
||||
unresolvable: boolean;
|
||||
/** Set only when `unresolvable`; says what could not be resolved. */
|
||||
unresolvableReason?: string;
|
||||
commitsBehind?: number;
|
||||
}
|
||||
> = {};
|
||||
|
||||
for (const [repoPath, registryName] of Object.entries(config.repos)) {
|
||||
if (registryEntries === null) {
|
||||
repoStatuses[repoPath] = {
|
||||
indexStale: false,
|
||||
contractsStale: false,
|
||||
missing: true,
|
||||
unresolvable: true,
|
||||
unresolvableReason: `the global registry could not be read: ${registryReadError}`,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
// Only `resolveRepo` is inside the try that produces the
|
||||
// "did not resolve" label, so the label is earned rather than assumed.
|
||||
// `loadMeta` and `checkStaleness` cannot throw — the first returns null on
|
||||
// every error, the second catches everything — but the reading below them
|
||||
// can, and did: `registry.repoSnapshots` is read off a bare
|
||||
// `JSON.parse(...) as ContractRegistry` with no shape check, so a
|
||||
// contracts.json missing that field threw a TypeError into this catch and
|
||||
// reported every repo as an unresolvable GLOBAL-registry entry. That sent
|
||||
// the operator to repair the wrong file. The optional chain below closes
|
||||
// the crash; this split stops the next one being mislabelled the same way.
|
||||
try {
|
||||
const repoObj = await this.port.resolveRepo(registryName);
|
||||
const meta: Partial<Pick<RepoMeta, 'lastCommit' | 'indexedAt'>> =
|
||||
|
|
@ -593,7 +755,7 @@ export class GroupService {
|
|||
? checkStaleness(repoObj.repoPath, meta.lastCommit)
|
||||
: { isStale: true, commitsBehind: -1 };
|
||||
|
||||
const snapshot = registry?.repoSnapshots[repoPath];
|
||||
const snapshot = registry?.repoSnapshots?.[repoPath];
|
||||
const contractsStale =
|
||||
snapshot && meta.indexedAt ? snapshot.indexedAt !== meta.indexedAt : !snapshot;
|
||||
|
||||
|
|
@ -601,17 +763,45 @@ export class GroupService {
|
|||
indexStale: staleness.isStale,
|
||||
contractsStale: Boolean(contractsStale),
|
||||
missing: false,
|
||||
unresolvable: false,
|
||||
commitsBehind: staleness.commitsBehind,
|
||||
};
|
||||
} catch {
|
||||
repoStatuses[repoPath] = { indexStale: false, contractsStale: false, missing: true };
|
||||
} catch (err) {
|
||||
// The registry read succeeded, so its answer about this row is
|
||||
// trustworthy: a row that is there and still would not resolve is a
|
||||
// different fact from a row that was never there, and the operator's
|
||||
// next move differs (repair the entry vs. index the repo).
|
||||
const known = registryIdentifies(registryEntries, registryName);
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
repoStatuses[repoPath] = {
|
||||
indexStale: false,
|
||||
contractsStale: false,
|
||||
missing: true,
|
||||
unresolvable: known,
|
||||
...(known
|
||||
? { unresolvableReason: `registry entry "${registryName}" did not resolve: ${reason}` }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
group: name,
|
||||
lastSync: registry?.generatedAt || null,
|
||||
missingRepos: registry?.missingRepos || [],
|
||||
// `readContractRegistry` is a bare `JSON.parse(...) as ContractRegistry`,
|
||||
// so both of these are whatever the file happened to hold — the
|
||||
// validation in `loadContractRegistryResilient` never runs on this path.
|
||||
// A `contracts.json` carrying a string here reached `cli/group.ts` and
|
||||
// died in `.join(', ')`, i.e. an unreadable registry crashing the command
|
||||
// whose job is to explain unreadable things.
|
||||
//
|
||||
// `missingRepos` has always been required, so there is no "not recorded"
|
||||
// state to preserve for it — an unreadable value degrades to empty.
|
||||
missingRepos: recordedRepoList(registry?.missingRepos) ?? [],
|
||||
// `unreadableRepos` does have one: absent means "not recorded", not
|
||||
// "none" (see ContractRegistry), and a value we could not read is equally
|
||||
// unrecorded. Reporting either as an empty list is the same conflation.
|
||||
unreadableRepos: recordedRepoList(registry?.unreadableRepos),
|
||||
repos: repoStatuses,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import * as os from 'node:os';
|
|||
import type { ContractRegistry } from './types.js';
|
||||
import { writeFileAtomic } from '../../storage/fs-atomic.js';
|
||||
|
||||
const CONTRACTS_FILE = 'contracts.json';
|
||||
export const CONTRACTS_FILE = 'contracts.json';
|
||||
|
||||
export function getDefaultGitnexusDir(): string {
|
||||
return process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus');
|
||||
|
|
@ -30,6 +30,11 @@ export function getGroupDir(gitnexusDir: string, groupName: string): string {
|
|||
return path.join(gitnexusDir, 'groups', groupName);
|
||||
}
|
||||
|
||||
/** The registry path, so callers that stat or watch the file do not respell its name. */
|
||||
export function getContractRegistryPath(groupDir: string): string {
|
||||
return path.join(groupDir, CONTRACTS_FILE);
|
||||
}
|
||||
|
||||
export async function writeContractRegistry(
|
||||
groupDir: string,
|
||||
registry: ContractRegistry,
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -100,7 +100,20 @@ export interface ContractRegistry {
|
|||
version: number;
|
||||
generatedAt: string;
|
||||
repoSnapshots: Record<string, RepoSnapshot>;
|
||||
/** Configured repos with no entry in the registry. */
|
||||
missingRepos: string[];
|
||||
/**
|
||||
* Configured repos that ARE registered but that this sync could not extract
|
||||
* from — the index would not open (version skew, lock, corruption), or an
|
||||
* extractor threw partway through. The two are one bucket because the
|
||||
* consequence is one thing: NONE of that repo's contracts are in this
|
||||
* registry. Distinct from `missingRepos`, which is "no entry in the
|
||||
* registry at all" and needs a different answer from the operator.
|
||||
*
|
||||
* Optional so a registry written before this field existed still parses —
|
||||
* absent means "not recorded", not "none".
|
||||
*/
|
||||
unreadableRepos?: string[];
|
||||
contracts: StoredContract[];
|
||||
crossLinks: CrossLink[];
|
||||
}
|
||||
|
|
@ -117,8 +130,24 @@ export interface RepoHandle {
|
|||
storagePath: string;
|
||||
}
|
||||
|
||||
/** Why local impact or fan-out stopped early (e.g. wall-clock budget exhausted). */
|
||||
export type GroupImpactTruncationReason = 'timeout' | 'partial';
|
||||
/**
|
||||
* Why local impact or fan-out stopped early (e.g. wall-clock budget exhausted).
|
||||
*
|
||||
* `'timeout'` and `'partial'` are runtime limits — the same query can succeed on
|
||||
* a retry. `'incomplete-sync'` is structural: the bridge itself was built from a
|
||||
* sync that could not read every configured repo, so those repos' contracts are
|
||||
* absent from every query against it until `gitnexus group sync` succeeds.
|
||||
*
|
||||
* A runtime array rather than a bare type union: every value here has to be
|
||||
* explained on the agent-facing surface that returns it, and only an enumerable
|
||||
* list lets a guard test assert that. A test that hand-lists the members passes
|
||||
* forever once a fourth is added — which is the exact drift the guard exists to
|
||||
* catch, so the list an agent is promised and the list the code can emit have
|
||||
* to come from the same place.
|
||||
*/
|
||||
export const GROUP_IMPACT_TRUNCATION_REASONS = ['timeout', 'partial', 'incomplete-sync'] as const;
|
||||
|
||||
export type GroupImpactTruncationReason = (typeof GROUP_IMPACT_TRUNCATION_REASONS)[number];
|
||||
|
||||
export interface GroupImpactResult {
|
||||
local: unknown;
|
||||
|
|
@ -222,5 +251,110 @@ export interface BridgeHandle {
|
|||
export interface BridgeMeta {
|
||||
version: number;
|
||||
generatedAt: string;
|
||||
/**
|
||||
* Size and mtime of the `bridge.lbug` this metadata was written for, so a
|
||||
* reader can tell whether the two still belong together.
|
||||
*
|
||||
* `writeBridge` replaces the database and writes this file as two operations;
|
||||
* a sync that stops between them leaves the PREVIOUS sync's metadata beside a
|
||||
* new database, and `runGroupImpact` reads completeness from that metadata.
|
||||
* Stamping the pair is what lets `bridgeMetaMatchesFile` reject the mismatch
|
||||
* without anything having to be deleted — deleting the old metadata up front
|
||||
* would lose it permanently on a swap that fails with the old database still
|
||||
* in place, which is a normal Windows outcome when a read-only handle is held.
|
||||
*
|
||||
* Optional: metadata written before this existed carries no stamp. Such a
|
||||
* file is not waved through — `bridgeMetaMatchesFile` falls back to comparing
|
||||
* the two files' modification times, since a successful write orders the
|
||||
* database rename before the metadata write and a database NEWER than the
|
||||
* metadata beside it therefore cannot be the one it describes.
|
||||
*
|
||||
* That fallback proves WRITE ORDER, not provenance, and is wrong in both
|
||||
* directions — a non-monotonic clock can make a mis-paired set read as
|
||||
* ordered, and any copy or restore that rewrites the database's times after
|
||||
* the metadata's demotes an intact legacy pair to a lower bound until the
|
||||
* next sync re-stamps it. A stamped pair never reaches that fallback, which
|
||||
* is the reason to prefer stamping over widening the heuristic. Both
|
||||
* directions are spelled out at `bridgeMetaMatchesFile`.
|
||||
*/
|
||||
bridgeSize?: number;
|
||||
bridgeMtimeMs?: number;
|
||||
/**
|
||||
* Reader-side only: true when `meta.json` parsed but one of its repo lists
|
||||
* held a value that was not a list of repo paths.
|
||||
*
|
||||
* NEVER PERSISTED. `readBridgeMeta` sets it to describe what it found in the
|
||||
* file; `writeBridgeMeta`'s only caller builds a fresh literal, so it cannot
|
||||
* round-trip back to disk. It lives on this interface rather than on a
|
||||
* reader-only subtype so that `readBridgeMeta` keeps the exact signature
|
||||
* every caller already compiles against.
|
||||
*
|
||||
* The unusable value is dropped rather than normalized, so `missingRepos: []`
|
||||
* on such a result is inert filler — this flag, not the empty list, is what
|
||||
* says the bridge's provenance is unknown.
|
||||
*/
|
||||
repoListsUnreadable?: boolean;
|
||||
/**
|
||||
* Reader-side only: did this metadata pair with the `bridge.lbug` beside it,
|
||||
* measured BEFORE anything opened that database?
|
||||
*
|
||||
* NEVER PERSISTED, for the same reason as `repoListsUnreadable`.
|
||||
*
|
||||
* The measurement has to happen before the open, and the answer has to be
|
||||
* carried rather than recomputed. `runGroupImpact` and `runGroupTrace` open
|
||||
* the bridge and only then ask about provenance, so a platform where a
|
||||
* read-only open advances the database's mtime would fail every unstamped
|
||||
* pair the moment it was read — turning back-compat for pre-stamp bridges
|
||||
* into a repo-wide "everything is a lower bound". Whether any given
|
||||
* LadybugDB build and OS does that is not something a reader should have to
|
||||
* know, and it cannot be observed on Windows, where the in-process
|
||||
* write→read reopen this would need is a documented limitation. Ordering the
|
||||
* check ahead of the open makes the question moot on every platform instead
|
||||
* of true on the ones that happen to be testable.
|
||||
*/
|
||||
pairedWithDatabase?: boolean;
|
||||
/**
|
||||
* PERSISTED, unlike the two fields above: the writer of this metadata could
|
||||
* not establish that it describes the `bridge.lbug` beside it, and no reader
|
||||
* may conclude otherwise from the files alone.
|
||||
*
|
||||
* Written by `refreshPreservedBridgeMeta` — the preserve path in `syncGroup`,
|
||||
* which refreshes the diagnostic lists of a bridge it deliberately does NOT
|
||||
* rebuild. That refresh rewrites `meta.json` ATOMICALLY, so this file's mtime
|
||||
* becomes now while the database's stays old; and "metadata newer than the
|
||||
* database beside it" is exactly the write order that
|
||||
* `unstampedMetaPairsByWriteOrder` accepts. A refresh that simply carried the
|
||||
* old fields forward would therefore convert a pair that check had been
|
||||
* REJECTING into one it waves through — laundering unknown provenance into
|
||||
* verified provenance, which is the fail-open this whole channel exists to
|
||||
* close.
|
||||
*
|
||||
* "Just don't write a stamp" is not a substitute, and is worse: an unstamped
|
||||
* metadata file is judged on the two file times, and the refresh has already
|
||||
* moved them into the accepting order. The verdict has to be recorded IN the
|
||||
* file, because the write that records it is itself what destroys the
|
||||
* evidence a reader would otherwise use.
|
||||
*
|
||||
* `bridgeMetaMatchesFile` rejects on this ahead of both the stamp and the
|
||||
* write-order heuristic, so `ensureBridgeReady` answers
|
||||
* `pairedWithDatabase: false` and `bridgeProvenanceUnknown` reports the
|
||||
* cross-repo answer as a lower bound. That is the ONE enforcement point; do
|
||||
* not add a second reader for this field.
|
||||
*
|
||||
* Self-clearing: a successful `writeBridge` builds fresh metadata from a
|
||||
* literal and never sets it, so the next good sync retires the marker without
|
||||
* anything having to delete it.
|
||||
*/
|
||||
provenanceUnknown?: boolean;
|
||||
missingRepos: string[];
|
||||
/**
|
||||
* Configured repos the sync that produced this bridge could not extract from
|
||||
* (see `ContractRegistry.unreadableRepos`). Their contracts and every
|
||||
* cross-link touching them are absent from `bridge.lbug`, so a cross-repo
|
||||
* impact query against this bridge is a lower bound, not a verdict —
|
||||
* `runGroupImpact` folds a non-empty value into its truncation fields for
|
||||
* exactly that reason.
|
||||
* Optional: a bridge written before this field existed does not record it.
|
||||
*/
|
||||
unreadableRepos?: string[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,13 +49,29 @@ export function resolvePhpImportInternal(
|
|||
|
||||
if (composerConfig) {
|
||||
const sorted = getSortedPsr4(composerConfig);
|
||||
const authoritativePsr4 =
|
||||
composerConfig.authoritativePsr4 ?? new Set(sorted.map(([namespace]) => namespace));
|
||||
let matchedAuthoritativeNamespace = false;
|
||||
let hasAuthoritativeCatchAllNamespace = false;
|
||||
const ownershipPath = normalized.replace(/^\/+/, '');
|
||||
|
||||
for (const [nsPrefix, dirPrefix] of sorted) {
|
||||
const nsPrefixSlash = nsPrefix.replace(/\\/g, '/');
|
||||
if (normalized.startsWith(nsPrefixSlash + '/') || normalized === nsPrefixSlash) {
|
||||
const remainder = normalized.slice(nsPrefixSlash.length).replace(/^\//, '');
|
||||
const nsPrefixSlash = nsPrefix.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
const isCatchAll = nsPrefixSlash === '';
|
||||
if (
|
||||
isCatchAll ||
|
||||
ownershipPath.startsWith(nsPrefixSlash + '/') ||
|
||||
ownershipPath === nsPrefixSlash
|
||||
) {
|
||||
const isAuthoritative = authoritativePsr4.has(nsPrefix);
|
||||
matchedAuthoritativeNamespace ||= isAuthoritative;
|
||||
hasAuthoritativeCatchAllNamespace ||= isAuthoritative && isCatchAll;
|
||||
const remainder = ownershipPath.slice(nsPrefixSlash.length).replace(/^\//, '');
|
||||
|
||||
// 1. Try class-style PSR-4: full path → file (e.g. App\Models\User → app/Models/User.php)
|
||||
const filePath = dirPrefix + (remainder ? '/' + remainder : '') + '.php';
|
||||
const mappedPath =
|
||||
dirPrefix === '' ? remainder : dirPrefix + (remainder ? '/' + remainder : '');
|
||||
const filePath = mappedPath + '.php';
|
||||
if (allFiles.has(filePath)) return filePath;
|
||||
if (index) {
|
||||
const result = index.getInsensitive(filePath);
|
||||
|
|
@ -64,45 +80,64 @@ export function resolvePhpImportInternal(
|
|||
|
||||
// 2. Function/constant fallback: strip last segment (symbol name), scan namespace directory.
|
||||
// e.g. App\Models\getUser → directory app/Models/, find first .php file in that dir.
|
||||
const lastSlash = remainder.lastIndexOf('/');
|
||||
const nsDir = lastSlash >= 0 ? dirPrefix + '/' + remainder.slice(0, lastSlash) : dirPrefix;
|
||||
// A root/catch-all mapping cannot safely infer a symbol's declaring
|
||||
// file from an arbitrary sibling. The higher-level PHP resolver has
|
||||
// parsed symbol-kind and declaration evidence for function/const
|
||||
// imports; class imports must not inherit this directory heuristic.
|
||||
if (!isCatchAll && dirPrefix !== '') {
|
||||
const lastSlash = remainder.lastIndexOf('/');
|
||||
const relativeNamespace = lastSlash >= 0 ? remainder.slice(0, lastSlash) : '';
|
||||
const nsDir = relativeNamespace === '' ? dirPrefix : `${dirPrefix}/${relativeNamespace}`;
|
||||
|
||||
// Prefer SuffixIndex directory lookup (O(log n + matches)) over linear scan.
|
||||
//
|
||||
// An EMPTY bucket is a final answer, not a miss to retry with the scan
|
||||
// below — which is what the `else` restores, and what this comment
|
||||
// always claimed. Re-scanning on empty was the last per-import
|
||||
// workspace traversal left in PHP resolution after #2901: any `use`
|
||||
// matching a PSR-4 prefix whose directory holds no direct `.php` child
|
||||
// (`App\Legacy\Ghost`) paid a full pass, measured at 201 traversals for
|
||||
// 200 imports.
|
||||
//
|
||||
// The bucket is a superset of what the scan can find, for BOTH index
|
||||
// shapes that reach here. A root-anchored direct child `nsDir/<x>.php`
|
||||
// has its directory exactly equal to `nsDir`, and `nsDir` is always one
|
||||
// of that directory's own suffixes — so the shared `dirMap` (keyed on
|
||||
// every directory suffix) necessarily contains it, as does the
|
||||
// root-anchored parity index `languages/php/import-target.ts` builds.
|
||||
// Empty superset therefore implies empty scan, and control falls
|
||||
// through to the next PSR-4 prefix exactly as before.
|
||||
if (index) {
|
||||
const candidates = index.getFilesInDir(nsDir, '.php');
|
||||
if (candidates.length > 0) return candidates[0];
|
||||
} else {
|
||||
// Linear scan, only when a SuffixIndex is genuinely unavailable.
|
||||
const nsDirPrefix = nsDir.endsWith('/') ? nsDir : nsDir + '/';
|
||||
for (const f of allFiles) {
|
||||
if (
|
||||
f.startsWith(nsDirPrefix) &&
|
||||
f.endsWith('.php') &&
|
||||
!f.slice(nsDirPrefix.length).includes('/')
|
||||
) {
|
||||
return f;
|
||||
// Prefer SuffixIndex directory lookup (O(log n + matches)) over linear scan.
|
||||
//
|
||||
// An EMPTY bucket is a final answer, not a miss to retry with the scan
|
||||
// below — which is what the `else` restores, and what this comment
|
||||
// always claimed. Re-scanning on empty was the last per-import
|
||||
// workspace traversal left in PHP resolution after #2901: any `use`
|
||||
// matching a PSR-4 prefix whose directory holds no direct `.php` child
|
||||
// (`App\Legacy\Ghost`) paid a full pass, measured at 201 traversals for
|
||||
// 200 imports.
|
||||
//
|
||||
// The bucket is a superset of what the scan can find, for BOTH index
|
||||
// shapes that reach here. A root-anchored direct child `nsDir/<x>.php`
|
||||
// has its directory exactly equal to `nsDir`, and `nsDir` is always one
|
||||
// of that directory's own suffixes — so the shared `dirMap` (keyed on
|
||||
// every directory suffix) necessarily contains it, as does the
|
||||
// root-anchored parity index `languages/php/import-target.ts` builds.
|
||||
// Empty superset therefore implies empty scan, and control falls
|
||||
// through to the next PSR-4 prefix exactly as before.
|
||||
if (index) {
|
||||
const candidates = index.getFilesInDir(nsDir, '.php');
|
||||
if (candidates.length > 0) return candidates[0];
|
||||
} else {
|
||||
// Linear scan, only when a SuffixIndex is genuinely unavailable.
|
||||
const nsDirPrefix = nsDir.endsWith('/') ? nsDir : nsDir + '/';
|
||||
for (const f of allFiles) {
|
||||
if (
|
||||
f.startsWith(nsDirPrefix) &&
|
||||
f.endsWith('.php') &&
|
||||
!f.slice(nsDirPrefix.length).includes('/')
|
||||
) {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A non-empty PSR-4 map is authoritative for namespaces it does not own.
|
||||
// Preserve the existing mapped-namespace fallback behavior; #2962 is the
|
||||
// conservative external-namespace gate, not a rewrite of mapped lookup.
|
||||
// A catch-all owns every namespace, so its misses remain authoritative.
|
||||
if (
|
||||
authoritativePsr4.size > 0 &&
|
||||
!composerConfig.hasUnmodeledAutoload &&
|
||||
(!matchedAuthoritativeNamespace || hasAuthoritativeCatchAllNamespace)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: suffix matching (works without composer.json)
|
||||
|
|
|
|||
|
|
@ -30,11 +30,103 @@ export interface GoModuleConfig {
|
|||
export interface ComposerConfig {
|
||||
/** Map of namespace prefix -> directory (e.g., "App\\" -> "app/") */
|
||||
psr4: Map<string, string>;
|
||||
/** Production `autoload.psr-4` prefixes that may gate external namespaces.
|
||||
* Absent on legacy/manual configs, where every mapping remains authoritative. */
|
||||
authoritativePsr4?: ReadonlySet<string>;
|
||||
/** True when Composer also declares an autoload mechanism this resolver does not model. */
|
||||
hasUnmodeledAutoload?: boolean;
|
||||
/** PSR-4 entries sorted by namespace length descending (longest match wins).
|
||||
* Cached once at config load time to avoid re-sorting on every import. */
|
||||
psr4Sorted?: readonly [string, string][];
|
||||
}
|
||||
|
||||
function normalizeComposerDirectory(baseDir: string, directory: string): string {
|
||||
const normalizedBase = baseDir.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
|
||||
const normalizedDirectory = directory
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^(?:\.\/)+/, '')
|
||||
.replace(/\/+$/, '');
|
||||
if (normalizedBase === '') return normalizedDirectory;
|
||||
if (normalizedDirectory === '') return normalizedBase;
|
||||
return path.posix.normalize(`${normalizedBase}/${normalizedDirectory}`);
|
||||
}
|
||||
|
||||
/** Parse one Composer manifest without performing I/O. */
|
||||
export function parseComposerConfig(value: unknown, baseDir = ''): ComposerConfig | null {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
|
||||
|
||||
const composer = value as Record<string, unknown>;
|
||||
const autoload = composer.autoload;
|
||||
const autoloadDev = composer['autoload-dev'];
|
||||
if (autoload === undefined && autoloadDev === undefined) return null;
|
||||
|
||||
const psr4 = new Map<string, string>();
|
||||
const authoritativePsr4 = new Set<string>();
|
||||
let hasUnmodeledAutoload = false;
|
||||
|
||||
const addSection = (sectionValue: unknown, authoritative: boolean): void => {
|
||||
if (typeof sectionValue !== 'object' || sectionValue === null || Array.isArray(sectionValue)) {
|
||||
return;
|
||||
}
|
||||
const section = sectionValue as Record<string, unknown>;
|
||||
if ('psr-0' in section || 'classmap' in section) hasUnmodeledAutoload = true;
|
||||
|
||||
const rawPsr4 = section['psr-4'];
|
||||
if (typeof rawPsr4 !== 'object' || rawPsr4 === null || Array.isArray(rawPsr4)) return;
|
||||
|
||||
for (const [namespace, directories] of Object.entries(rawPsr4)) {
|
||||
const stringDirectories = Array.isArray(directories)
|
||||
? directories.filter((entry): entry is string => typeof entry === 'string')
|
||||
: typeof directories === 'string'
|
||||
? [directories]
|
||||
: [];
|
||||
if (stringDirectories.length === 0) continue;
|
||||
if (stringDirectories.length > 1) hasUnmodeledAutoload = true;
|
||||
|
||||
const normalizedNamespace = namespace.replace(/\\+$/, '');
|
||||
const normalizedDirectory = normalizeComposerDirectory(baseDir, stringDirectories[0]);
|
||||
const existing = psr4.get(normalizedNamespace);
|
||||
if (existing !== undefined && existing !== normalizedDirectory) {
|
||||
hasUnmodeledAutoload = true;
|
||||
continue;
|
||||
}
|
||||
if (existing === undefined) psr4.set(normalizedNamespace, normalizedDirectory);
|
||||
if (authoritative) authoritativePsr4.add(normalizedNamespace);
|
||||
}
|
||||
};
|
||||
|
||||
// Production mappings win duplicate prefixes. Development mappings remain
|
||||
// usable for test code but do not establish authority for the external gate.
|
||||
addSection(autoload, true);
|
||||
addSection(autoloadDev, false);
|
||||
|
||||
return { psr4, authoritativePsr4, hasUnmodeledAutoload };
|
||||
}
|
||||
|
||||
/** Merge package-local Composer manifests into one repository-relative config. */
|
||||
export function mergeComposerConfigs(configs: readonly ComposerConfig[]): ComposerConfig | null {
|
||||
if (configs.length === 0) return null;
|
||||
|
||||
const psr4 = new Map<string, string>();
|
||||
const authoritativePsr4 = new Set<string>();
|
||||
let hasUnmodeledAutoload = false;
|
||||
for (const config of configs) {
|
||||
hasUnmodeledAutoload ||= config.hasUnmodeledAutoload === true;
|
||||
for (const [namespace, directory] of config.psr4) {
|
||||
const existing = psr4.get(namespace);
|
||||
if (existing !== undefined && existing !== directory) {
|
||||
hasUnmodeledAutoload = true;
|
||||
continue;
|
||||
}
|
||||
if (existing === undefined) psr4.set(namespace, directory);
|
||||
}
|
||||
for (const namespace of config.authoritativePsr4 ?? config.psr4.keys()) {
|
||||
authoritativePsr4.add(namespace);
|
||||
}
|
||||
}
|
||||
return { psr4, authoritativePsr4, hasUnmodeledAutoload };
|
||||
}
|
||||
|
||||
/** C# project config parsed from .csproj files */
|
||||
export interface CSharpProjectConfig {
|
||||
/** Root namespace from <RootNamespace> or assembly name (default: project directory name) */
|
||||
|
|
@ -161,22 +253,13 @@ export async function loadComposerConfig(repoRoot: string): Promise<ComposerConf
|
|||
try {
|
||||
const composerPath = path.join(repoRoot, 'composer.json');
|
||||
const raw = await fs.readFile(composerPath, 'utf-8');
|
||||
const composer = JSON.parse(raw);
|
||||
const psr4Raw = composer.autoload?.['psr-4'] ?? {};
|
||||
const psr4Dev = composer['autoload-dev']?.['psr-4'] ?? {};
|
||||
const merged = { ...psr4Raw, ...psr4Dev };
|
||||
|
||||
const psr4 = new Map<string, string>();
|
||||
for (const [ns, dir] of Object.entries(merged)) {
|
||||
const nsNorm = (ns as string).replace(/\\+$/, '');
|
||||
const dirNorm = (dir as string).replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
psr4.set(nsNorm, dirNorm);
|
||||
}
|
||||
const config = parseComposerConfig(JSON.parse(raw));
|
||||
if (config === null) return null;
|
||||
|
||||
if (isDev) {
|
||||
logger.info(`📦 Loaded ${psr4.size} PSR-4 mappings from composer.json`);
|
||||
logger.info(`📦 Loaded ${config.psr4.size} PSR-4 mappings from composer.json`);
|
||||
}
|
||||
return { psr4 };
|
||||
return config;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,11 @@ import type { CfgVisitor } from './cfg/types.js';
|
|||
import type { NodeLabel } from 'gitnexus-shared';
|
||||
import type { ExtractedRoute } from './route-extractors/laravel.js';
|
||||
import type { SharedSpringType } from './route-extractors/spring-shared.js';
|
||||
import type {
|
||||
ModuleConstants,
|
||||
Operand,
|
||||
RepoConstants,
|
||||
} from './route-extractors/constant-resolver.js';
|
||||
import type Parser from 'tree-sitter';
|
||||
import type { ExtractedDecoratorRoute } from './workers/parse-worker.js';
|
||||
|
||||
|
|
@ -46,6 +51,44 @@ import type { ExtractedDecoratorRoute } from './workers/parse-worker.js';
|
|||
/** Tree-sitter query captures: capture name → AST node (or undefined if not captured). */
|
||||
export type CaptureMap = Record<string, SyntaxNode | undefined>;
|
||||
|
||||
export interface DefinitionPropertiesContext {
|
||||
readonly nodeLabel: NodeLabel;
|
||||
readonly nodeName: string;
|
||||
readonly definitionNode: SyntaxNode;
|
||||
readonly parsedImports: readonly ParsedImport[];
|
||||
readonly isExported: boolean;
|
||||
}
|
||||
|
||||
export type DefinitionPropertiesExtractor = (
|
||||
context: DefinitionPropertiesContext,
|
||||
) => Readonly<Record<string, unknown>> | undefined;
|
||||
|
||||
/** Run optional provider enrichment without allowing one hook failure to drop
|
||||
* the rest of the worker's language batch. */
|
||||
export function runDefinitionPropertiesExtractor(
|
||||
extractor: DefinitionPropertiesExtractor,
|
||||
context: DefinitionPropertiesContext,
|
||||
onError: (error: unknown) => void,
|
||||
): Readonly<Record<string, unknown>> | undefined {
|
||||
try {
|
||||
return extractor(context);
|
||||
} catch (error) {
|
||||
onError(error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Provider metadata is additive; graph identity and source-location fields
|
||||
* supplied by the worker remain authoritative. */
|
||||
export function mergeCanonicalDefinitionProperties<
|
||||
TCanonical extends Readonly<Record<string, unknown>>,
|
||||
>(
|
||||
providerProperties: Readonly<Record<string, unknown>>,
|
||||
canonicalProperties: TCanonical,
|
||||
): Record<string, unknown> & TCanonical {
|
||||
return { ...providerProperties, ...canonicalProperties } as Record<string, unknown> & TCanonical;
|
||||
}
|
||||
|
||||
// ── Strategy tag types ─────────────────────────────────────────────────────
|
||||
// NOTE: `MroStrategy` is defined in `gitnexus-shared` and re-exported above
|
||||
// so `core/ingestion/model/resolve.ts` can consume it without importing from
|
||||
|
|
@ -64,6 +107,25 @@ export interface AstFrameworkPatternConfig {
|
|||
* Required fields must be explicitly set; optional fields have defaults
|
||||
* applied by defineLanguage().
|
||||
*/
|
||||
/**
|
||||
* Should the parse worker run {@link LanguageProviderConfig.extractModuleConstants}
|
||||
* on this file?
|
||||
*
|
||||
* Exported so the DECISION is testable without booting a worker. It encodes the
|
||||
* one rule that is easy to get backwards: a provider that declares no
|
||||
* `moduleConstantHeuristic` harvests unconditionally. Writing the gate as
|
||||
* `provider.moduleConstantHeuristic?.(content)` reads `undefined` as "skip" and
|
||||
* silently disables the hook for every provider without a heuristic — which is
|
||||
* exactly how Python's already-shipped harvest was turned off (#2391/#2980).
|
||||
*/
|
||||
export function shouldHarvestModuleConstants(
|
||||
provider: Pick<LanguageProvider, 'extractModuleConstants' | 'moduleConstantHeuristic'>,
|
||||
content: string,
|
||||
): boolean {
|
||||
if (!provider.extractModuleConstants) return false;
|
||||
return !provider.moduleConstantHeuristic || provider.moduleConstantHeuristic(content);
|
||||
}
|
||||
|
||||
interface LanguageProviderConfig {
|
||||
// ── Identity ──────────────────────────────────────────────────────
|
||||
readonly id: SupportedLanguages;
|
||||
|
|
@ -252,6 +314,10 @@ interface LanguageProviderConfig {
|
|||
* constant, and static declarations. Produces VariableInfo with type, visibility,
|
||||
* isConst, isStatic, isMutable metadata. Default: undefined (no variable extraction). */
|
||||
readonly variableExtractor?: VariableExtractor;
|
||||
/** Add language-owned, structured properties to a definition node. Values
|
||||
* cross the worker boundary and must therefore be structured-clone-safe.
|
||||
* Shared ingestion code treats these properties as opaque. */
|
||||
readonly definitionPropertiesExtractor?: DefinitionPropertiesExtractor;
|
||||
/** Class/type extractor for deriving canonical qualified names for class-like symbols.
|
||||
* Uses the same provider-driven strategy pattern as method/field extraction so
|
||||
* namespace/package/module rules stay language-specific. */
|
||||
|
|
@ -336,6 +402,58 @@ interface LanguageProviderConfig {
|
|||
filePath: string,
|
||||
) => SharedSpringType[];
|
||||
|
||||
/**
|
||||
* Harvest this file's module-level string constants (#2391 core, #2980 Java
|
||||
* parity) into the language-agnostic {@link ModuleConstants} shape, so the
|
||||
* parse phase can resolve non-literal decorator route paths cross-file.
|
||||
*
|
||||
* The worker calls this when BOTH hold:
|
||||
* - the provider declares no `moduleConstantHeuristic`, or the one it
|
||||
* declares matched — syntax-driven, e.g. a `static final String` field or
|
||||
* a constants-bearing import; NEVER a class-name pattern like
|
||||
* `*Constants`, which silently drops route constants living in classes
|
||||
* named e.g. `ApiPaths`/`Routes`, and
|
||||
* - the extraction yields something resolvable (a literal, an expression, or
|
||||
* an import binding), keeping the aggregate bounded on large repos.
|
||||
*
|
||||
* Default: undefined (no constant harvest; non-literal route paths of this
|
||||
* language floor to skip).
|
||||
*/
|
||||
readonly extractModuleConstants?: (tree: Parser.Tree) => ModuleConstants;
|
||||
|
||||
/**
|
||||
* Cheap content heuristic deciding whether the worker should run
|
||||
* {@link extractModuleConstants} on a file. Guards the harvest cost on huge
|
||||
* repos: files that cannot contribute (no constant-bearing syntax) are not
|
||||
* walked. Must be syntax-driven (field/import shape), not identifier
|
||||
* pattern-matching on class names.
|
||||
*
|
||||
* Default: undefined — harvest EVERY file of this language. A gate is opt-in
|
||||
* because getting it wrong silently drops routes that already resolve, and a
|
||||
* missed gate only costs time. Declare one only where the cost bites (Java's
|
||||
* Maven monorepos) and only after checking it against every shape
|
||||
* {@link extractModuleConstants} accepts.
|
||||
*/
|
||||
readonly moduleConstantHeuristic?: (content: string) => boolean;
|
||||
|
||||
/**
|
||||
* Fold one file's non-literal route-path operand list
|
||||
* (`routePathExpr`/`routePathOperands` of an `ExtractedDecoratorRoute`)
|
||||
* against the repo-wide, file-path-keyed constant map, or null when it cannot
|
||||
* be fully folded (skip floor — never a phantom path). Languages whose
|
||||
* qualified refs resolve through class imports (`Outer.CONST`,
|
||||
* `com.example.ApiPaths.USERS`) need this hook because the shared fold has no
|
||||
* notion of qualified names; Python's bare-name refs use the shared default.
|
||||
*
|
||||
* Default: undefined (the parse phase falls back to the shared
|
||||
* language-agnostic operand fold).
|
||||
*/
|
||||
readonly foldRoutePathOperands?: (
|
||||
filePath: string,
|
||||
operands: readonly Operand[],
|
||||
repo: RepoConstants,
|
||||
) => string | null;
|
||||
|
||||
// ── Noise filtering ────────────────────────────────────────────────
|
||||
/** Built-in/stdlib names that should be filtered from the call graph for this language.
|
||||
* Default: undefined (no language-specific filtering). */
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@ import type { AstFrameworkPatternConfig } from '../language-provider.js';
|
|||
import { createLeadingDocDescriptionExtractor } from '../utils/ast-helpers.js';
|
||||
import { javaTypeConfig } from '../type-extractors/jvm.js';
|
||||
import { extractSpringRoutes, extractSpringTypes } from '../route-extractors/spring.js';
|
||||
import {
|
||||
extractJavaModuleConstants,
|
||||
foldJavaOperands,
|
||||
isJavaConstantFile,
|
||||
} from '../route-extractors/java-const-resolver.js';
|
||||
import { javaExportChecker } from '../export-detection.js';
|
||||
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
|
||||
import { javaImportConfig } from '../import-resolvers/configs/jvm.js';
|
||||
|
|
@ -216,4 +221,26 @@ export const javaProvider = defineLanguage({
|
|||
// ── Route extraction ──
|
||||
extractDecoratorRoutes: extractSpringRoutes,
|
||||
extractRouteInheritanceTypes: extractSpringTypes,
|
||||
|
||||
// ── #2980: constant harvest + qualified-ref fold for non-literal mapping
|
||||
// paths (`@PostMapping(ApiPaths.SAVE_V1)`) — kept behind provider hooks so
|
||||
// the shared ingestion layers stay language-agnostic. The heuristic is
|
||||
// SYNTAX-driven (field/import shape), never a class-name pattern: constant
|
||||
// classes are routinely named `ApiPaths`/`Routes`/`Paths`, which a
|
||||
// `*Constants`-style gate would silently drop (review round-2 High finding).
|
||||
extractModuleConstants: extractJavaModuleConstants,
|
||||
// One gate, shared with the group side's `prepareRepo` pre-pass so the two
|
||||
// subsystems cannot disagree about which files define constants (see
|
||||
// JAVA_CONSTANT_FILE_RE — the previous divergence dropped constant
|
||||
// INTERFACES on this side only, which cost the graph its Route nodes while
|
||||
// the group still published the contract).
|
||||
moduleConstantHeuristic: (content) =>
|
||||
isJavaConstantFile(content) ||
|
||||
// `import com.winning.opt.common.ApiPaths;` — ANY class import can bind a
|
||||
// constant ref (`ApiPaths.X` at an annotation site), so gate on the
|
||||
// general import shape, not on the imported name. Ingestion-only: this
|
||||
// side needs the importing controller's own import table, which the group
|
||||
// side instead derives lazily from the tree it already holds.
|
||||
/\bimport\s+(?:static\s+)?[\w.]+\s*;/.test(content),
|
||||
foldRoutePathOperands: foldJavaOperands,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,9 +21,13 @@ import { resolvePhpImportInternal } from '../../import-resolvers/php.js';
|
|||
import type { SuffixIndex } from '../../import-resolvers/utils.js';
|
||||
import { perFileSet } from '../../import-resolvers/per-file-set.js';
|
||||
import { getWorkspaceFileIndex } from '../../import-resolvers/workspace-file-index.js';
|
||||
import type { ComposerConfig } from '../../language-config.js';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
mergeComposerConfigs,
|
||||
parseComposerConfig,
|
||||
type ComposerConfig,
|
||||
} from '../../language-config.js';
|
||||
import { readdirSync, readFileSync, type Dirent } from 'node:fs';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
|
||||
export interface PhpResolveContext {
|
||||
readonly fromFile: string;
|
||||
|
|
@ -48,19 +52,18 @@ function namespaceDirectories(
|
|||
|
||||
if (composerConfig === null) return [...directories];
|
||||
|
||||
const normalizedTarget = normalizePhpPath(targetRaw);
|
||||
const normalizedTarget = normalizePhpPath(targetRaw).replace(/^\/+/, '');
|
||||
const mappings = [...composerConfig.psr4.entries()].sort((left, right) => {
|
||||
const lengthDifference = right[0].length - left[0].length;
|
||||
return lengthDifference !== 0 ? lengthDifference : left[0].localeCompare(right[0]);
|
||||
});
|
||||
for (const [namespacePrefix, directoryPrefix] of mappings) {
|
||||
const normalizedPrefix = normalizePhpPath(namespacePrefix);
|
||||
if (
|
||||
normalizedTarget !== normalizedPrefix &&
|
||||
!normalizedTarget.startsWith(`${normalizedPrefix}/`)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const matchesNamespace =
|
||||
normalizedPrefix === '' ||
|
||||
normalizedTarget === normalizedPrefix ||
|
||||
normalizedTarget.startsWith(`${normalizedPrefix}/`);
|
||||
if (!matchesNamespace) continue;
|
||||
|
||||
const remainder = normalizedTarget.slice(normalizedPrefix.length).replace(/^\//, '');
|
||||
const separator = remainder.lastIndexOf('/');
|
||||
|
|
@ -82,21 +85,11 @@ function parentDirectory(filePath: string): string {
|
|||
}
|
||||
|
||||
function directoryAliases(filePath: string): string[] {
|
||||
const normalizedPath = normalizePhpPath(filePath);
|
||||
const separator = normalizedPath.lastIndexOf('/');
|
||||
if (separator < 0) return [''];
|
||||
|
||||
const parent = normalizedPath.slice(0, separator);
|
||||
const aliases = new Set([parent]);
|
||||
const segments = parent.split('/').filter(Boolean);
|
||||
for (let index = 0; index < segments.length; index++) {
|
||||
aliases.add(segments.slice(index).join('/'));
|
||||
}
|
||||
return [...aliases];
|
||||
return [parentDirectory(filePath)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Directory alias → the files under it, built once per pass.
|
||||
* Exact repository-relative directory → the files under it, built once per pass.
|
||||
*
|
||||
* A scope-resolution pass shares one stable `parsedFiles` array across imports,
|
||||
* so the array identity is the memo key — see `perFileSet`.
|
||||
|
|
@ -302,42 +295,67 @@ const getPhpWorkspaceIndex = perFileSet((allFilePaths: ReadonlySet<string>): Php
|
|||
// ─── loadResolutionConfig ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load and parse `composer.json` from the repo root. Returns a
|
||||
* `ComposerConfig` object (PSR-4 namespace → directory mappings) or
|
||||
* `null` when no `composer.json` is present or it cannot be parsed.
|
||||
* Load and parse repository and package-local `composer.json` manifests.
|
||||
* Package mappings are rebased to repository-relative paths before merging.
|
||||
*
|
||||
* The result is threaded into each `resolvePhpImportInternal` call as
|
||||
* the `composerConfig` argument.
|
||||
*/
|
||||
export function loadPhpComposerConfig(repoPath: string): ComposerConfig | null {
|
||||
try {
|
||||
const composerPath = join(repoPath, 'composer.json');
|
||||
const raw = readFileSync(composerPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (typeof parsed !== 'object' || parsed === null) return null;
|
||||
const skipDirectories = new Set([
|
||||
'.git',
|
||||
'.gitnexus',
|
||||
'node_modules',
|
||||
'vendor',
|
||||
'dist',
|
||||
'build',
|
||||
'coverage',
|
||||
]);
|
||||
const pending = [repoPath];
|
||||
const manifests: string[] = [];
|
||||
let incomplete = false;
|
||||
let visitedDirectories = 0;
|
||||
|
||||
const composer = parsed as Record<string, unknown>;
|
||||
const autoload = composer['autoload'] as Record<string, unknown> | undefined;
|
||||
if (autoload === undefined) return null;
|
||||
|
||||
const psr4Raw = (autoload['psr-4'] ?? {}) as Record<string, string | string[]>;
|
||||
const psr4 = new Map<string, string>();
|
||||
|
||||
for (const [ns, dirs] of Object.entries(psr4Raw)) {
|
||||
// namespace prefix ends with `\` — keep as-is; resolver strips it
|
||||
const normalizedNs = ns.replace(/\\$/, '');
|
||||
const dir = Array.isArray(dirs) ? dirs[0] : dirs;
|
||||
if (typeof dir === 'string') {
|
||||
// Normalize directory path (strip trailing slash)
|
||||
const normalizedDir = dir.replace(/\/+$/, '');
|
||||
psr4.set(normalizedNs, normalizedDir);
|
||||
while (pending.length > 0) {
|
||||
const directory = pending.pop();
|
||||
if (directory === undefined) break;
|
||||
if (++visitedDirectories > 20_000) {
|
||||
incomplete = true;
|
||||
break;
|
||||
}
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = readdirSync(directory, { withFileTypes: true }).sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
);
|
||||
} catch {
|
||||
incomplete = true;
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && entry.name === 'composer.json') {
|
||||
manifests.push(join(directory, entry.name));
|
||||
} else if (entry.isDirectory() && !skipDirectories.has(entry.name)) {
|
||||
pending.push(join(directory, entry.name));
|
||||
}
|
||||
}
|
||||
|
||||
return { psr4 };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const configs: ComposerConfig[] = [];
|
||||
for (const manifest of manifests.sort()) {
|
||||
try {
|
||||
const baseDir = normalizePhpPath(relative(repoPath, dirname(manifest)));
|
||||
const config = parseComposerConfig(JSON.parse(readFileSync(manifest, 'utf8')), baseDir);
|
||||
if (config !== null) configs.push(config);
|
||||
} catch {
|
||||
incomplete = true;
|
||||
}
|
||||
}
|
||||
|
||||
const merged = mergeComposerConfigs(configs);
|
||||
if (merged === null) return null;
|
||||
if (incomplete) merged.hasUnmodeledAutoload = true;
|
||||
return merged;
|
||||
}
|
||||
|
||||
// ─── resolvePhpImportTarget ────────────────────────────────────────────────
|
||||
|
|
@ -434,11 +452,7 @@ export function resolvePhpImportTargetInternal(
|
|||
...new Set(
|
||||
directories.flatMap((directory) => {
|
||||
const files = directoryIndex.get(normalizePhpPath(directory)) ?? [];
|
||||
// A suffix alias can match directories under different roots (for
|
||||
// example app/Models and vendor/pkg/app/Models). Picking either root
|
||||
// would be a guess, so fail closed to the composer resolution instead.
|
||||
const distinctParents = new Set(files.map((file) => parentDirectory(file.filePath)));
|
||||
return distinctParents.size > 1 ? [] : files;
|
||||
return files;
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import {
|
|||
} from './python/index.js';
|
||||
import { extractDjangoRoutes } from '../route-extractors/django.js';
|
||||
import { discoverDjangoRootUrls } from '../route-extractors/django-root-discovery.js';
|
||||
import { extractPythonModuleConstants } from '../route-extractors/python-const-resolver.js';
|
||||
|
||||
const BUILT_INS: ReadonlySet<string> = new Set([
|
||||
'print',
|
||||
|
|
@ -158,4 +159,17 @@ export const pythonProvider = defineLanguage({
|
|||
receiverBinding: pythonReceiverBinding,
|
||||
arityCompatibility: pythonArityCompatibility,
|
||||
resolveImportTarget: resolvePythonImportTarget,
|
||||
|
||||
// ── #2391 constant harvest, provider-hook form (#2980): module-level string
|
||||
// constants + from-imports for non-literal decorator route paths. Bare-name
|
||||
// refs fold through the shared resolver (no foldRoutePathOperands needed).
|
||||
// No `moduleConstantHeuristic`: Python harvests unconditionally, exactly as
|
||||
// #2391 shipped it. A content gate was tried here and removed on review — it
|
||||
// required `NAME` immediately followed by `=`, so it silently dropped the two
|
||||
// idiomatic typed-FastAPI shapes (`API: str = "/api"`,
|
||||
// `API: Final[str] = "/api"`) and every composed constant whose RHS starts
|
||||
// with an identifier (`USERS = BASE + "/users"`), i.e. it REGRESSED routes
|
||||
// that already resolve on main. The worker treats a missing heuristic as
|
||||
// default-open; only Java opts into a gate, where the cost actually bites.
|
||||
extractModuleConstants: extractPythonModuleConstants,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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`. ──
|
||||
|
|
@ -469,7 +473,7 @@ export const typescriptProvider = defineLanguage({
|
|||
|
||||
export const javascriptProvider = defineLanguage({
|
||||
id: SupportedLanguages.JavaScript,
|
||||
extensions: ['.js', '.jsx'],
|
||||
extensions: ['.js', '.jsx', '.mjs', '.cjs'],
|
||||
entryPointPatterns: [/^use[A-Z]/],
|
||||
astFrameworkPatterns: [
|
||||
{
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ import {
|
|||
createParserForLanguage,
|
||||
} from '../../tree-sitter/parser-loader.js';
|
||||
import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
|
||||
import { getProvider, providers } from '../languages/index.js';
|
||||
import { getProvider, getProviderForFile, providers } from '../languages/index.js';
|
||||
import { SCOPE_RESOLVERS } from '../scope-resolution/pipeline/registry.js';
|
||||
import { DATA_ROUTE_TABLE_SOURCE } from '../route-extractors/data-route-table.js';
|
||||
import type Parser from 'tree-sitter';
|
||||
|
|
@ -1303,8 +1303,15 @@ export async function runChunkedParseAndResolve(
|
|||
resolvedRoutes.push(dr);
|
||||
continue;
|
||||
}
|
||||
// Provider-driven fold (#2980): languages with qualified-ref semantics
|
||||
// (Java `ApiPaths.X` / `com.example.ApiPaths.X`) fold through their
|
||||
// provider hook; everything else uses the shared language-agnostic
|
||||
// operand fold. No language names in the shared layer.
|
||||
const fold = getProviderForFile(dr.filePath)?.foldRoutePathOperands;
|
||||
const value = dr.routePathOperands
|
||||
? resolveOperands(dr.filePath, dr.routePathOperands, repoConstants)
|
||||
? fold
|
||||
? fold(dr.filePath, dr.routePathOperands, repoConstants)
|
||||
: resolveOperands(dr.filePath, dr.routePathOperands, repoConstants)
|
||||
: null;
|
||||
if (value === null) {
|
||||
skipped++;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ const MAX_RESOLVE_DEPTH = 8;
|
|||
* whose true value is genuinely huge — building it risks a `RangeError`/heap OOM,
|
||||
* so we floor to `null` (skip) instead (#2393). The depth cap bounds recursion but
|
||||
* NOT output size, which grows multiplicatively; this bounds the output. */
|
||||
const MAX_FOLD_LENGTH = 8192;
|
||||
export const MAX_FOLD_LENGTH = 8192;
|
||||
|
||||
/**
|
||||
* One term of a constant's right-hand side. A `+`-concatenation
|
||||
|
|
@ -175,10 +175,18 @@ function computeFold(
|
|||
return null;
|
||||
}
|
||||
|
||||
function newState(repo: RepoConstants, resolveImport: ImportResolver): ResolveState {
|
||||
function newState(
|
||||
repo: RepoConstants,
|
||||
resolveImport: ImportResolver,
|
||||
repoKeys?: ReadonlySet<string>,
|
||||
): ResolveState {
|
||||
return {
|
||||
repo,
|
||||
repoKeys: new Set(repo.keys()),
|
||||
// Materializing the key set here is O(files), and this runs once per fold —
|
||||
// which is once per import hop, not once per scan. A binding that already
|
||||
// holds the set (every one of them does; it is a projection of the same map
|
||||
// it builds `repo` from) passes it in and skips the copy entirely.
|
||||
repoKeys: repoKeys ?? new Set(repo.keys()),
|
||||
resolveImport,
|
||||
visited: new Set(),
|
||||
memo: new Map(),
|
||||
|
|
@ -195,8 +203,9 @@ export function resolveConstant(
|
|||
name: string,
|
||||
repo: RepoConstants,
|
||||
resolveImport: ImportResolver,
|
||||
repoKeys?: ReadonlySet<string>,
|
||||
): string | null {
|
||||
return foldName(fileKey, name, newState(repo, resolveImport), 0);
|
||||
return foldName(fileKey, name, newState(repo, resolveImport, repoKeys), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -209,6 +218,7 @@ export function resolveOperands(
|
|||
operands: readonly Operand[],
|
||||
repo: RepoConstants,
|
||||
resolveImport: ImportResolver,
|
||||
repoKeys?: ReadonlySet<string>,
|
||||
): string | null {
|
||||
return foldExpr(fileKey, operands, newState(repo, resolveImport), 0);
|
||||
return foldExpr(fileKey, operands, newState(repo, resolveImport, repoKeys), 0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,630 @@
|
|||
/**
|
||||
* Java binding for the language-agnostic constant resolver (#2391 core).
|
||||
*
|
||||
* Supplies the two Java-specific pieces the shared fold in
|
||||
* `constant-resolver.ts` needs — {@link resolveJavaImport} (import-specifier →
|
||||
* file, honoring JVM package/classpath rules) and
|
||||
* {@link extractJavaModuleConstants} (tree → {@link ModuleConstants}) — plus a
|
||||
* pre-bound {@link resolveJavaConstant} wrapper so callers stay
|
||||
* language-oblivious. The reusable fold, the cycle guard, and the depth cap
|
||||
* all live in the agnostic core.
|
||||
*
|
||||
* Java constant shape (one per type declaration; nested classes flatten into
|
||||
* the same file-level namespace, mirroring how `Outer.CONST` and a top-level
|
||||
* `CONST` are indistinguishable at the fold layer):
|
||||
*
|
||||
* public class ApiPathConstants {
|
||||
* public static final String DIAGNOSIS_SAVE_V1 = "/api/v1/diagnosis/add";
|
||||
* public static final String API_CIS_SAVE_SUMMARY = API_CIS_V1 + "summary/save";
|
||||
* }
|
||||
*
|
||||
* Reference shapes at annotation sites this binding resolves:
|
||||
* @PostMapping(ApiPathConstants.DIAGNOSIS_SAVE_V1) // qualified
|
||||
* @PostMapping(com.winning.opt.X.ApiPathConstants.Y) // FQN-qualified
|
||||
* @PostMapping(DIAGNOSIS_SAVE_V1) // static-imported
|
||||
* @PostMapping(API_CIS_V1 + "summary/save") // inline concat
|
||||
*
|
||||
* Which ANNOTATIONS count as routes is a separate question this module has no
|
||||
* say in: `spring-shared.ts` holds an exact-name map, so a vendor alias like
|
||||
* `@WinPostMapping` yields no route on this base regardless of how its value
|
||||
* folds (#2883). Folding and alias recognition compose; neither implies the
|
||||
* other.
|
||||
*
|
||||
* Import shapes consumed:
|
||||
* import com.winning.opt.diagnosis.api.constants.ApiPathConstants;
|
||||
* import static com.winning.opt.diagnosis.api.constants.ApiPathConstants.API_CIS_V1;
|
||||
*
|
||||
* Keying (KTD4 parity with the Python binding): the repo map is keyed by
|
||||
* unique POSIX file path. A Java import `com.a.b.CONSTS` resolves to the file
|
||||
* whose path ends with `com/a/b/CONSTS.java`; when 2+ files share that suffix
|
||||
* the import is ambiguous and returns null (skip floor), never a wrong path.
|
||||
*/
|
||||
|
||||
import type Parser from 'tree-sitter';
|
||||
import { unquoteSpringLiteral } from './spring-shared.js';
|
||||
import {
|
||||
MAX_FOLD_LENGTH,
|
||||
type ImportBinding,
|
||||
type ImportResolver,
|
||||
type ModuleConstants,
|
||||
type Operand,
|
||||
type RepoConstants,
|
||||
} from './constant-resolver.js';
|
||||
|
||||
export type {
|
||||
ImportBinding,
|
||||
ModuleConstants,
|
||||
Operand,
|
||||
RepoConstants,
|
||||
} from './constant-resolver.js';
|
||||
|
||||
/**
|
||||
* Cheap content gate: can this Java file DEFINE a string constant that a route
|
||||
* annotation might reference?
|
||||
*
|
||||
* Exported so BOTH sides of the pipeline use the same predicate and cannot
|
||||
* disagree about which files carry constants — the ingestion provider
|
||||
* (`languages/java.ts`, as `moduleConstantHeuristic`) and the group extractor's
|
||||
* `prepareRepo` pre-pass (`group/extractors/http-patterns/java.ts`). They used
|
||||
* to spell it differently, and the two spellings disagreed on a constant
|
||||
* INTERFACE: the group admitted it and published a provider contract at the
|
||||
* folded path, while ingestion rejected the file and emitted no Route node for
|
||||
* it — an R4 parity break in the losing direction, since ingestion is the side
|
||||
* that drives the graph and `api_impact`.
|
||||
*
|
||||
* Arms:
|
||||
* - a `static` … `String NAME =` declaration, with the modifier run matched as
|
||||
* a span so every legal order works (`static public final String`,
|
||||
* `public final static String`) and so `java.lang.String` — which the
|
||||
* extractor accepts — is admitted too.
|
||||
* - an `interface` declaration carrying a String assignment — interface fields
|
||||
* are implicitly `public static final` (JLS 9.3), so a pure constant
|
||||
* interface has neither keyword and no import. The assignment conjunct keeps
|
||||
* a file whose PROSE merely mentions "interface " from costing a parse.
|
||||
*/
|
||||
// `static` … `String NAME =` on one declaration. The modifier run is matched as
|
||||
// a span rather than as the adjacent pair `static final`, because the extractor
|
||||
// scans modifiers INDEPENDENTLY (`isStaticFinal`) and Java lets them appear in
|
||||
// any order — `static public final String`, `public final static String` — and
|
||||
// because the type may be written out as `java.lang.String`, which the
|
||||
// extractor also accepts. A gate narrower than the extractor it feeds is the
|
||||
// same defect class as the ingestion/group divergence this predicate exists to
|
||||
// prevent, just one layer down.
|
||||
//
|
||||
// The span excludes `;{}()` so it cannot jump a statement or block boundary: a
|
||||
// local `String s = "x"` inside `static void f() { … }` is not matched, because
|
||||
// reaching it from `static` crosses `(`, `)` and `{`. `final` is not required
|
||||
// even though the extractor requires it — the gate may be wider than the
|
||||
// extractor, never narrower.
|
||||
const STATIC_STRING_CONSTANT_RE = /\bstatic\b[^;{}()]{0,80}\bString\s+\w+\s*=/;
|
||||
const INTERFACE_DECL_RE = /\binterface\s+\w/;
|
||||
const STRING_ASSIGNMENT_RE = /\bString\s+\w+\s*=/;
|
||||
|
||||
export function isJavaConstantFile(source: string): boolean {
|
||||
if (STATIC_STRING_CONSTANT_RE.test(source)) return true;
|
||||
// The interface arm is a bare word match, so on its own it admits any file
|
||||
// whose PROSE mentions "interface " — and every admitted file costs the group
|
||||
// side a full extra parse. Requiring a String assignment as well keeps every
|
||||
// shape `extractJavaModuleConstants` accepts in an interface body (bare
|
||||
// `String`, `java.lang.String`, no space before `=`, multi-declarator) while
|
||||
// dropping the comment-only matches.
|
||||
return INTERFACE_DECL_RE.test(source) && STRING_ASSIGNMENT_RE.test(source);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Java {@link ImportResolver}: map a fully-qualified import specifier to
|
||||
* the unique file key it refers to, or null when it cannot be pinned to
|
||||
* exactly one file.
|
||||
*
|
||||
* `com.winning.opt.X.ApiPathConstants` → the file key ending in
|
||||
* `com/winning/opt/X/ApiPathConstants.java`. Because the repo map is
|
||||
* file-path-keyed and Maven multi-module trees repeat package roots across
|
||||
* modules (`winning-opt-a/.../api/constants/ApiPathConstants.java` and
|
||||
* `winning-opt-b/.../api/constants/ApiPathConstants.java`), suffix matching
|
||||
* stays UNIQUE-suffix: an import whose full package+class path matches N files
|
||||
* in N different modules cannot be pinned, so it returns null — the skip floor
|
||||
* this module promises, never a wrong path.
|
||||
*
|
||||
* A nearest-shared-directory tie-break was tried here and removed on review:
|
||||
* javac resolves duplicate FQNs by CLASSPATH ORDER, not directory proximity, so
|
||||
* a `src/test` fixture copy or a module that merely sits closer in the tree can
|
||||
* outrank the real dependency and yield a silently wrong literal. In a resolver
|
||||
* whose whole contract is skip-or-correct, a plausible guess is the one answer
|
||||
* that cannot be allowed.
|
||||
*/
|
||||
export const resolveJavaImport: ImportResolver = (_importingFileKey, moduleSpec, repoKeys) => {
|
||||
// A static import `a.b.C.CONST` names the class as all-but-last segment;
|
||||
// a plain import `a.b.C` names the class as last segment. Both resolve to
|
||||
// a file ending `a/b/C.java`; treating the whole spec as a path and
|
||||
// trimming the last segment when the direct hit fails covers both shapes.
|
||||
const asPath = moduleSpec.replace(/\./g, '/');
|
||||
const classFile = `${asPath}.java`;
|
||||
|
||||
// Exact package-path suffix match, unique or nothing.
|
||||
let hit: string | null = null;
|
||||
for (const key of repoKeys) {
|
||||
if (key === classFile || key.endsWith(`/${classFile}`)) {
|
||||
if (hit !== null) return null; // 2+ modules carry this FQN — unresolvable
|
||||
hit = key;
|
||||
}
|
||||
}
|
||||
return hit;
|
||||
};
|
||||
|
||||
/**
|
||||
* Is `node` a Java string literal (`"..."`), and if so what value does the
|
||||
* route layer give it?
|
||||
*
|
||||
* tree-sitter-java splits a `string_literal` AROUND its `escape_sequence`
|
||||
* children, so joining `string_fragment`s alone silently DELETES every escape:
|
||||
* `"/user/{id:\\d+}"` — the standard Spring path-variable regex constraint —
|
||||
* folded to `/user/{id:d+}`, and a pure-escape literal (`"\\t"`) folded to the
|
||||
* empty string. Slicing the quotes off the raw text keeps the source spelling,
|
||||
* which is precisely what the LITERAL path does
|
||||
* ({@link unquoteSpringLiteral}) — so `@GetMapping(ApiPaths.USER_REGEX)` and
|
||||
* `@GetMapping("/user/{id:\\d+}")` now emit the same path for the same Java
|
||||
* source instead of two spellings the graph cannot reconcile. Same
|
||||
* `string_fragment`-join trap as the NestJS one in #3017.
|
||||
*/
|
||||
function stringLiteralValue(node: Parser.SyntaxNode): string | null {
|
||||
if (node.type !== 'string_literal') return null;
|
||||
// A Java text block is also a `string_literal` here, and `unquoteSpringLiteral`
|
||||
// has a `"""` arm that would hand back the raw block — leading newline and
|
||||
// incidental indentation included, both of which Java strips. Nothing
|
||||
// downstream normalizes that, so it would publish a Route at a path like
|
||||
// "\n /api/v1/x\n ". The old fragment-join returned '' here, which
|
||||
// floored to skip; keep that floor rather than trade it for a wrong path.
|
||||
if (node.text.startsWith('"""')) return null;
|
||||
return unquoteSpringLiteral(node.text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a qualified-name expression (`ApiPaths`, `com.example.ApiPaths`) to
|
||||
* its dotted text, or null when any segment is not a plain identifier (calls,
|
||||
* `this`, array access, generics — not a static constant shape).
|
||||
*/
|
||||
function flattenQualifiedIdentifier(node: Parser.SyntaxNode): string | null {
|
||||
if (node.type === 'identifier') return node.text;
|
||||
if (node.type === 'field_access') {
|
||||
const object = node.childForFieldName('object');
|
||||
const field = node.childForFieldName('field');
|
||||
if (object && field) {
|
||||
const head = flattenQualifiedIdentifier(object);
|
||||
return head === null ? null : `${head}.${field.text}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Java constant initializer into an operand list, or null when it is
|
||||
* not a foldable string expression. Handles a bare string literal, a bare
|
||||
* identifier (`X = Y`), qualified/static-import-free references
|
||||
* (`X = CONSTS.Y` — recorded as ONE ref named `CONSTS.Y`), and
|
||||
* left-associative `+` chains of the three. Everything else — numbers, calls,
|
||||
* ternaries, method refs, `String.format`, enum constants — returns null,
|
||||
* which makes the constant unresolvable (→ skip floor), never a wrong value.
|
||||
*/
|
||||
export function parseJavaConstOperands(
|
||||
node: Parser.SyntaxNode | null | undefined,
|
||||
depth = 0,
|
||||
): Operand[] | null {
|
||||
if (!node) return null;
|
||||
if (depth > 64) return null;
|
||||
if (node.type === 'string_literal') {
|
||||
const value = stringLiteralValue(node);
|
||||
return value === null ? null : [{ kind: 'literal', value }];
|
||||
}
|
||||
if (node.type === 'identifier') {
|
||||
return [{ kind: 'ref', name: node.text }];
|
||||
}
|
||||
// `CONSTS.FIELD` — field_access in tree-sitter-java for expressions. The
|
||||
// object side may itself be a chain (`com.example.ApiPaths` parses as
|
||||
// nested field_access), so flatten recursively: every segment must be a
|
||||
// plain identifier/keyword to qualify (a call `f().X`, `this.X`, or an
|
||||
// array access object side is not a constant shape → null, skip floor).
|
||||
if (node.type === 'field_access') {
|
||||
const object = node.childForFieldName('object');
|
||||
const field = node.childForFieldName('field');
|
||||
if (object && field) {
|
||||
const objectName = flattenQualifiedIdentifier(object);
|
||||
if (objectName !== null) return [{ kind: 'ref', name: `${objectName}.${field.text}` }];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (node.type === 'binary_expression') {
|
||||
const isPlus = (node.children ?? []).some((c) => c.type === '+');
|
||||
if (!isPlus) return null;
|
||||
const left = parseJavaConstOperands(node.childForFieldName('left'), depth + 1);
|
||||
const right = parseJavaConstOperands(node.childForFieldName('right'), depth + 1);
|
||||
if (left === null || right === null) return null;
|
||||
return [...left, ...right];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the file-level string constants and import bindings of one parsed
|
||||
* Java file into the {@link ModuleConstants} shape the resolver consumes.
|
||||
*
|
||||
* Constants: every `static final String NAME = …` field of every type
|
||||
* declaration in the file (nested classes included — their simple names
|
||||
* would collide at the fold layer, but qualified refs carry the class name
|
||||
* so nesting only matters for same-name fields, which flatten last-wins).
|
||||
* Interface constants (`String NAME = "…"`) are implicitly static final and
|
||||
* are collected too.
|
||||
*
|
||||
* References to OTHER constants via qualified names (`ApiPathConstants.X`)
|
||||
* are stored as refs named `ApiPathConstants.X`; at the fold layer such a ref
|
||||
* resolves through the import map (`ApiPathConstants` → module) followed by
|
||||
* field lookup in the target file's OWN class-name-qualified namespace. To
|
||||
* support that, constant names are ALSO recorded under
|
||||
* `<DeclaringClass>.<FIELD>` (both spellings share one entry).
|
||||
*
|
||||
* Last-wins in source order; a non-foldable rebind (`X = compute()`) drops X
|
||||
* to unresolvable rather than keeping a stale literal.
|
||||
*/
|
||||
export function extractJavaModuleConstants(tree: Parser.Tree): ModuleConstants {
|
||||
const literals = new Map<string, string>();
|
||||
const exprs = new Map<string, readonly Operand[]>();
|
||||
const imports = new Map<string, ImportBinding>();
|
||||
|
||||
// Pass 1: imports (both shapes).
|
||||
const walkImports = (node: Parser.SyntaxNode): void => {
|
||||
if (node.type === 'import_declaration') {
|
||||
// import a.b.C; | import static a.b.C; | import static a.b.C.F;
|
||||
const isStatic = node.children.some((c) => c.type === 'static' && c.text === 'static');
|
||||
const scoped = node.children.find((c) => c.type === 'scoped_identifier');
|
||||
if (scoped) {
|
||||
const text = scoped.text;
|
||||
const lastDot = text.lastIndexOf('.');
|
||||
const fqn = text.slice(0, lastDot);
|
||||
const name = text.slice(lastDot + 1);
|
||||
if (isStatic) {
|
||||
// import static a.b.C.F → local F from module a.b.C, original F.
|
||||
imports.set(name, { module: fqn, originalName: name });
|
||||
} else {
|
||||
// import a.b.C → module IS the class FQN; originalName is the class
|
||||
// simple name. resolveJavaImport maps `a.b.C` → `a/b/C.java`.
|
||||
imports.set(name, { module: text, originalName: name });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const child of node.children ?? []) walkImports(child);
|
||||
};
|
||||
walkImports(tree.rootNode);
|
||||
|
||||
// Pass 2: constants. A field declaration is a constant when it is
|
||||
// `static final` (explicit) or inside an interface (implicit).
|
||||
const isStaticFinal = (modifiers: Parser.SyntaxNode | null | undefined): boolean => {
|
||||
if (!modifiers) return false;
|
||||
let sawStatic = false;
|
||||
let sawFinal = false;
|
||||
for (const m of modifiers.children ?? []) {
|
||||
if (m.type === 'static') sawStatic = true;
|
||||
if (m.type === 'final') sawFinal = true;
|
||||
}
|
||||
return sawStatic && sawFinal;
|
||||
};
|
||||
|
||||
const collectFieldConstants = (
|
||||
classBody: Parser.SyntaxNode,
|
||||
insideInterface: boolean,
|
||||
declaringClass: string | null,
|
||||
): void => {
|
||||
for (const member of classBody.children ?? []) {
|
||||
// tree-sitter-java: interface fields are `constant_declaration`, class
|
||||
// fields are `field_declaration`. Both carry `variable_declarator`s.
|
||||
if (member.type !== 'field_declaration' && member.type !== 'constant_declaration') continue;
|
||||
const mods = member.children.find((c) => c.type === 'modifiers');
|
||||
if (!insideInterface && !isStaticFinal(mods)) continue;
|
||||
// Type must be String (java.lang.String is implicit-imported).
|
||||
const typeNode = member.childForFieldName('type');
|
||||
if (!typeNode) continue;
|
||||
const typeText = typeNode.text;
|
||||
if (typeText !== 'String' && typeText !== 'java.lang.String') continue;
|
||||
|
||||
const declarators = member.children.filter((c) => c.type === 'variable_declarator');
|
||||
for (const decl of declarators) {
|
||||
const nameNode = decl.childForFieldName('name');
|
||||
const valueNode = decl.childForFieldName('value');
|
||||
if (!nameNode) continue;
|
||||
const name = nameNode.text;
|
||||
const operands = parseJavaConstOperands(valueNode);
|
||||
// Same-name shadowing across nested types (legal Java, unlike
|
||||
// same-class redeclaration): a later binding must REPLACE the earlier
|
||||
// flattened simple-name entry — including dropping it to unresolvable
|
||||
// when the new initializer is not foldable (`X = compute()`) — rather
|
||||
// than leave the stale outer literal resolvable. Skip floor, mirroring
|
||||
// Python #2391's rebind-drop. Qualified `Class.FIELD` aliases are
|
||||
// per-type-keyed but same-named nested types can still collide, so
|
||||
// they get the same replace/drop treatment.
|
||||
const qname = declaringClass ? `${declaringClass}.${name}` : null;
|
||||
if (operands === null) {
|
||||
literals.delete(name);
|
||||
exprs.delete(name);
|
||||
// …and the static IMPORT of the same simple name. A local
|
||||
// `static final String` shadows `import static a.b.C.PATH` inside
|
||||
// that class (JLS 6.4.1), so the correct answer for a non-foldable
|
||||
// rebind is "unresolvable" — leaving the import alive makes the fold
|
||||
// fall through it (computeFold: literals → exprs → imports) and
|
||||
// return the IMPORTED value, i.e. a wrong path where the skip floor
|
||||
// is owed. #2393's Python defect, reproduced for Java.
|
||||
//
|
||||
// The delete is file-scoped because these maps are (see the header:
|
||||
// nested types flatten into one file-level namespace). So a SIBLING
|
||||
// top-level class in the same file that legitimately uses the import
|
||||
// loses it too and floors to skip, where javac would resolve it.
|
||||
// That direction is the acceptable one — a missing route, not a wrong
|
||||
// one — and the shape (two top-level classes, one shadowing a static
|
||||
// import with a non-foldable initializer) is vanishingly rare next to
|
||||
// the wrong-value it prevents.
|
||||
imports.delete(name);
|
||||
if (qname) {
|
||||
literals.delete(qname);
|
||||
exprs.delete(qname);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const literalValue =
|
||||
operands.length === 1 && operands[0].kind === 'literal'
|
||||
? (operands[0] as { value: string }).value
|
||||
: null;
|
||||
if (literalValue !== null) {
|
||||
literals.set(name, literalValue);
|
||||
exprs.delete(name);
|
||||
} else {
|
||||
exprs.set(name, operands);
|
||||
literals.delete(name);
|
||||
}
|
||||
// Qualified alias: `CONSTS.X` refs (folded refs carry the class name).
|
||||
if (qname) {
|
||||
if (literalValue !== null) {
|
||||
literals.set(qname, literalValue);
|
||||
exprs.delete(qname);
|
||||
} else {
|
||||
exprs.set(qname, operands);
|
||||
literals.delete(qname);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const walkTypes = (node: Parser.SyntaxNode, insideInterface: boolean): void => {
|
||||
for (const child of node.children ?? []) {
|
||||
const isInterface = child.type === 'interface_declaration';
|
||||
// Enums and records are ordinary type declarations for constant
|
||||
// purposes — their fields need an explicit `static final` (JLS 8.9/8.10),
|
||||
// unlike an interface's implicitly-constant ones. They used to be only
|
||||
// RECURSED into, never collected, so a `static final String` declared
|
||||
// directly in an enum or record was silently absent from the map.
|
||||
const isTypeDecl =
|
||||
isInterface ||
|
||||
child.type === 'class_declaration' ||
|
||||
child.type === 'enum_declaration' ||
|
||||
child.type === 'record_declaration';
|
||||
if (!isTypeDecl) {
|
||||
walkTypes(child, insideInterface);
|
||||
continue;
|
||||
}
|
||||
const className = child.childForFieldName('name')?.text ?? null;
|
||||
const body = child.children.find(
|
||||
(c) => c.type === 'class_body' || c.type === 'interface_body' || c.type === 'enum_body',
|
||||
);
|
||||
if (!body) continue;
|
||||
// An enum's members hang one level deeper, under `enum_body_declarations`
|
||||
// (the `enum_body` itself holds only the enum constants).
|
||||
const memberBody = body.children.find((c) => c.type === 'enum_body_declarations') ?? body;
|
||||
// Recompute implicit interface semantics at each type boundary: a
|
||||
// class nested in an interface is a normal class whose fields need
|
||||
// explicit `static final` (JLS 9.5 — only the interface's own fields
|
||||
// are implicitly public static final). Propagating the outer
|
||||
// `insideInterface` flag in would harvest mutable nested fields as
|
||||
// constants and let a same-name nested field shadow a real interface
|
||||
// constant with a stale value.
|
||||
if (className) collectFieldConstants(memberBody, isInterface, className);
|
||||
// Recurse over the WHOLE body, not just `memberBody`: an enum's constants
|
||||
// are siblings of `enum_body_declarations`, so narrowing here dropped any
|
||||
// type nested inside an enum-constant body whenever the enum also had
|
||||
// member declarations. For a class/interface/record the two are the same
|
||||
// node; for an enum `body` is a strict superset, and the extra visit to
|
||||
// `enum_body_declarations` collects nothing twice (collectFieldConstants
|
||||
// is still called on `memberBody` alone).
|
||||
walkTypes(body, isInterface);
|
||||
}
|
||||
};
|
||||
walkTypes(tree.rootNode, false);
|
||||
|
||||
return { literals, exprs, imports: imports as Map<string, ImportBinding> };
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-fold state. Mirrors the guards the agnostic core carries in `foldName`,
|
||||
* which this binding stopped delegating to once it had to resolve qualified
|
||||
* operands itself:
|
||||
*
|
||||
* - `memo` caches SUCCESSES only and is never popped. Without it a
|
||||
* shared-descendant DAG (`X_k = X_{k+1} + X_{k+1}`) re-folds each child once
|
||||
* per reference — O(2^depth) — and {@link MAX_FOLD_LENGTH} cannot save it,
|
||||
* because a chain whose intermediate values are the empty string never
|
||||
* accumulates any output. Measured before this state existed: one route over
|
||||
* a 31-line constants file took 2.7 s at 26 levels and 11 s at 28, on the
|
||||
* main thread, per file. A `null` may be transient (a name that cycles on one
|
||||
* branch can resolve on another), so caching it would be unsound.
|
||||
* - `visited` is the ACTIVE resolution stack, popped on unwind, so diamonds
|
||||
* fold instead of false-cycling while true cycles still terminate.
|
||||
* - `constantKeys` is the candidate set import ambiguity is measured over:
|
||||
* files that actually DEFINE a constant. Handing `resolveJavaImport` every
|
||||
* repo key made the two subsystems disagree — ingestion's map also holds
|
||||
* import-only files (its gate has an import arm), so a duplicate FQN that
|
||||
* defines nothing was invisible to the group and made ingestion alone floor
|
||||
* to skip. Hoisting it also stops rebuilding the set on every qualified ref.
|
||||
*/
|
||||
interface JavaFoldState {
|
||||
readonly repo: RepoConstants;
|
||||
readonly constantKeys: ReadonlySet<string>;
|
||||
readonly visited: Set<string>;
|
||||
readonly memo: Map<string, string>;
|
||||
}
|
||||
|
||||
function newFoldState(repo: RepoConstants): JavaFoldState {
|
||||
const constantKeys = new Set<string>();
|
||||
for (const [key, mc] of repo) {
|
||||
if (mc.literals.size > 0 || mc.exprs.size > 0) constantKeys.add(key);
|
||||
}
|
||||
return { repo, constantKeys, visited: new Set(), memo: new Map() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a single Java constant referenced in `fileKey` to its literal string
|
||||
* value, folding `+` concatenation and following import chains via
|
||||
* {@link resolveJavaImport}, or null when it cannot be fully folded.
|
||||
*
|
||||
* `name` may be simple (`DIAGNOSIS_SAVE_V1`, resolved via static import or
|
||||
* same-file constant) or qualified (`ApiPathConstants.DIAGNOSIS_SAVE_V1`,
|
||||
* resolved via the class import + the target file's qualified alias).
|
||||
*/
|
||||
export function resolveJavaConstant(
|
||||
fileKey: string,
|
||||
name: string,
|
||||
repo: RepoConstants,
|
||||
depth = 0,
|
||||
): string | null {
|
||||
return resolveWithState(fileKey, name, newFoldState(repo), depth);
|
||||
}
|
||||
|
||||
function resolveWithState(
|
||||
fileKey: string,
|
||||
name: string,
|
||||
state: JavaFoldState,
|
||||
depth: number,
|
||||
): string | null {
|
||||
if (depth > 32) return null;
|
||||
const guard = `${fileKey}::${name}`;
|
||||
const memoized = state.memo.get(guard);
|
||||
if (memoized !== undefined) return memoized;
|
||||
if (state.visited.has(guard)) return null; // cycle: `name` is on the active stack
|
||||
state.visited.add(guard);
|
||||
try {
|
||||
const result = computeJavaFold(fileKey, name, state, depth);
|
||||
if (result !== null) state.memo.set(guard, result);
|
||||
return result;
|
||||
} finally {
|
||||
state.visited.delete(guard);
|
||||
}
|
||||
}
|
||||
|
||||
function computeJavaFold(
|
||||
fileKey: string,
|
||||
name: string,
|
||||
state: JavaFoldState,
|
||||
depth: number,
|
||||
): string | null {
|
||||
const { repo, constantKeys } = state;
|
||||
// Qualified ref (`ApiPathConstants.FIELD`): constants and imports are keyed by
|
||||
// their IN-FILE name, so a dotted name never hits directly. Split head.tail:
|
||||
// resolve the head through the importing file's class import, then look the
|
||||
// tail up in the target file — first as the class-qualified alias `Head.TAIL`
|
||||
// (what extractJavaModuleConstants records), then as a bare `TAIL` (same-file
|
||||
// nested/interface constant).
|
||||
const dot = name.indexOf('.');
|
||||
if (dot > 0) {
|
||||
const head = name.slice(0, dot);
|
||||
const tail = name.slice(dot + 1);
|
||||
const imp = repo.get(fileKey)?.imports.get(head);
|
||||
if (imp) {
|
||||
const targetFile = resolveJavaImport(fileKey, imp.module, constantKeys);
|
||||
if (targetFile !== null) {
|
||||
const qualified = resolveWithState(targetFile, `${head}.${tail}`, state, depth + 1);
|
||||
if (qualified !== null) return qualified;
|
||||
const bare = resolveWithState(targetFile, tail, state, depth + 1);
|
||||
if (bare !== null) return bare;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// Un-imported qualified name (FQN form `com.a.b.C.FIELD`): try resolving
|
||||
// the longest dotted prefix as a class import target.
|
||||
const parts = name.split('.');
|
||||
for (let cut = parts.length - 2; cut >= 1; cut--) {
|
||||
const fqn = parts.slice(0, cut + 1).join('.');
|
||||
const targetFile = resolveJavaImport(fileKey, fqn, constantKeys);
|
||||
if (targetFile !== null) {
|
||||
const field = parts.slice(cut + 1).join('.');
|
||||
const declaring = parts[cut];
|
||||
const qualified = resolveWithState(targetFile, `${declaring}.${field}`, state, depth + 1);
|
||||
if (qualified !== null) return qualified;
|
||||
return resolveWithState(targetFile, field, state, depth + 1);
|
||||
}
|
||||
}
|
||||
// No import bound the head and no FQN prefix resolved — fall through. A
|
||||
// dotted name is ALSO a valid key in this file's own maps:
|
||||
// `extractJavaModuleConstants` records every constant under
|
||||
// `<DeclaringClass>.<FIELD>` as well as its simple name, so a same-file
|
||||
// qualified reference (`ApiPaths.X` inside ApiPaths.java) resolves below.
|
||||
}
|
||||
|
||||
// Name lookup: literals, then same-file expressions, then the import chase.
|
||||
// Reached for a bare name and for a dotted name that named no import.
|
||||
// Expressions are folded HERE rather than handed to the agnostic core because
|
||||
// an operand of a Java initializer may itself be a QUALIFIED ref
|
||||
// (`X = BConsts.Y + "/tail"`) and the core only knows bare names: it looks
|
||||
// `BConsts.Y` up in maps keyed by simple name, misses, and floors the whole
|
||||
// chain to null. Recursing through this function gives every operand the same
|
||||
// qualified treatment the entry-point name got.
|
||||
const mc = repo.get(fileKey);
|
||||
if (!mc) return null;
|
||||
const literal = mc.literals.get(name);
|
||||
if (literal !== undefined) return literal;
|
||||
const expr = mc.exprs.get(name);
|
||||
if (expr !== undefined) return foldOperands(fileKey, expr, state, depth + 1);
|
||||
const imp = mc.imports.get(name);
|
||||
if (imp !== undefined) {
|
||||
const targetFile = resolveJavaImport(fileKey, imp.module, constantKeys);
|
||||
if (targetFile === null) return null;
|
||||
return resolveWithState(targetFile, imp.originalName, state, depth + 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenate an operand list, resolving each `ref` through the qualified-aware
|
||||
* walk so `Class.CONST` works at every position, not just at the entry point.
|
||||
*
|
||||
* Bounded by {@link MAX_FOLD_LENGTH}: the depth cap bounds RECURSION but not
|
||||
* OUTPUT, which grows multiplicatively (`X = A + A; A = B + B; …`), so a
|
||||
* pathological chain would build a gigabyte-scale string before any cap fired.
|
||||
* Overrun floors to null (#2393).
|
||||
*/
|
||||
function foldOperands(
|
||||
fileKey: string,
|
||||
operands: readonly Operand[],
|
||||
state: JavaFoldState,
|
||||
depth: number,
|
||||
): string | null {
|
||||
let out = '';
|
||||
for (const op of operands) {
|
||||
if (op.kind === 'literal') {
|
||||
out += op.value;
|
||||
} else {
|
||||
const piece = resolveWithState(fileKey, op.name, state, depth);
|
||||
if (piece === null) return null;
|
||||
out += piece;
|
||||
}
|
||||
if (out.length > MAX_FOLD_LENGTH) return null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold an inline operand list (e.g. `API_CIS_V1 + "summary/save"`) against
|
||||
* `fileKey`, or null when any piece is unresolvable (skip floor).
|
||||
*/
|
||||
export function foldJavaOperands(
|
||||
fileKey: string,
|
||||
operands: readonly Operand[],
|
||||
repo: RepoConstants,
|
||||
): string | null {
|
||||
const out = foldOperands(fileKey, operands, newFoldState(repo), 0);
|
||||
return out === '' ? null : out;
|
||||
}
|
||||
1213
gitnexus/src/core/ingestion/route-extractors/js-const-resolver.ts
Normal file
1213
gitnexus/src/core/ingestion/route-extractors/js-const-resolver.ts
Normal file
File diff suppressed because it is too large
Load diff
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ import {
|
|||
unquoteSpringLiteral,
|
||||
type SharedSpringType,
|
||||
} from './spring-shared.js';
|
||||
import { parseJavaConstOperands } from './java-const-resolver.js';
|
||||
|
||||
/**
|
||||
* Single predicate-free tree-sitter query that captures all route annotations
|
||||
|
|
@ -53,6 +54,13 @@ import {
|
|||
* suppresses that class's method-level array routes rather than emit them with a
|
||||
* dropped prefix (a wrong route). Full class-array cross-product support is left
|
||||
* to a follow-up (#2280).
|
||||
*
|
||||
* The class-level `@value_expr` branches exist for the same reason: a
|
||||
* CONSTANT-valued class prefix (`@RequestMapping(ApiPaths.BASE)`) cannot be
|
||||
* folded here — the repo-wide constant map only exists in the parse phase — so
|
||||
* they only DETECT it, and Phase 2 suppresses every method route under such a
|
||||
* class. Without them the prefix was invisible and the method route was emitted
|
||||
* unprefixed, i.e. at a path the application does not serve.
|
||||
*/
|
||||
const ROUTE_ANNOTATION_QUERY = new Parser.Query(
|
||||
Java,
|
||||
|
|
@ -90,6 +98,42 @@ const ROUTE_ANNOTATION_QUERY = new Parser.Query(
|
|||
key: (identifier) @key
|
||||
value: [(string_literal) @value
|
||||
(element_value_array_initializer (string_literal) @value)]))))) @node
|
||||
(class_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: [(identifier) (scoped_identifier)] @ann
|
||||
arguments: (annotation_argument_list
|
||||
[(identifier) @value_expr
|
||||
(field_access) @value_expr
|
||||
(binary_expression) @value_expr])))) @node
|
||||
(class_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: [(identifier) (scoped_identifier)] @ann
|
||||
arguments: (annotation_argument_list
|
||||
(element_value_pair
|
||||
key: (identifier) @key
|
||||
value: [(identifier) @value_expr
|
||||
(field_access) @value_expr
|
||||
(binary_expression) @value_expr]))))) @node
|
||||
(method_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: [(identifier) (scoped_identifier)] @ann
|
||||
arguments: (annotation_argument_list
|
||||
[(identifier) @value_expr
|
||||
(field_access) @value_expr
|
||||
(binary_expression) @value_expr])))) @node
|
||||
(method_declaration
|
||||
(modifiers
|
||||
(annotation
|
||||
name: [(identifier) (scoped_identifier)] @ann
|
||||
arguments: (annotation_argument_list
|
||||
(element_value_pair
|
||||
key: (identifier) @key
|
||||
value: [(identifier) @value_expr
|
||||
(field_access) @value_expr
|
||||
(binary_expression) @value_expr]))))) @node
|
||||
]
|
||||
`,
|
||||
);
|
||||
|
|
@ -122,6 +166,11 @@ export function extractSpringRoutes(
|
|||
// class-array cross-product support is out of scope here.
|
||||
const prefixByClassId = new Map<number, string>();
|
||||
const classesWithArrayPrefix = new Set<number>();
|
||||
// Classes whose `@RequestMapping` prefix is a constant reference or concat.
|
||||
// Same treatment as the array form, for the same reason: no single prefix
|
||||
// string is knowable at extraction time, so emitting the methods below would
|
||||
// publish them at a WRONG (unprefixed) path rather than not at all.
|
||||
const classesWithUnfoldablePrefix = new Set<number>();
|
||||
const classHttpMethodsById = new Map<number, readonly string[]>();
|
||||
for (const match of TYPE_DECLARATION_QUERY.matches(tree.rootNode)) {
|
||||
const typeNode = match.captures.find((capture) => capture.name === 'type')?.node;
|
||||
|
|
@ -139,11 +188,16 @@ export function extractSpringRoutes(
|
|||
const node = caps['node'];
|
||||
const valueNode = caps['value'];
|
||||
const keyNode = caps['key'];
|
||||
if (!annNode || !node || !valueNode) continue;
|
||||
const valueExprNode = caps['value_expr'];
|
||||
if (!annNode || !node || (!valueNode && !valueExprNode)) continue;
|
||||
|
||||
const capturedAnnotationName = annNode.text.split('.').pop() ?? annNode.text;
|
||||
if (node.type === 'class_declaration' && capturedAnnotationName === 'RequestMapping') {
|
||||
if (!isRouteMemberKey(keyNode)) continue;
|
||||
if (!valueNode) {
|
||||
classesWithUnfoldablePrefix.add(node.id);
|
||||
continue;
|
||||
}
|
||||
if (valueNode.parent?.type === 'element_value_array_initializer') {
|
||||
classesWithArrayPrefix.add(node.id);
|
||||
continue;
|
||||
|
|
@ -166,7 +220,11 @@ export function extractSpringRoutes(
|
|||
const node = caps['node'];
|
||||
const valueNode = caps['value'];
|
||||
const keyNode = caps['key'];
|
||||
if (!annNode || !node || !valueNode) continue;
|
||||
// A constant-referencing value arrives as @value_expr, not @value — the
|
||||
// match carries exactly one of the two. Require @value only when no
|
||||
// @value_expr is present; the operand branch below folds the expression.
|
||||
const valueExprCapture = match.captures.find((c) => c.name === 'value_expr')?.node ?? null;
|
||||
if (!annNode || !node || (!valueNode && !valueExprCapture)) continue;
|
||||
|
||||
if (node.type !== 'method_declaration') continue;
|
||||
|
||||
|
|
@ -181,8 +239,12 @@ export function extractSpringRoutes(
|
|||
if (methodMethods.length === 0) continue;
|
||||
if (!isRouteMemberKey(keyNode)) continue;
|
||||
|
||||
const routePath = unquoteSpringLiteral(valueNode.text);
|
||||
if (routePath === null) continue;
|
||||
// #2391-style non-literal path (constant ref or `+`-concat): emit with
|
||||
// operands for cross-file folding in the parse phase. The match carries
|
||||
// either @value (literal) or @value_expr (non-literal) — never both.
|
||||
const valueExprNode = valueExprCapture;
|
||||
const routePath = valueNode ? unquoteSpringLiteral(valueNode.text) : null;
|
||||
if (routePath === null && !valueExprNode) continue;
|
||||
const enclosingType = findEnclosingType(node);
|
||||
|
||||
// Interface-declared `@*Mapping`s are not concrete routes on their own — the
|
||||
|
|
@ -206,10 +268,20 @@ export function extractSpringRoutes(
|
|||
// scan — safe under routeCoverage:'partial'. Full class-array cross-product
|
||||
// support is tracked in #2280. (Scalar method paths under an array class
|
||||
// prefix are left unchanged: that pre-existing divergence is out of scope.)
|
||||
const isArrayElement = valueNode.parent?.type === 'element_value_array_initializer';
|
||||
const isArrayElement = valueNode?.parent?.type === 'element_value_array_initializer';
|
||||
if (isArrayElement && enclosingClass && classesWithArrayPrefix.has(enclosingClass.id)) {
|
||||
continue;
|
||||
}
|
||||
// Same rule for a CONSTANT-valued class prefix (`@RequestMapping(ApiPaths.BASE)`),
|
||||
// and for every method route under it — not just array-form ones. The prefix
|
||||
// needs the repo-wide constant map, which does not exist at extraction time,
|
||||
// so the prefix would simply be dropped and the route emitted at a path the
|
||||
// application never serves. On base such a route was not emitted at all;
|
||||
// turning a missing fact into a wrong one is the failure this module's skip
|
||||
// floor exists to prevent. Folding class prefixes cross-file is a follow-up.
|
||||
if (enclosingClass && classesWithUnfoldablePrefix.has(enclosingClass.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const classPrefix = enclosingClass ? (prefixByClassId.get(enclosingClass.id) ?? '') : '';
|
||||
// `node` is the annotated `method_declaration`; its name field is the
|
||||
|
|
@ -217,6 +289,25 @@ export function extractSpringRoutes(
|
|||
const handlerName = node.childForFieldName('name')?.text;
|
||||
|
||||
for (const httpMethod of httpMethods) {
|
||||
if (routePath === null && valueExprNode) {
|
||||
// Non-literal annotation value: parse operands now; the parse phase
|
||||
// folds them against the repo-wide Java constant map (KTD5 skip floor
|
||||
// on failure — never a phantom `POST /`).
|
||||
const operands = parseJavaConstOperands(valueExprNode);
|
||||
if (operands === null) continue;
|
||||
routes.push({
|
||||
filePath,
|
||||
routePath: '',
|
||||
routePathExpr: valueExprNode.text,
|
||||
routePathOperands: operands,
|
||||
httpMethod,
|
||||
decoratorName: ann,
|
||||
lineNumber: annNode.startPosition.row + lineOffset,
|
||||
...(classPrefix ? { prefix: classPrefix } : {}),
|
||||
...(handlerName ? { handlerName } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
routes.push({
|
||||
filePath,
|
||||
routePath,
|
||||
|
|
@ -233,6 +324,13 @@ export function extractSpringRoutes(
|
|||
for (const match of TYPE_DECLARATION_QUERY.matches(tree.rootNode)) {
|
||||
const typeNode = match.captures.find((capture) => capture.name === 'type')?.node;
|
||||
if (typeNode?.type !== 'class_declaration') continue;
|
||||
// A no-argument `@GetMapping` IS the class prefix, so a class prefix that
|
||||
// cannot be folded here leaves nothing to emit — the route would ship with
|
||||
// `routePath: ''` and no prefix, i.e. an empty-path Route. The Phase 2 loop
|
||||
// above already suppresses these classes; this loop needs the same guard, or
|
||||
// the suppression is one-sided and the group side (which routes both shapes
|
||||
// through `methodRoutes`) disagrees with ingestion.
|
||||
if (classesWithUnfoldablePrefix.has(typeNode.id)) continue;
|
||||
const classPrefix = prefixByClassId.get(typeNode.id) ?? '';
|
||||
const classMethods = classHttpMethodsById.get(typeNode.id) ?? ['*'];
|
||||
for (const methodNode of directMethods(typeNode)) {
|
||||
|
|
|
|||
|
|
@ -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} | ||||