mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-09 03:17:54 +00:00
Merge branch 'main' into feat/unified-deployment-enhancement
This commit is contained in:
commit
ebda1a443c
34 changed files with 5792 additions and 658 deletions
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
4
.github/workflows/docker.yml
vendored
4
.github/workflows/docker.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
86
gitnexus-web/package-lock.json
generated
86
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,7 +30,7 @@
|
|||
"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",
|
||||
|
|
@ -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.9.9",
|
||||
"@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",
|
||||
|
|
@ -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",
|
||||
|
|
@ -2638,13 +2638,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.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-14.0.5.tgz",
|
||||
"integrity": "sha512-ChbTraIvChbcFXMwDPLE8MoWpNGSRhJ2cXsE0V3iJQIVYDRgjFoT6JzWfkuc7w/3ojLLr8eMoae7M1v6OXoC5Q==",
|
||||
"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 +2657,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 +2691,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.9.9",
|
||||
"resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.9.9.tgz",
|
||||
"integrity": "sha512-jaMocJLa+rP3WpwYrbx2kUpHObjXK/JZOsbtmodDMAtfXbwl7niPNcEbdYYj/fBPSX8yRUXBF3tQsasocbjD5Q==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
|
|
@ -2701,10 +2701,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.0.5",
|
||||
"@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 +2739,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 +2765,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 +3131,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 +5715,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"
|
||||
|
|
|
|||
|
|
@ -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,7 +40,7 @@
|
|||
"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",
|
||||
|
|
@ -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.9.9",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"jsdom": "^29.1.1",
|
||||
|
|
|
|||
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"
|
||||
|
|
|
|||
|
|
@ -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,11 +9,21 @@ 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 {
|
||||
buildJsRepoFacts,
|
||||
extractJsModuleFacts,
|
||||
isAxiosNamespace,
|
||||
isHttpClientRef,
|
||||
resolveJsPathExpression,
|
||||
type JsModuleFacts,
|
||||
type JsRepoFacts,
|
||||
} from '../../../ingestion/route-extractors/js-const-resolver.js';
|
||||
|
||||
/**
|
||||
* Node.js / TypeScript HTTP plugin family. Handles:
|
||||
|
|
@ -98,15 +108,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))
|
||||
`,
|
||||
};
|
||||
|
||||
|
|
@ -158,7 +181,7 @@ interface NodePatternBundle {
|
|||
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>>;
|
||||
|
|
@ -177,7 +200,7 @@ function compileBundle(language: unknown, name: string): NodePatternBundle {
|
|||
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'),
|
||||
|
|
@ -309,12 +332,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,25 +356,234 @@ 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.
|
||||
|
|
@ -471,22 +713,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 +851,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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
@ -64,6 +69,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;
|
||||
|
|
@ -336,6 +360,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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -469,7 +469,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: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -141,6 +141,7 @@ import {
|
|||
templateConstraintsIdTag,
|
||||
} from '../utils/template-arguments.js';
|
||||
import type { LanguageProvider } from '../language-provider.js';
|
||||
import { shouldHarvestModuleConstants } from '../language-provider.js';
|
||||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
import { extractParsedFile, type ScopeCaptureSourceKind } from '../scope-extractor-bridge.js';
|
||||
import {
|
||||
|
|
@ -1421,7 +1422,6 @@ export function extractORMQueries(
|
|||
|
||||
import { extractFastAPIRouterBindings } from '../route-extractors/fastapi-router-bindings.js';
|
||||
import {
|
||||
extractPythonModuleConstants,
|
||||
parseConstOperands,
|
||||
type ModuleConstants,
|
||||
type Operand,
|
||||
|
|
@ -2963,11 +2963,18 @@ const processFileGroup = (
|
|||
(result.routerModuleAliases ??= []),
|
||||
(result.routerConstructorPrefixes ??= []),
|
||||
);
|
||||
// #2391: harvest module-level string constants + from-imports so parse-impl
|
||||
// can resolve non-literal decorator route paths cross-file. Only emit for
|
||||
// files that carry something resolvable (a constant definition or an import
|
||||
// binding) to keep the aggregate bounded on large repos.
|
||||
const constants = extractPythonModuleConstants(tree);
|
||||
}
|
||||
|
||||
// #2391/#2980: harvest module-level string constants + import bindings via
|
||||
// the provider hook so parse-impl can resolve non-literal decorator route
|
||||
// paths cross-file. Cost-gated by the provider's syntax-driven heuristic;
|
||||
// only files that carry something resolvable (a constant definition or an
|
||||
// import binding) are emitted, keeping the aggregate bounded on large repos.
|
||||
// A provider that declares no heuristic harvests unconditionally — see
|
||||
// `shouldHarvestModuleConstants`, which owns that rule so it can be tested
|
||||
// without booting a worker.
|
||||
if (provider.extractModuleConstants && shouldHarvestModuleConstants(provider, parseContent)) {
|
||||
const constants = provider.extractModuleConstants(tree);
|
||||
if (constants.literals.size > 0 || constants.exprs.size > 0 || constants.imports.size > 0) {
|
||||
(result.moduleConstants ??= []).push({ filePath: file.path, constants });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -538,7 +538,38 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
|
|||
// cache would replay unchanged worker results without those routes. Version 70
|
||||
// then adds Spring non-HTTP handler side-channel facts (#2417 / #2891), so Java
|
||||
// and Kotlin caches persist scheduled, event, messaging, and managed-job facts.
|
||||
const SCHEMA_BUMP = 70;
|
||||
//
|
||||
// 70 -> 71 adds the Java constant-route capture set (#2980):
|
||||
// `route-extractors/java-const-resolver.ts`, the `spring.ts` operand branch,
|
||||
// and the parse-worker's provider-driven constant harvest. A warm pre-feature
|
||||
// cache replays those files' worker results with `moduleConstants` absent and
|
||||
// `routePathOperands` unset, so every constant-based Spring route on an
|
||||
// unchanged file is silently dropped — the feature is inert until something
|
||||
// else invalidates the cache.
|
||||
//
|
||||
// This branch briefly reasoned that no bump was needed because the ledger
|
||||
// "already sits at 70, whose capture set post-dates and includes this harvest".
|
||||
// It does not: v70 was cut by fe3d7e56b for Spring non-HTTP handler facts
|
||||
// (#2417 / #2891), an ancestor of this PR's base, and it cannot include a
|
||||
// harvest that does not exist on main. Because
|
||||
// `PARSE_CACHE_VERSION = ${SCHEMA_BUMP}+${GITNEXUS_PKG_VERSION}` and
|
||||
// package.json is untouched here, leaving 70 makes the key BYTE-IDENTICAL
|
||||
// before and after this merge — precisely the inert-feature trap the v33/v34
|
||||
// notes above warn about. Exposure is bounded by the package version (a
|
||||
// released upgrade invalidates anyway), but same-version warm caches — dev
|
||||
// builds, CI caches, anyone who indexed with an unreleased build — replay the
|
||||
// stale captures.
|
||||
//
|
||||
// 72, not 71: open PR #3017 (`fix/nest-decorator-routes`, NestJS decorator route
|
||||
// indexing) already claims 71, with an identical pin test. Re-checking
|
||||
// origin/main alone would not catch that — main is 70 and stays 70 until one of
|
||||
// the two merges, at which point the second lands a byte-identical
|
||||
// PARSE_CACHE_VERSION and is inert. This is exactly the rule the ledger states
|
||||
// and the v37/v38 clash it was written for: the next free value above every
|
||||
// IN-FLIGHT claim, not above origin/main. Every open PR touching gitnexus/ was
|
||||
// scanned; #3017 is the only other claimant.
|
||||
// RE-CHECK AGAINST origin/main AND OPEN PRs IMMEDIATELY BEFORE MERGING.
|
||||
const SCHEMA_BUMP = 72;
|
||||
const GITNEXUS_PKG_VERSION = (() => {
|
||||
try {
|
||||
// package.json sits at gitnexus/package.json — two levels up from
|
||||
|
|
|
|||
|
|
@ -28,11 +28,9 @@
|
|||
* has stopped resolving anything at all, so counting alone would stay green
|
||||
* while every PHP IMPORTS edge disappeared.
|
||||
*
|
||||
* On the one traversal PHP still pays per import in a specific case — a PSR-4
|
||||
* namespace whose directory has no direct `.php` children — see the pinned
|
||||
* residual arm at the bottom of the unit parity test. It lives in
|
||||
* `import-resolvers/php.ts`, which #2901 does not touch, so the corpora here
|
||||
* resolve through the legs that do reach the index.
|
||||
* The no-Composer arm below separately pins a proper suffix hit and a root-file
|
||||
* miss. That pair guards the PHP parity view itself; a raw shared-index handoff
|
||||
* would make the root file resolve even though the traversal count stayed one.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { phpScopeResolver } from '../../src/core/ingestion/languages/php/scope-resolver.js';
|
||||
|
|
@ -73,9 +71,8 @@ describe('PHP import resolution — index reuse across use-statements (#2901)',
|
|||
|
||||
for (let i = 0; i < 200; i++) {
|
||||
// A PSR-4 class hit, a function import that falls back to the namespace
|
||||
// directory, and a third-party namespace that misses. The miss is the
|
||||
// expensive case: it matches no PSR-4 prefix and so walks every suffix ×
|
||||
// every extension before returning null.
|
||||
// directory, and a third-party namespace that the Composer authority
|
||||
// gate rejects before suffix fallback.
|
||||
resolved.push(resolveImportTarget('App\\Models\\User', FROM_FILE, files, COMPOSER));
|
||||
resolved.push(resolveImportTarget('App\\Models\\getUser', FROM_FILE, files, COMPOSER));
|
||||
resolved.push(resolveImportTarget(`Psr\\Log\\Missing${i}`, FROM_FILE, files, COMPOSER));
|
||||
|
|
@ -99,12 +96,14 @@ describe('PHP import resolution — index reuse across use-statements (#2901)',
|
|||
// that used to cost a `findIndex` pass per extension — as the only path.
|
||||
for (let i = 0; i < 200; i++) {
|
||||
resolved.push(resolveImportTarget('Legacy\\Helper', FROM_FILE, files, null));
|
||||
resolved.push(resolveImportTarget('index', FROM_FILE, files, null));
|
||||
resolved.push(resolveImportTarget(`Psr\\Log\\Missing${i}`, FROM_FILE, files, null));
|
||||
}
|
||||
|
||||
expect(files.scans).toBe(1);
|
||||
expect(resolved[0]).toBe('lib/Legacy/Helper.php');
|
||||
expect(resolved[1]).toBeNull();
|
||||
expect(resolved[2]).toBeNull();
|
||||
});
|
||||
|
||||
it('a distinct file set gets its own index (no stale cross-run reuse)', () => {
|
||||
|
|
@ -130,11 +129,9 @@ describe('PHP import resolution — index reuse across use-statements (#2901)',
|
|||
'app/Services/Service00000.php',
|
||||
);
|
||||
|
||||
// Suffix fallback: no PSR-4 prefix matches `Legacy`, so `suffixResolve`
|
||||
// answers from the longest matching proper path suffix.
|
||||
expect(resolveImportTarget('Legacy\\Helper', FROM_FILE, files, COMPOSER)).toBe(
|
||||
'lib/Legacy/Helper.php',
|
||||
);
|
||||
// Composer's non-empty PSR-4 map is authoritative: an unmatched namespace
|
||||
// belongs outside the repository and cannot fall through to a local suffix.
|
||||
expect(resolveImportTarget('Legacy\\Helper', FROM_FILE, files, COMPOSER)).toBeNull();
|
||||
|
||||
// A root-level file is NOT reachable as a proper suffix — the pre-#2901
|
||||
// behaviour the parity view preserves, and the single most likely thing a
|
||||
|
|
|
|||
213
gitnexus/test/unit/group/java-const-route-parity.test.ts
Normal file
213
gitnexus/test/unit/group/java-const-route-parity.test.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
/**
|
||||
* Group ↔ ingestion parity for Java constant-valued Spring route paths (#2980).
|
||||
*
|
||||
* Drives `JAVA_HTTP_PLUGIN.prepareRepo` + `scan(tree, ctx, rel)` with all three
|
||||
* arguments and compares the result against what `extractSpringRoutes` + the
|
||||
* Java operand fold produce on the ingestion side. The existing Spring parity
|
||||
* guards call `scan(tree)` with ONE argument, which makes them structurally
|
||||
* blind here: without a repo context the plugin drops every constant-valued
|
||||
* route, so no fixture they carry can exercise this feature.
|
||||
*
|
||||
* Asserted:
|
||||
* • a constant-valued mapping resolves to the SAME path on both sides;
|
||||
* • a CONSTANT class prefix suppresses the method route on both sides — the
|
||||
* prefix cannot be folded at extraction time, and emitting the route
|
||||
* unprefixed would publish a path the application does not serve;
|
||||
* • without a repo context the group side emits nothing (the documented skip
|
||||
* floor, and the branch that makes the 1-arg guards blind);
|
||||
* • literal routes are untouched.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import Parser from 'tree-sitter';
|
||||
import Java from 'tree-sitter-java';
|
||||
import { JAVA_HTTP_PLUGIN } from '../../../src/core/group/extractors/http-patterns/java.js';
|
||||
import type { HttpDetection } from '../../../src/core/group/extractors/http-patterns/types.js';
|
||||
import { extractSpringRoutes } from '../../../src/core/ingestion/route-extractors/spring.js';
|
||||
import { javaProvider } from '../../../src/core/ingestion/languages/java.js';
|
||||
import {
|
||||
extractJavaModuleConstants,
|
||||
foldJavaOperands,
|
||||
type RepoConstants,
|
||||
} from '../../../src/core/ingestion/route-extractors/java-const-resolver.js';
|
||||
|
||||
const parser = new Parser();
|
||||
const parseSource = (p: Parser, src: string): Parser.Tree => {
|
||||
p.setLanguage(Java);
|
||||
return p.parse(src);
|
||||
};
|
||||
const parse = (src: string): Parser.Tree => parseSource(parser, src);
|
||||
|
||||
/** Group side: prepareRepo + a 3-argument scan over every .java file. */
|
||||
function groupProviders(files: Record<string, string>): string[] {
|
||||
const ctx = JAVA_HTTP_PLUGIN.prepareRepo?.({
|
||||
files: Object.keys(files),
|
||||
parser: new Parser(),
|
||||
readFile: (rel: string) => files[rel] ?? null,
|
||||
parseSource,
|
||||
});
|
||||
const out: string[] = [];
|
||||
for (const rel of Object.keys(files)) {
|
||||
const detections: HttpDetection[] = JAVA_HTTP_PLUGIN.scan(parse(files[rel]), ctx, rel);
|
||||
for (const d of detections) {
|
||||
if (d.role === 'provider') out.push(`${d.method} ${d.path}`);
|
||||
}
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
/** Ingestion side: extract routes, then fold operands against the same map. */
|
||||
function ingestionRoutes(files: Record<string, string>): string[] {
|
||||
const repo: RepoConstants = new Map();
|
||||
for (const [rel, src] of Object.entries(files)) {
|
||||
repo.set(rel, extractJavaModuleConstants(parse(src)));
|
||||
}
|
||||
const out: string[] = [];
|
||||
for (const [rel, src] of Object.entries(files)) {
|
||||
for (const route of extractSpringRoutes(parse(src), rel, 0)) {
|
||||
const path = route.routePathOperands
|
||||
? foldJavaOperands(rel, route.routePathOperands, repo)
|
||||
: route.routePath;
|
||||
if (path === null) continue;
|
||||
out.push(`${route.httpMethod} ${`${route.prefix ?? ''}${path}`.replace(/\/{2,}/g, '/')}`);
|
||||
}
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
const CONSTS = 'src/main/java/com/example/ApiPaths.java';
|
||||
const CTL = 'src/main/java/com/example/OrderController.java';
|
||||
|
||||
const CONSTS_SRC = `package com.example;
|
||||
public class ApiPaths {
|
||||
public static final String BASE = "/api/v1";
|
||||
public static final String ORDERS = "/api/v1/orders";
|
||||
}`;
|
||||
|
||||
describe('Java constant-valued routes: group ↔ ingestion parity (#2980)', () => {
|
||||
it('resolves a constant-valued mapping to the same path on both sides', () => {
|
||||
const files = {
|
||||
[CONSTS]: CONSTS_SRC,
|
||||
[CTL]: `package com.example;
|
||||
import com.example.ApiPaths;
|
||||
public class OrderController {
|
||||
@GetMapping(ApiPaths.ORDERS)
|
||||
public void list() {}
|
||||
}`,
|
||||
};
|
||||
expect(groupProviders(files)).toEqual(['GET /api/v1/orders']);
|
||||
expect(ingestionRoutes(files)).toEqual(groupProviders(files));
|
||||
});
|
||||
|
||||
it('suppresses the method route under a CONSTANT class prefix on both sides', () => {
|
||||
// The class prefix needs the repo-wide constant map, which does not exist
|
||||
// at extraction time on either side. Emitting the method route would drop
|
||||
// the prefix and publish `GET /api/v1/orders`-without-its-base — a path the
|
||||
// application never serves. On base such a route was not emitted at all, so
|
||||
// shipping it unprefixed would turn a missing fact into a wrong one.
|
||||
const files = {
|
||||
[CONSTS]: CONSTS_SRC,
|
||||
[CTL]: `package com.example;
|
||||
import com.example.ApiPaths;
|
||||
@RequestMapping(ApiPaths.BASE)
|
||||
public class OrderController {
|
||||
@GetMapping(ApiPaths.ORDERS)
|
||||
public void list() {}
|
||||
|
||||
@GetMapping("/literal")
|
||||
public void literal() {}
|
||||
}`,
|
||||
};
|
||||
expect(groupProviders(files)).toEqual([]);
|
||||
expect(ingestionRoutes(files)).toEqual([]);
|
||||
});
|
||||
|
||||
it('still applies a LITERAL class prefix', () => {
|
||||
const files = {
|
||||
[CONSTS]: CONSTS_SRC,
|
||||
[CTL]: `package com.example;
|
||||
import com.example.ApiPaths;
|
||||
@RequestMapping("/api/v1")
|
||||
public class OrderController {
|
||||
@GetMapping("/orders")
|
||||
public void list() {}
|
||||
}`,
|
||||
};
|
||||
expect(groupProviders(files)).toEqual(['GET /api/v1/orders']);
|
||||
expect(ingestionRoutes(files)).toEqual(['GET /api/v1/orders']);
|
||||
});
|
||||
|
||||
it('emits nothing for a constant route when scanned without a repo context', () => {
|
||||
// This is the branch that makes the 1-argument parity guards blind to the
|
||||
// whole feature; pin it so it is not silently dead in the suite.
|
||||
const src = `package com.example;
|
||||
import com.example.ApiPaths;
|
||||
public class OrderController {
|
||||
@GetMapping(ApiPaths.ORDERS)
|
||||
public void list() {}
|
||||
}`;
|
||||
const detections = JAVA_HTTP_PLUGIN.scan(parse(src));
|
||||
expect(detections.filter((d) => d.role === 'provider')).toEqual([]);
|
||||
});
|
||||
|
||||
it('suppresses a NO-ARGUMENT mapping under a constant class prefix on both sides', () => {
|
||||
// A bare `@GetMapping` IS the class prefix, so an unfoldable class prefix
|
||||
// leaves nothing to emit. Ingestion routes these through a separate loop
|
||||
// from the path-carrying ones, and that loop needs the same guard — without
|
||||
// it ingestion emitted an empty-path Route where the group emitted nothing.
|
||||
const files = {
|
||||
[CONSTS]: CONSTS_SRC,
|
||||
[CTL]: `package com.example;
|
||||
import com.example.ApiPaths;
|
||||
@RequestMapping(ApiPaths.BASE)
|
||||
public class OrderController {
|
||||
@GetMapping public void list() {}
|
||||
@PostMapping public void create() {}
|
||||
}`,
|
||||
};
|
||||
expect(ingestionRoutes(files)).toEqual([]);
|
||||
expect(groupProviders(files)).toEqual([]);
|
||||
});
|
||||
|
||||
it('measures import ambiguity over the same candidate set on both sides', () => {
|
||||
// Ingestion's harvest gate also admits import-only files, so its repo map is
|
||||
// a superset of the group's. When ambiguity was measured over every key, a
|
||||
// duplicate FQN belonging to a class that defines NOTHING was invisible to
|
||||
// the group and made ingestion alone floor to skip — reopening the very
|
||||
// parity break this feature exists to close. Both sides now measure over
|
||||
// constant-DEFINING files only.
|
||||
const files = {
|
||||
'svc-a/src/main/java/com/x/ApiPaths.java': `package com.x;
|
||||
public class ApiPaths { public static final String ORDERS = "/api/v1/orders"; }`,
|
||||
// Same FQN, different module, defines no constant — must not create ambiguity.
|
||||
'svc-b/src/main/java/com/x/ApiPaths.java': `package com.x;
|
||||
import java.util.List;
|
||||
public class ApiPaths {}`,
|
||||
'svc-a/src/main/java/com/x/web/OrderController.java': `package com.x.web;
|
||||
import com.x.ApiPaths;
|
||||
public class OrderController {
|
||||
@GetMapping(ApiPaths.ORDERS)
|
||||
public void list() {}
|
||||
}`,
|
||||
};
|
||||
// Guard the premise: the two maps really are different sizes.
|
||||
const ingestionKeys = Object.entries(files).filter(([, src]) =>
|
||||
javaProvider.moduleConstantHeuristic?.(src),
|
||||
).length;
|
||||
expect(ingestionKeys).toBe(3);
|
||||
expect(groupProviders(files)).toEqual(['GET /api/v1/orders']);
|
||||
expect(ingestionRoutes(files)).toEqual(['GET /api/v1/orders']);
|
||||
});
|
||||
|
||||
it('leaves literal routes unchanged with no constant map at all', () => {
|
||||
const files = {
|
||||
[CTL]: `package com.example;
|
||||
public class OrderController {
|
||||
@PostMapping("/api/v1/orders")
|
||||
public void create() {}
|
||||
}`,
|
||||
};
|
||||
expect(groupProviders(files)).toEqual(['POST /api/v1/orders']);
|
||||
expect(ingestionRoutes(files)).toEqual(['POST /api/v1/orders']);
|
||||
});
|
||||
});
|
||||
845
gitnexus/test/unit/group/js-http-consumer-resolution.test.ts
Normal file
845
gitnexus/test/unit/group/js-http-consumer-resolution.test.ts
Normal file
|
|
@ -0,0 +1,845 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import Parser from 'tree-sitter';
|
||||
import JavaScript from 'tree-sitter-javascript';
|
||||
import TypeScript from 'tree-sitter-typescript';
|
||||
import {
|
||||
TYPESCRIPT_HTTP_PLUGIN,
|
||||
JAVASCRIPT_HTTP_PLUGIN,
|
||||
} from '../../../src/core/group/extractors/http-patterns/node.js';
|
||||
import { resolveJsImport } from '../../../src/core/ingestion/route-extractors/js-const-resolver.js';
|
||||
import type { HttpDetection } from '../../../src/core/group/extractors/http-patterns/types.js';
|
||||
|
||||
const tsParser = new Parser();
|
||||
tsParser.setLanguage(TypeScript.typescript);
|
||||
|
||||
// Compiled tree-sitter queries are grammar-bound, so a plugin must be driven
|
||||
// with a tree parsed by ITS grammar.
|
||||
const jsParser = new Parser();
|
||||
jsParser.setLanguage(JavaScript);
|
||||
|
||||
/**
|
||||
* Drive the plugin the way the orchestrator does: a `prepareRepo` pre-pass over
|
||||
* a virtual repo, then a per-file `scan` with the resulting context.
|
||||
*/
|
||||
function scanRepo(files: Record<string, string>, target: string): HttpDetection[] {
|
||||
const paths = Object.keys(files);
|
||||
const repoContext = TYPESCRIPT_HTTP_PLUGIN.prepareRepo?.({
|
||||
repoPath: '/repo',
|
||||
files: paths,
|
||||
parser: tsParser,
|
||||
readFile: (rel) => files[rel] ?? null,
|
||||
parseSource: (parser, src) => parser.parse(src),
|
||||
});
|
||||
return TYPESCRIPT_HTTP_PLUGIN.scan(tsParser.parse(files[target]), repoContext, target);
|
||||
}
|
||||
|
||||
const consumers = (detections: HttpDetection[]) => detections.filter((d) => d.role === 'consumer');
|
||||
|
||||
/** `scanRepo`, but the pre-pass may fail on chosen files. */
|
||||
function scanRepoWithParse(
|
||||
files: Record<string, string>,
|
||||
target: string,
|
||||
parseSource: (parser: Parser, src: string) => Parser.Tree | null,
|
||||
): HttpDetection[] {
|
||||
const repoContext = TYPESCRIPT_HTTP_PLUGIN.prepareRepo?.({
|
||||
repoPath: '/repo',
|
||||
files: Object.keys(files),
|
||||
parser: tsParser,
|
||||
readFile: (rel) => files[rel] ?? null,
|
||||
parseSource,
|
||||
});
|
||||
return TYPESCRIPT_HTTP_PLUGIN.scan(tsParser.parse(files[target]), repoContext, target);
|
||||
}
|
||||
|
||||
// The shape the finding was reported against: a configured client in one file,
|
||||
// a frozen route table in another, and call sites that reference both by name.
|
||||
const AXIOS_CONFIG = `
|
||||
import axios from 'axios';
|
||||
const axiosInstance = axios.create({ baseURL: process.env.API_URL });
|
||||
const routeApiClient = axiosInstance;
|
||||
export default routeApiClient;
|
||||
`;
|
||||
|
||||
const API_ROUTES = `
|
||||
export const API_ROUTE_PATH = {
|
||||
LINKS: "/links",
|
||||
EVENTS: "/events",
|
||||
CURATOR_LISTS: "/curator-lists",
|
||||
} as const;
|
||||
`;
|
||||
|
||||
describe('JS/TS HTTP consumer resolution', () => {
|
||||
it('resolves a configured client and a table path imported from other files', () => {
|
||||
const detections = scanRepo(
|
||||
{
|
||||
'src/lib/axios.config.ts': AXIOS_CONFIG,
|
||||
'src/api-modules/shared/api-routes.ts': API_ROUTES,
|
||||
'src/api-modules/curators/curators.api.ts': `
|
||||
import routeApiClient from '@/lib/axios.config';
|
||||
import { API_ROUTE_PATH } from '@/api-modules/shared/api-routes';
|
||||
export async function getLists() {
|
||||
return routeApiClient.get(API_ROUTE_PATH.CURATOR_LISTS, {});
|
||||
}
|
||||
`,
|
||||
},
|
||||
'src/api-modules/curators/curators.api.ts',
|
||||
);
|
||||
|
||||
expect(consumers(detections)).toContainEqual(
|
||||
expect.objectContaining({ role: 'consumer', method: 'GET', path: '/curator-lists' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves relative imports for the client and the route table', () => {
|
||||
const detections = scanRepo(
|
||||
{
|
||||
'src/lib/axios.config.ts': AXIOS_CONFIG,
|
||||
'src/api/routes.ts': API_ROUTES,
|
||||
'src/api/links.api.ts': `
|
||||
import client from '../lib/axios.config';
|
||||
import { API_ROUTE_PATH } from './routes';
|
||||
export const load = () => client.post(API_ROUTE_PATH.LINKS);
|
||||
`,
|
||||
},
|
||||
'src/api/links.api.ts',
|
||||
);
|
||||
|
||||
expect(consumers(detections)).toContainEqual(
|
||||
expect.objectContaining({ method: 'POST', path: '/links' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('folds a template partially, keeping the resolved prefix', () => {
|
||||
const detections = scanRepo(
|
||||
{
|
||||
'src/lib/axios.config.ts': AXIOS_CONFIG,
|
||||
'src/api/routes.ts': API_ROUTES,
|
||||
'src/api/curators.api.ts': `
|
||||
import client from '../lib/axios.config';
|
||||
import { API_ROUTE_PATH } from './routes';
|
||||
export const add = (eventId: string) =>
|
||||
client.post(\`\${API_ROUTE_PATH.CURATOR_LISTS}/\${eventId}/add-to-list\`);
|
||||
`,
|
||||
},
|
||||
'src/api/curators.api.ts',
|
||||
);
|
||||
|
||||
// The unresolvable `${eventId}` stays a placeholder for consumer-side
|
||||
// normalization to read as {param}; the known prefix is no longer lost.
|
||||
expect(consumers(detections)).toContainEqual(
|
||||
expect.objectContaining({ path: '/curator-lists/${eventId}/add-to-list' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves a `+` concatenation against an imported base constant', () => {
|
||||
const detections = scanRepo(
|
||||
{
|
||||
'src/lib/axios.config.ts': AXIOS_CONFIG,
|
||||
'src/api/base.ts': `export const BASE = "/api/v1";`,
|
||||
'src/api/users.api.ts': `
|
||||
import client from '../lib/axios.config';
|
||||
import { BASE } from './base';
|
||||
export const list = () => client.get(BASE + "/users");
|
||||
`,
|
||||
},
|
||||
'src/api/users.api.ts',
|
||||
);
|
||||
|
||||
expect(consumers(detections)).toContainEqual(
|
||||
expect.objectContaining({ method: 'GET', path: '/api/v1/users' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('follows a barrel re-export to the defining module', () => {
|
||||
const detections = scanRepo(
|
||||
{
|
||||
'src/lib/axios.config.ts': AXIOS_CONFIG,
|
||||
'src/api/routes.ts': API_ROUTES,
|
||||
'src/api/index.ts': `export { API_ROUTE_PATH } from './routes';`,
|
||||
'src/api/events.api.ts': `
|
||||
import client from '../lib/axios.config';
|
||||
import { API_ROUTE_PATH } from './index';
|
||||
export const list = () => client.get(API_ROUTE_PATH.EVENTS);
|
||||
`,
|
||||
},
|
||||
'src/api/events.api.ts',
|
||||
);
|
||||
|
||||
expect(consumers(detections)).toContainEqual(
|
||||
expect.objectContaining({ method: 'GET', path: '/events' }),
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Shapes real applications actually ship ────────────────────────
|
||||
|
||||
it('proves a client built by a factory wrapper, not just a bare axios.create', () => {
|
||||
const detections = scanRepo(
|
||||
{
|
||||
// The shape Sourcerer-fe ships: the instance is an argument to a
|
||||
// decorator that returns the configured client.
|
||||
'src/lib/axios.config.ts': `
|
||||
import axios from 'axios';
|
||||
const routeApiClient = setupClientInterceptors({
|
||||
axiosInstance: axios.create({ baseURL: API_URL }),
|
||||
onError: (e) => e,
|
||||
});
|
||||
export default routeApiClient;
|
||||
`,
|
||||
'src/api/routes.ts': API_ROUTES,
|
||||
'src/api/links.api.ts': `
|
||||
import routeApiClient from '@/lib/axios.config';
|
||||
import { API_ROUTE_PATH } from '@/api/routes';
|
||||
export const load = () => routeApiClient.get(API_ROUTE_PATH.LINKS);
|
||||
`,
|
||||
},
|
||||
'src/api/links.api.ts',
|
||||
);
|
||||
|
||||
expect(consumers(detections)).toContainEqual(
|
||||
expect.objectContaining({ method: 'GET', path: '/links' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('follows `export *` through a directory barrel', () => {
|
||||
const detections = scanRepo(
|
||||
{
|
||||
'src/lib/axios.config.ts': AXIOS_CONFIG,
|
||||
'src/api-modules/shared/api-routes.ts': API_ROUTES,
|
||||
'src/api-modules/shared/index.ts': `
|
||||
export * from "./api-routes";
|
||||
export * from "./query-keys";
|
||||
`,
|
||||
'src/api-modules/shared/query-keys.ts': `export const QUERY_KEYS = { A: "a" };`,
|
||||
'src/api-modules/curators/curators.api.ts': `
|
||||
import client from '@/lib/axios.config';
|
||||
import { API_ROUTE_PATH } from '@/api-modules/shared';
|
||||
export const get = () => client.get(API_ROUTE_PATH.CURATOR_LISTS);
|
||||
`,
|
||||
},
|
||||
'src/api-modules/curators/curators.api.ts',
|
||||
);
|
||||
|
||||
expect(consumers(detections)).toContainEqual(
|
||||
expect.objectContaining({ method: 'GET', path: '/curator-lists' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('recognizes an aliased axios import', () => {
|
||||
const detections = scanRepo(
|
||||
{
|
||||
'src/lib/client.ts': `
|
||||
import ax from 'axios';
|
||||
export default ax.create({ baseURL: '/' });
|
||||
`,
|
||||
'src/api/routes.ts': API_ROUTES,
|
||||
'src/api/events.api.ts': `
|
||||
import client from '../lib/client';
|
||||
import { API_ROUTE_PATH } from './routes';
|
||||
export const list = () => client.get(API_ROUTE_PATH.EVENTS);
|
||||
`,
|
||||
},
|
||||
'src/api/events.api.ts',
|
||||
);
|
||||
|
||||
expect(consumers(detections)).toContainEqual(
|
||||
expect.objectContaining({ method: 'GET', path: '/events' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps literal segments of a template nested inside a substitution', () => {
|
||||
const detections = scanRepo(
|
||||
{
|
||||
'src/lib/axios.config.ts': AXIOS_CONFIG,
|
||||
'src/api/routes.ts': API_ROUTES,
|
||||
'src/api/events.api.ts': `
|
||||
import client from '../lib/axios.config';
|
||||
import { API_ROUTE_PATH } from './routes';
|
||||
export const unlike = (id: string) =>
|
||||
client.delete(\`\${API_ROUTE_PATH.EVENTS}\${\`/\${id}/unlike\`}\`);
|
||||
`,
|
||||
},
|
||||
'src/api/events.api.ts',
|
||||
);
|
||||
|
||||
expect(consumers(detections)).toContainEqual(
|
||||
expect.objectContaining({ path: '/events/${id}/unlike' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not let a client built inside a callback vouch for the outer name', () => {
|
||||
const detections = scanRepo(
|
||||
{
|
||||
'src/thing.ts': `
|
||||
const thing = configure(() => axios.create({ baseURL: '/' }));
|
||||
export const read = () => thing.get('/users');
|
||||
`,
|
||||
},
|
||||
'src/thing.ts',
|
||||
);
|
||||
|
||||
expect(consumers(detections)).toEqual([]);
|
||||
});
|
||||
|
||||
// ─── Precision guards ──────────────────────────────────────────────
|
||||
|
||||
it('does NOT emit an Express provider route as a consumer of itself', () => {
|
||||
const detections = scanRepo(
|
||||
{
|
||||
'src/server.ts': `
|
||||
import express from 'express';
|
||||
const router = express.Router();
|
||||
router.get('/users', listUsers);
|
||||
app.post('/orders', createOrder);
|
||||
`,
|
||||
},
|
||||
'src/server.ts',
|
||||
);
|
||||
|
||||
expect(consumers(detections)).toEqual([]);
|
||||
// …while still being seen as providers.
|
||||
expect(detections.filter((d) => d.role === 'provider').map((d) => d.path)).toEqual(
|
||||
expect.arrayContaining(['/users', '/orders']),
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT claim an unproven receiver that merely has a .get method', () => {
|
||||
const detections = scanRepo(
|
||||
{
|
||||
'src/cache.ts': `
|
||||
const cache = new Map<string, string>();
|
||||
const store = { get: (k: string) => k };
|
||||
export const read = () => cache.get('/users') ?? store.get('/orders');
|
||||
`,
|
||||
},
|
||||
'src/cache.ts',
|
||||
);
|
||||
|
||||
expect(consumers(detections)).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses to resolve an import whose specifier matches two files', () => {
|
||||
const detections = scanRepo(
|
||||
{
|
||||
'src/lib/axios.config.ts': AXIOS_CONFIG,
|
||||
'a/shared/routes.ts': API_ROUTES,
|
||||
'b/shared/routes.ts': `export const API_ROUTE_PATH = { LINKS: "/other-links" } as const;`,
|
||||
'src/api/links.api.ts': `
|
||||
import client from '../lib/axios.config';
|
||||
import { API_ROUTE_PATH } from 'shared/routes';
|
||||
export const load = () => client.get(API_ROUTE_PATH.LINKS);
|
||||
`,
|
||||
},
|
||||
'src/api/links.api.ts',
|
||||
);
|
||||
|
||||
// Two candidates for `shared/routes` — an unresolved path is correct here;
|
||||
// guessing either one would invent a cross-repo link.
|
||||
expect(consumers(detections)).toEqual([]);
|
||||
});
|
||||
|
||||
// ─── Backward compatibility ────────────────────────────────────────
|
||||
|
||||
it('still detects a bare axios call with a literal path and no repo context', () => {
|
||||
const detections = JAVASCRIPT_HTTP_PLUGIN.scan(
|
||||
jsParser.parse(`axios.get('/legacy'); axios.post('/legacy', body);`),
|
||||
);
|
||||
|
||||
expect(consumers(detections)).toEqual([
|
||||
expect.objectContaining({ method: 'GET', path: '/legacy' }),
|
||||
expect.objectContaining({ method: 'POST', path: '/legacy' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves the raw template when there is no repo context to fold against', () => {
|
||||
const detections = JAVASCRIPT_HTTP_PLUGIN.scan(jsParser.parse('axios.get(`/users/${id}`);'));
|
||||
|
||||
expect(consumers(detections)).toContainEqual(expect.objectContaining({ path: '/users/${id}' }));
|
||||
});
|
||||
|
||||
it('drops a non-literal path it cannot resolve rather than emitting its text', () => {
|
||||
const detections = JAVASCRIPT_HTTP_PLUGIN.scan(
|
||||
jsParser.parse(`axios.get(API_ROUTE_PATH.LINKS);`),
|
||||
);
|
||||
|
||||
expect(consumers(detections)).toEqual([]);
|
||||
});
|
||||
|
||||
// ─── Review findings: precision, termination and keying ────────────
|
||||
|
||||
it('keys the fact map the same way on a platform that hands it backslashes', () => {
|
||||
// glob v13 is called without `posix: true` and its walker joins with the
|
||||
// platform separator, so on Windows every path here arrives backslashed.
|
||||
const files = {
|
||||
'src\\lib\\axios.config.ts': AXIOS_CONFIG,
|
||||
'src\\api\\routes.ts': API_ROUTES,
|
||||
'src\\api\\links.api.ts': `
|
||||
import client from '../lib/axios.config';
|
||||
import { API_ROUTE_PATH } from './routes';
|
||||
export const load = () => client.get(API_ROUTE_PATH.LINKS);
|
||||
`,
|
||||
};
|
||||
|
||||
expect(consumers(scanRepo(files, 'src\\api\\links.api.ts'))).toContainEqual(
|
||||
expect.objectContaining({ method: 'GET', path: '/links' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT treat a container that merely HOLDS an axios instance as a client', () => {
|
||||
const detections = scanRepo(
|
||||
{
|
||||
'src/stores.ts': `
|
||||
import axios from 'axios';
|
||||
const registry = { http: axios.create({ baseURL: '/' }), version: 'v1' };
|
||||
const picked = MOCK ? memoryStore : axios.create({ baseURL: '/' });
|
||||
const pool = new Map([['api', axios.create({ baseURL: '/' })]]);
|
||||
export const read = () => [
|
||||
registry.get('/settings'),
|
||||
picked.get('/feature-flags'),
|
||||
pool.get('/tenant'),
|
||||
];
|
||||
`,
|
||||
},
|
||||
'src/stores.ts',
|
||||
);
|
||||
|
||||
expect(consumers(detections)).toEqual([]);
|
||||
});
|
||||
|
||||
it('still proves the factory shape the containment rule existed for', () => {
|
||||
const detections = scanRepo(
|
||||
{
|
||||
'src/lib/client.ts': `
|
||||
import axios from 'axios';
|
||||
export default withRetries(setupInterceptors(axios.create({ baseURL: '/' })));
|
||||
`,
|
||||
'src/api/routes.ts': API_ROUTES,
|
||||
'src/api/links.api.ts': `
|
||||
import client from '../lib/client';
|
||||
import { API_ROUTE_PATH } from './routes';
|
||||
export const load = () => client.get(API_ROUTE_PATH.LINKS);
|
||||
`,
|
||||
},
|
||||
'src/api/links.api.ts',
|
||||
);
|
||||
|
||||
expect(consumers(detections)).toContainEqual(
|
||||
expect.objectContaining({ method: 'GET', path: '/links' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a resolved constant that is not path-shaped', () => {
|
||||
const detections = scanRepo(
|
||||
{
|
||||
'src/lib/axios.config.ts': AXIOS_CONFIG,
|
||||
'src/api/strings.ts': `
|
||||
export const CONFIG = { TIMEOUT: "5000" } as const;
|
||||
export const MSG = { ERROR: "Could not reach the server" } as const;
|
||||
`,
|
||||
'src/api/calls.api.ts': `
|
||||
import api from '../lib/axios.config';
|
||||
import { CONFIG, MSG } from './strings';
|
||||
export const a = () => api.get(CONFIG.TIMEOUT);
|
||||
export const b = () => api.post(MSG.ERROR);
|
||||
`,
|
||||
},
|
||||
'src/api/calls.api.ts',
|
||||
);
|
||||
|
||||
// "5000" normalizes to /{param} and matches every one-segment provider
|
||||
// route in the group; the message normalizes to a path with spaces in it.
|
||||
expect(consumers(detections)).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps an all-numeric path that is written as a path', () => {
|
||||
const detections = scanRepo(
|
||||
{
|
||||
'src/lib/axios.config.ts': AXIOS_CONFIG,
|
||||
'src/api/legacy.api.ts': `
|
||||
import api from '../lib/axios.config';
|
||||
export const load = () => api.get('/123');
|
||||
`,
|
||||
},
|
||||
'src/api/legacy.api.ts',
|
||||
);
|
||||
|
||||
// The leading slash is what separates a route from a folded timeout; the
|
||||
// consumer normalizer reads the segment as {param} either way.
|
||||
expect(consumers(detections)).toContainEqual(
|
||||
expect.objectContaining({ method: 'GET', path: '/123' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a path whose leading term never resolved', () => {
|
||||
const detections = scanRepo(
|
||||
{
|
||||
'src/lib/axios.config.ts': AXIOS_CONFIG,
|
||||
'src/api/unanchored.api.ts': `
|
||||
import client from '../lib/axios.config';
|
||||
const BASE = process.env.NEXT_PUBLIC_API_URL;
|
||||
export const a = (x, y) => client.get(\`\${x}\${y}\`);
|
||||
export const b = () => client.get(BASE + '/users');
|
||||
`,
|
||||
},
|
||||
'src/api/unanchored.api.ts',
|
||||
);
|
||||
|
||||
// `${x}${y}` squashes to /{param}{param} and `${BASE}/users` to
|
||||
// /{param}/users — both exact-match real provider routes.
|
||||
expect(consumers(detections)).toEqual([]);
|
||||
});
|
||||
|
||||
it('caps the folded output instead of building a path of unbounded length', () => {
|
||||
const pad = 'a'.repeat(4000);
|
||||
const detections = scanRepo(
|
||||
{
|
||||
'src/lib/axios.config.ts': AXIOS_CONFIG,
|
||||
'src/api/big.api.ts': `
|
||||
import client from '../lib/axios.config';
|
||||
const PAD = "/${pad}";
|
||||
export const load = () => client.get(PAD + PAD + PAD);
|
||||
`,
|
||||
},
|
||||
'src/api/big.api.ts',
|
||||
);
|
||||
|
||||
// Each term is under the core's 8 192-char cap; their concatenation is not,
|
||||
// and the result is persisted into contractId / meta.path.
|
||||
expect(consumers(detections)).toEqual([]);
|
||||
});
|
||||
|
||||
it('terminates on expressions deep enough to overflow the stack', () => {
|
||||
// `scan` is contractually non-throwing: `sync.ts` turns a throw here into an
|
||||
// unexplained "missing repo" that silently drops every contract of every
|
||||
// kind for that repo. Both shapes recursed once per term before this.
|
||||
// 3 000 is near this tree-sitter build's own parse ceiling for a `+` chain;
|
||||
// nested templates parse to ~6 000, and at 4 000 the unbounded fold threw
|
||||
// `RangeError: Maximum call stack size exceeded` straight out of `scan`.
|
||||
const chain = Array.from({ length: 3000 }, (_, i) => `"/s${i}"`).join(' + ');
|
||||
let nested = '`/x`';
|
||||
for (let i = 0; i < 4000; i++) nested = '`${' + nested + '}`';
|
||||
|
||||
expect(() => scanRepo({ 'src/a.ts': `axios.get(${chain});` }, 'src/a.ts')).not.toThrow();
|
||||
expect(() => scanRepo({ 'src/b.ts': `axios.get(${nested});` }, 'src/b.ts')).not.toThrow();
|
||||
});
|
||||
|
||||
it('survives a file whose parse throws, and still resolves the rest of the repo', () => {
|
||||
const detections = scanRepoWithParse(
|
||||
{
|
||||
'src/lib/axios.config.ts': AXIOS_CONFIG,
|
||||
'src/api/routes.ts': API_ROUTES,
|
||||
'src/api/poison.ts': `export const X = "/x";`,
|
||||
'src/api/links.api.ts': `
|
||||
import client from '../lib/axios.config';
|
||||
import { API_ROUTE_PATH } from './routes';
|
||||
export const load = () => client.get(API_ROUTE_PATH.LINKS);
|
||||
`,
|
||||
},
|
||||
'src/api/links.api.ts',
|
||||
(parser, src) => {
|
||||
if (src.includes('"/x"')) throw new Error('ParseTimeoutError');
|
||||
return parser.parse(src);
|
||||
},
|
||||
);
|
||||
|
||||
expect(consumers(detections)).toContainEqual(
|
||||
expect.objectContaining({ method: 'GET', path: '/links' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('sees an axios import declared below the binding that uses it', () => {
|
||||
const detections = scanRepo(
|
||||
{
|
||||
// ES module bindings are hoisted, so this is legal and binds the same `ax`.
|
||||
'src/lib/late.ts': `
|
||||
const client = ax.create({ baseURL: '/' });
|
||||
import ax from 'axios';
|
||||
export default client;
|
||||
`,
|
||||
'src/api/routes.ts': API_ROUTES,
|
||||
'src/api/links.api.ts': `
|
||||
import client from '../lib/late';
|
||||
import { API_ROUTE_PATH } from './routes';
|
||||
export const load = () => client.get(API_ROUTE_PATH.LINKS);
|
||||
`,
|
||||
},
|
||||
'src/api/links.api.ts',
|
||||
);
|
||||
|
||||
expect(consumers(detections)).toContainEqual(
|
||||
expect.objectContaining({ method: 'GET', path: '/links' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a partially folded path whose unresolved term contains spaces', () => {
|
||||
const detections = scanRepo(
|
||||
{
|
||||
'src/lib/axios.config.ts': AXIOS_CONFIG,
|
||||
'src/api/routes.ts': API_ROUTES,
|
||||
'src/api/events.api.ts': `
|
||||
import client from '../lib/axios.config';
|
||||
import { API_ROUTE_PATH } from './routes';
|
||||
export const list = (draft: boolean, page?: number) => [
|
||||
client.get(\`\${API_ROUTE_PATH.EVENTS}/\${draft ? 'draft' : 'live'}\`),
|
||||
client.get(\`\${API_ROUTE_PATH.LINKS}/\${page ?? 1}\`),
|
||||
];
|
||||
`,
|
||||
},
|
||||
'src/api/events.api.ts',
|
||||
);
|
||||
|
||||
// The placeholder is a runtime value that consumer normalization reads as
|
||||
// {param}; its source text is not part of the path shape.
|
||||
expect(consumers(detections).map((d) => d.path)).toEqual(
|
||||
expect.arrayContaining(["/events/${draft ? 'draft' : 'live'}", '/links/${page ?? 1}']),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not remove a detection the literal axios receiver already produced', () => {
|
||||
// Before the query was widened this shape matched and normalized to
|
||||
// /{param}/users. Anchoring applies to what the widening newly admits, not
|
||||
// to output that already shipped.
|
||||
const detections = JAVASCRIPT_HTTP_PLUGIN.scan(
|
||||
jsParser.parse('axios.get(`${API_BASE}/users`); axios.get(`${a}${b}`);'),
|
||||
);
|
||||
|
||||
expect(consumers(detections).map((d) => d.path)).toEqual(['${API_BASE}/users', '${a}${b}']);
|
||||
});
|
||||
|
||||
it('caps the literal fallback of an oversized template too', () => {
|
||||
const pad = 'a'.repeat(9000);
|
||||
const detections = scanRepo(
|
||||
{
|
||||
'src/lib/axios.config.ts': AXIOS_CONFIG,
|
||||
'src/api/big.api.ts': `
|
||||
import client from '../lib/axios.config';
|
||||
export const load = (id: string) => client.get(\`/${pad}\${id}\`);
|
||||
`,
|
||||
},
|
||||
'src/api/big.api.ts',
|
||||
);
|
||||
|
||||
expect(consumers(detections)).toEqual([]);
|
||||
});
|
||||
|
||||
it('proves a client handed to a factory inside a nested options object', () => {
|
||||
const detections = scanRepo(
|
||||
{
|
||||
'src/lib/client.ts': `
|
||||
import axios from 'axios';
|
||||
export default createClient({ transport: { instance: axios.create({}) } });
|
||||
`,
|
||||
'src/lib/composed.ts': `
|
||||
import axios from 'axios';
|
||||
export default compose([axios.create({}), withAuth]);
|
||||
`,
|
||||
'src/api/routes.ts': API_ROUTES,
|
||||
'src/api/links.api.ts': `
|
||||
import nested from '../lib/client';
|
||||
import composed from '../lib/composed';
|
||||
import { API_ROUTE_PATH } from './routes';
|
||||
export const a = () => nested.get(API_ROUTE_PATH.LINKS);
|
||||
export const b = () => composed.get(API_ROUTE_PATH.EVENTS);
|
||||
`,
|
||||
},
|
||||
'src/api/links.api.ts',
|
||||
);
|
||||
|
||||
expect(consumers(detections).map((d) => d.path)).toEqual(
|
||||
expect.arrayContaining(['/links', '/events']),
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT trust the spelling `axios` when the file binds that name itself', () => {
|
||||
const detections = scanRepo(
|
||||
{
|
||||
'src/shadow.ts': `
|
||||
const axios = fakeFactory;
|
||||
const api = axios.create();
|
||||
export const a = () => api.get('/x');
|
||||
export const b = () => axios.get('/y');
|
||||
`,
|
||||
'src/mock.ts': `
|
||||
const axios = { create: () => ({ get: (u: string) => u }) };
|
||||
const api = axios.create();
|
||||
export const c = () => api.get('/z');
|
||||
`,
|
||||
},
|
||||
'src/shadow.ts',
|
||||
);
|
||||
|
||||
// The spelling is the only evidence here, and it is false.
|
||||
expect(consumers(detections)).toEqual([]);
|
||||
expect(
|
||||
consumers(
|
||||
scanRepo(
|
||||
{
|
||||
'src/mock.ts': `
|
||||
const axios = { create: () => ({ get: (u: string) => u }) };
|
||||
const api = axios.create();
|
||||
export const c = () => api.get('/z');
|
||||
`,
|
||||
},
|
||||
'src/mock.ts',
|
||||
),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('resolves a CommonJS require of axios, aliased or not', () => {
|
||||
const cjs = (local: string) => `
|
||||
const ${local} = require('axios');
|
||||
const api = ${local}.create({ baseURL: '/' });
|
||||
export const viaInstance = () => api.get('/instance');
|
||||
export const viaModule = () => ${local}.get('/module');
|
||||
`;
|
||||
|
||||
for (const local of ['axios', 'ax']) {
|
||||
const detections = scanRepo({ 'src/cjs.ts': cjs(local) }, 'src/cjs.ts');
|
||||
expect(consumers(detections).map((d) => d.path)).toEqual(
|
||||
expect.arrayContaining(['/instance', '/module']),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves the axios module used directly under an import alias', () => {
|
||||
const detections = scanRepo(
|
||||
{
|
||||
'src/aliased.ts': `
|
||||
import ax from 'axios';
|
||||
export const f = () => ax.get('/health');
|
||||
`,
|
||||
},
|
||||
'src/aliased.ts',
|
||||
);
|
||||
|
||||
expect(consumers(detections)).toContainEqual(
|
||||
expect.objectContaining({ method: 'GET', path: '/health' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a name two `export *` barrels both provide', () => {
|
||||
const detections = scanRepo(
|
||||
{
|
||||
'src/lib/axios.config.ts': AXIOS_CONFIG,
|
||||
'src/api/a.ts': `export const API_ROUTE_PATH = { LINKS: "/links-a" } as const;`,
|
||||
'src/api/b.ts': `export const API_ROUTE_PATH = { LINKS: "/links-b" } as const;`,
|
||||
'src/api/index.ts': `
|
||||
export * from './a';
|
||||
export * from './b';
|
||||
`,
|
||||
'src/api/links.api.ts': `
|
||||
import client from '../lib/axios.config';
|
||||
import { API_ROUTE_PATH } from './index';
|
||||
export const load = () => client.get(API_ROUTE_PATH.LINKS);
|
||||
`,
|
||||
},
|
||||
'src/api/links.api.ts',
|
||||
);
|
||||
|
||||
expect(consumers(detections)).toEqual([]);
|
||||
});
|
||||
|
||||
it('does NOT bind a Node builtin specifier to a same-named repo file', () => {
|
||||
const detections = scanRepo(
|
||||
{
|
||||
'src/lib/http.ts': `
|
||||
import axios from 'axios';
|
||||
export default axios.create({ baseURL: '/' });
|
||||
`,
|
||||
'src/api/health.ts': `
|
||||
import http from 'http';
|
||||
export const ping = () => http.get('http://example.com/health');
|
||||
`,
|
||||
},
|
||||
'src/api/health.ts',
|
||||
);
|
||||
|
||||
expect(consumers(detections)).toEqual([]);
|
||||
});
|
||||
|
||||
it('measures the pre-pass ceiling in bytes, not UTF-16 code units', () => {
|
||||
// Under 512 Ki code units, over 512 KiB of UTF-8 — the ceiling mirrors the
|
||||
// analyzer's byte-size limit, so this file must be skipped.
|
||||
const detections = scanRepo(
|
||||
{
|
||||
'src/lib/huge.ts': `
|
||||
import axios from 'axios';
|
||||
// ${'á'.repeat(300_000)}
|
||||
export default axios.create({ baseURL: '/' });
|
||||
`,
|
||||
'src/api/links.api.ts': `
|
||||
import client from '../lib/huge';
|
||||
export const load = () => client.get('/links');
|
||||
`,
|
||||
},
|
||||
'src/api/links.api.ts',
|
||||
);
|
||||
|
||||
expect(consumers(detections)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveJsImport', () => {
|
||||
const keys = (...paths: string[]) => new Set(paths);
|
||||
|
||||
it('refuses a tail two different modules claim, across extensions', () => {
|
||||
expect(
|
||||
resolveJsImport(
|
||||
'src/x.ts',
|
||||
'@/shared/routes',
|
||||
keys('a/shared/routes.ts', 'b/shared/routes.ts'),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveJsImport(
|
||||
'src/x.ts',
|
||||
'@/shared/routes',
|
||||
keys('a/shared/routes.ts', 'b/shared/routes.tsx'),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveJsImport(
|
||||
'src/x.ts',
|
||||
'@/shared/routes',
|
||||
keys('a/shared/routes.ts', 'b/shared/routes.js'),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveJsImport(
|
||||
'src/x.ts',
|
||||
'@/shared/routes',
|
||||
keys('a/shared/routes.ts', 'b/shared/routes/index.ts'),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps extension precedence when the matches are one module', () => {
|
||||
// `x/routes.ts` and `x/routes/index.ts` are two spellings of `x/routes`;
|
||||
// Node and tsc both pick the file, so this is precedence, not ambiguity.
|
||||
expect(
|
||||
resolveJsImport('src/a.ts', '@/x/routes', keys('src/x/routes.ts', 'src/x/routes/index.ts')),
|
||||
).toBe('src/x/routes.ts');
|
||||
expect(resolveJsImport('src/a.ts', '@/x/routes', keys('src/x/routes.tsx'))).toBe(
|
||||
'src/x/routes.tsx',
|
||||
);
|
||||
});
|
||||
|
||||
it('never resolves a single-segment bare specifier to a repo file', () => {
|
||||
// A bare npm package or Node builtin is not ours to resolve — and this is
|
||||
// also the hot path: the unindexed sweep that ran here cost 19.6x on a
|
||||
// 4 000-file repo whose only trigger was `import _ from 'lodash'`.
|
||||
expect(resolveJsImport('src/a.ts', 'http', keys('src/lib/http.ts'))).toBeNull();
|
||||
expect(resolveJsImport('src/a.ts', 'axios', keys('src/lib/axios.ts'))).toBeNull();
|
||||
expect(resolveJsImport('src/a.ts', 'lodash', keys('src/lodash.ts'))).toBeNull();
|
||||
});
|
||||
|
||||
it('still resolves alias and relative specifiers', () => {
|
||||
expect(resolveJsImport('src/a/b.ts', './c', keys('src/a/c.ts'))).toBe('src/a/c.ts');
|
||||
expect(resolveJsImport('src/a/b.ts', '@/lib/http', keys('src/lib/http.ts'))).toBe(
|
||||
'src/lib/http.ts',
|
||||
);
|
||||
expect(resolveJsImport('src/a/b.ts', 'lib/http', keys('src/lib/http.ts'))).toBe(
|
||||
'src/lib/http.ts',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -221,14 +221,23 @@ describe('PARSE_CACHE_VERSION', () => {
|
|||
// Version 69 added #2969's JS/TS data-route-table decoratorRoutes. Version 70
|
||||
// adds Spring non-HTTP handler side-channel facts (#2417 / #2891), so it is
|
||||
// the next free value after both cache payload changes.
|
||||
it('pins SCHEMA_BUMP to 70 so concurrent bumps cannot silently collide (#2766)', () => {
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(70);
|
||||
// Moved 70 -> 71 for #2980's Java constant-route capture set (moduleConstants
|
||||
// + routePathOperands). This branch first argued no bump was needed because
|
||||
// "the ledger already sits at 70, whose capture set post-dates and includes
|
||||
// this harvest" — it does not: 70 was cut by fe3d7e56b for #2417/#2891, an
|
||||
// ancestor of this PR's base. Leaving it made PARSE_CACHE_VERSION byte-
|
||||
// identical across the merge, so every same-package-version warm cache
|
||||
// replayed pre-feature captures and the feature was inert. 71 is the next
|
||||
// free value above every claim at this merge — origin/main is 70 and open
|
||||
// PR #3017 already claims 71, so 71 would have collided.
|
||||
it('pins SCHEMA_BUMP to 72 so concurrent bumps cannot silently collide (#2766)', () => {
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(72);
|
||||
// The PREVIOUS version must fail the reuse gate, not merely differ from the
|
||||
// current one — a hardcoded number outside the conflict hunk rebases cleanly
|
||||
// while being wrong, which is exactly how the 37/38 exact clashes landed.
|
||||
// Every nearby historical or in-flight value is rejected, including 69,
|
||||
// which carried the route-table payload before this merge.
|
||||
for (const taken of [59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69]) {
|
||||
for (const taken of [59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71]) {
|
||||
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -40,12 +40,9 @@ describe('getLanguageFromFilename', () => {
|
|||
});
|
||||
|
||||
describe('JavaScript', () => {
|
||||
it('detects .js files', () => {
|
||||
expect(getLanguageFromFilename('index.js')).toBe(SupportedLanguages.JavaScript);
|
||||
});
|
||||
|
||||
it('detects .jsx files', () => {
|
||||
expect(getLanguageFromFilename('App.jsx')).toBe(SupportedLanguages.JavaScript);
|
||||
it.each(['.js', '.jsx', '.mjs', '.cjs'])('detects %s files', (ext) => {
|
||||
expect(getLanguageFromFilename(`module${ext}`)).toBe(SupportedLanguages.JavaScript);
|
||||
expect(getProviderForFile(`src/module${ext}`)?.id).toBe(SupportedLanguages.JavaScript);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
264
gitnexus/test/unit/java-route-const-pipeline-e2e.test.ts
Normal file
264
gitnexus/test/unit/java-route-const-pipeline-e2e.test.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
/**
|
||||
* #2980 review round-2: COLD and WARM pipeline e2e for the provider-hook
|
||||
* constant harvest (`extractModuleConstants` / `moduleConstantHeuristic` /
|
||||
* `foldRoutePathOperands`).
|
||||
*
|
||||
* The maintainer's blocking finding: unit tests only exercised worker-gated
|
||||
* helpers — never the REAL pipeline. A controller referencing constants from
|
||||
* a class NOT named `*Constants` (e.g. `ApiPaths`) was silently dropped:
|
||||
* the old content gate `/import ... [\\w.]*Constants/` never matched, the
|
||||
* constants file never entered the import map, the route resolved to null and
|
||||
* got skipped.
|
||||
*
|
||||
* This file drives the REAL `runChunkedParseAndResolve` with the REAL compiled
|
||||
* dist worker (vitest auto-falls back to dist/core/ingestion/workers/
|
||||
* parse-worker.js) over a fixture repo shaped like the reviewer's example:
|
||||
*
|
||||
* repo/
|
||||
* src/main/java/com/example/ApiPaths.java — constants class NOT named
|
||||
* *Constants (the High bug)
|
||||
* src/main/java/com/example/UserController.java — @RequestMapping prefix +
|
||||
* @PostMapping(ApiPaths.X) +
|
||||
* FQN form + concat over a
|
||||
* static-imported bare ref
|
||||
*
|
||||
* Assertions (both runs):
|
||||
* - the emitted Route node carries the FOLDED literal path, not the expr;
|
||||
* - ALL THREE non-literal shapes survive (qualified, FQN-qualified, concat);
|
||||
* - a phantom `POST ` / empty path never appears (skip floor);
|
||||
* - the warm run yields the IDENTICAL route set AND is a genuine replay
|
||||
* (`usedWorkerPool === false`) — the harvest result survives the
|
||||
* structured-clone cache round trip (ModuleConstants uses Map, exercised
|
||||
* through mapReplacer/mapReviver). Asserting the route set alone would pass
|
||||
* on a cache MISS that silently reparsed.
|
||||
*
|
||||
* Rebuild gate: this test requires dist/ to be current; when dist/ is stale
|
||||
* (older than src/) it self-skips with a loud message rather than silently
|
||||
* asserting against the old binary. (CI builds before vitest, so it runs.)
|
||||
*/
|
||||
import { beforeEach, afterEach, describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
|
||||
import { runChunkedParseAndResolve } from '../../src/core/ingestion/pipeline-phases/parse-impl.js';
|
||||
import { PARSE_CACHE_VERSION, type ParseCache } from '../../src/storage/parse-cache.js';
|
||||
import {
|
||||
getDurableParsedFileDir,
|
||||
pruneAndSaveDurableParsedFileStore,
|
||||
} from '../../src/storage/parsedfile-store.js';
|
||||
|
||||
// ── dist freshness gate ───────────────────────────────────────────────────
|
||||
// The worker is one emitted file among many: TypeScript emits every module in
|
||||
// this feature separately, so comparing dist/parse-worker.js against
|
||||
// src/parse-worker.ts alone passes while the resolver, the Spring extractor or
|
||||
// the provider behind it are stale — and the test then asserts against the
|
||||
// PREVIOUS build's harvest. Gate on the newest mtime across every source this
|
||||
// pipeline actually loads.
|
||||
const repoRoot = path.resolve(__dirname, '..', '..');
|
||||
const distWorker = path.join(repoRoot, 'dist', 'core', 'ingestion', 'workers', 'parse-worker.js');
|
||||
const GATED_SOURCES = [
|
||||
'core/ingestion/workers/parse-worker.ts',
|
||||
'core/ingestion/route-extractors/java-const-resolver.ts',
|
||||
'core/ingestion/route-extractors/constant-resolver.ts',
|
||||
'core/ingestion/route-extractors/spring.ts',
|
||||
'core/ingestion/languages/java.ts',
|
||||
'core/ingestion/languages/python.ts',
|
||||
'core/ingestion/language-provider.ts',
|
||||
'core/ingestion/pipeline-phases/parse-impl.ts',
|
||||
];
|
||||
const newestSourceMs = Math.max(
|
||||
...GATED_SOURCES.map((rel) => fs.statSync(path.join(repoRoot, 'src', rel)).mtimeMs),
|
||||
);
|
||||
const distStale = !fs.existsSync(distWorker) || fs.statSync(distWorker).mtimeMs < newestSourceMs;
|
||||
|
||||
if (distStale) {
|
||||
// `describe.skip` prints only vitest's ordinary skip marker, so without this
|
||||
// the docblock's promised "loud message" did not exist and a stale/absent
|
||||
// dist/ looked like a passing run.
|
||||
console.warn(
|
||||
'[#2980 e2e] SKIPPED: dist/ is missing or older than src/ — run `npm run build` to exercise the real pipeline.',
|
||||
);
|
||||
}
|
||||
|
||||
const maybeDescribe = distStale ? describe.skip : describe;
|
||||
|
||||
// ── fixture repo (reviewer's exact High-finding shape) ────────────────────
|
||||
const API_PATHS = `package com.example.common;
|
||||
|
||||
public class ApiPaths {
|
||||
public static final String USERS = "/api/v1/users";
|
||||
public static final String ORDERS = "/api/v1/orders";
|
||||
public static final String V1 = "/api/v1";
|
||||
}
|
||||
`;
|
||||
|
||||
const USER_CONTROLLER = `package com.example;
|
||||
|
||||
import com.example.common.ApiPaths;
|
||||
import static com.example.common.ApiPaths.V1;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
@RequestMapping("/users")
|
||||
public class UserController {
|
||||
|
||||
// Qualified ref via a class NOT named *Constants (High finding): the old
|
||||
// gate dropped the whole route because ApiPaths fails the name pattern.
|
||||
@PostMapping(ApiPaths.USERS)
|
||||
public void create() {}
|
||||
|
||||
// FQN-qualified form (F3): multi-segment field_access chain.
|
||||
@GetMapping(com.example.common.ApiPaths.ORDERS)
|
||||
public void list() {}
|
||||
|
||||
// Inline concat with a STATIC-IMPORTED bare ref — the shape this fixture
|
||||
// used to only claim: it spelled the operand as the full FQN chain, which
|
||||
// just re-tested the FQN branch above, so bare-name resolution through the
|
||||
// import table had no coverage anywhere in the suite.
|
||||
@PostMapping(V1 + "/orders")
|
||||
public void createOrders() {}
|
||||
}
|
||||
`;
|
||||
|
||||
let repoDir: string;
|
||||
let storageDir: string;
|
||||
|
||||
function writeFixture(): { path: string; size: number }[] {
|
||||
const files: [string, string][] = [
|
||||
['src/main/java/com/example/common/ApiPaths.java', API_PATHS],
|
||||
['src/main/java/com/example/UserController.java', USER_CONTROLLER],
|
||||
];
|
||||
const out: { path: string; size: number }[] = [];
|
||||
for (const [rel, content] of files) {
|
||||
const full = path.join(repoDir, rel);
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, content);
|
||||
out.push({ path: rel, size: Buffer.byteLength(content) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The parse phase does not emit Route nodes itself — it returns the folded
|
||||
* `decoratorRoutes` (the routes phase emits them downstream). Asserting on the
|
||||
* folded paths at THIS seam is exactly the regression the maintainer asked
|
||||
* for: the worker's harvest → provider heuristic → parse-impl fold, with the
|
||||
* real dist worker.
|
||||
*/
|
||||
type PipelineResult = Awaited<ReturnType<typeof runChunkedParseAndResolve>>;
|
||||
|
||||
function foldedRoutesOf(result: PipelineResult): Array<{ path: string; method: string }> {
|
||||
return (result.allDecoratorRoutes ?? [])
|
||||
.filter((r) => typeof r.routePath === 'string')
|
||||
.map((r) => ({ path: r.routePath, method: r.httpMethod }));
|
||||
}
|
||||
|
||||
async function runPipeline(
|
||||
cache: ParseCache,
|
||||
files: { path: string; size: number }[],
|
||||
): Promise<PipelineResult> {
|
||||
const kg = createKnowledgeGraph();
|
||||
return await runChunkedParseAndResolve(
|
||||
kg,
|
||||
files,
|
||||
files.map((f) => f.path),
|
||||
files.length,
|
||||
repoDir,
|
||||
Date.now(),
|
||||
() => {},
|
||||
{ workerPoolSize: 1, parseCache: cache },
|
||||
);
|
||||
}
|
||||
|
||||
maybeDescribe('#2980 provider-hook constant harvest — real pipeline (cold + warm)', () => {
|
||||
beforeEach(() => {
|
||||
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gnx-2980-cold-'));
|
||||
storageDir = path.join(repoDir, '.gitnexus');
|
||||
});
|
||||
afterEach(() => {
|
||||
for (const d of [repoDir]) fs.rmSync(d, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('cold run: folds qualified / FQN / concat paths from a non-*Constants class', async () => {
|
||||
const files = writeFixture();
|
||||
const cache: ParseCache = {
|
||||
version: PARSE_CACHE_VERSION,
|
||||
entries: new Map(),
|
||||
usedKeys: new Set(),
|
||||
storagePath: storageDir,
|
||||
onDiskKeys: new Set(),
|
||||
};
|
||||
|
||||
const result = await runPipeline(cache, files);
|
||||
expect(result.usedWorkerPool).toBe(true);
|
||||
const routes = foldedRoutesOf(result);
|
||||
|
||||
// All three non-literal shapes resolve to folded literals. (The class-level
|
||||
// @RequestMapping("/users") prefix join happens in the downstream routes
|
||||
// phase — at this seam we assert the method-level folded paths.)
|
||||
const paths = routes.map((r) => r.path).sort();
|
||||
expect(paths).toContain('/api/v1/users'); // qualified ref via import
|
||||
expect(paths).toContain('/api/v1/orders'); // FQN multi-segment chain
|
||||
// The concat route folds to the same literal as the FQN route.
|
||||
expect(paths.filter((p) => p === '/api/v1/orders').length).toBeGreaterThanOrEqual(2);
|
||||
// Skip floor: no phantom empty/raw-expr paths.
|
||||
for (const p of paths) {
|
||||
expect(p.length).toBeGreaterThan(1);
|
||||
expect(p).not.toContain('ApiPaths');
|
||||
expect(p).not.toContain('com.example');
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
it('warm run: parse-cache replay yields the identical folded route set', async () => {
|
||||
const files = writeFixture();
|
||||
const cache: ParseCache = {
|
||||
version: PARSE_CACHE_VERSION,
|
||||
entries: new Map(),
|
||||
usedKeys: new Set(),
|
||||
storagePath: storageDir,
|
||||
onDiskKeys: new Set(),
|
||||
};
|
||||
|
||||
// Run #1 populates the cache; persist it like run-analyze does — BOTH the
|
||||
// chunk shards and the durable ParsedFile store. `slimParseWorkerResultsForCache`
|
||||
// blanks `parsedFiles` before writing a shard, so a warm run without the
|
||||
// durable store cannot replay the chunk and silently falls back to the
|
||||
// workers — which is what this test used to do while still passing.
|
||||
const run1 = await runPipeline(cache, files);
|
||||
const { saveParseCache, pruneCache } = await import('../../src/storage/parse-cache.js');
|
||||
pruneCache(cache, cache.usedKeys);
|
||||
const savedKeys = await saveParseCache(storageDir, cache);
|
||||
expect(savedKeys.length).toBeGreaterThan(0);
|
||||
await pruneAndSaveDurableParsedFileStore(
|
||||
getDurableParsedFileDir(storageDir),
|
||||
PARSE_CACHE_VERSION,
|
||||
new Set(savedKeys),
|
||||
);
|
||||
|
||||
// Run #2 — warm: every chunk is a cache HIT, no worker spawn, the cached
|
||||
// ParseWorkerResult (moduleConstants included) is replayed from disk.
|
||||
const { loadParseCache } = await import('../../src/storage/parse-cache.js');
|
||||
const warm = await loadParseCache(storageDir);
|
||||
expect(warm.onDiskKeys).toEqual(new Set(savedKeys));
|
||||
const run2 = await runPipeline(warm, files);
|
||||
|
||||
const cold = foldedRoutesOf(run1)
|
||||
.map((r) => `${r.method} ${r.path}`)
|
||||
.sort();
|
||||
const hot = foldedRoutesOf(run2)
|
||||
.map((r) => `${r.method} ${r.path}`)
|
||||
.sort();
|
||||
expect(hot).toEqual(cold);
|
||||
expect(hot.length).toBeGreaterThan(0);
|
||||
// Without this the test proves nothing about the cache: `loadParseCache`
|
||||
// returns an EMPTY cache on any failure (missing file, corrupt JSON,
|
||||
// version mismatch) and never throws, so a broken Map round-trip through
|
||||
// mapReplacer/mapReviver — the exact regression this test exists for —
|
||||
// would silently reparse through the workers and produce the same routes.
|
||||
expect(run1.usedWorkerPool).toBe(true);
|
||||
expect(run2.usedWorkerPool).toBe(false);
|
||||
}, 120_000);
|
||||
});
|
||||
794
gitnexus/test/unit/java-route-const-resolver.test.ts
Normal file
794
gitnexus/test/unit/java-route-const-resolver.test.ts
Normal file
|
|
@ -0,0 +1,794 @@
|
|||
/**
|
||||
* Java route-path constant resolution (#2391 Java binding).
|
||||
*
|
||||
* Fixtures sampled from REAL Winning Health WiNEX-Outpatient source shapes
|
||||
* (lesson from the vendor-alias PR #2883 review: hand-written textbook
|
||||
* fixtures missed the dominant real-world spelling — 1198 constant-ref
|
||||
* routes vs 2 literals in the real repo).
|
||||
*
|
||||
* Value shapes covered, spelled with the Spring annotations this branch
|
||||
* actually recognises (`@PostMapping` & co., bare or fully qualified):
|
||||
* - `@PostMapping(ApiPathConstants.DIAGNOSIS_SAVE_V1)` — qualified ref, the
|
||||
* dominant real-world spelling (1063 occurrences in the source corpus)
|
||||
* - `@PostMapping(value = ApiPathConstants.X)` / `(path = X)` — named
|
||||
* argument, 414+ occurrences
|
||||
* - `@PostMapping(API_CIS_GET_TREATMENT_ORDER_V1)` — static-imported bare
|
||||
* name, 79 files
|
||||
* - `public static final String API = OTHER + "suffix"` — composed constant
|
||||
* - interface constants (implicitly static final)
|
||||
* - escaped characters survive folding identically to the literal path
|
||||
* - same-package simple-name collision floors to skip across Maven modules
|
||||
* - FQN-qualified annotation value (4 occurrences)
|
||||
* - unresolvable references floor to skip (never a phantom path)
|
||||
*
|
||||
* NOT covered, deliberately: the vendor alias `@WinPostMapping`. The corpus is
|
||||
* dominated by it, but Spring alias recognition is an EXACT-NAME map
|
||||
* (`spring-shared.ts`) on this base — there is no `*Mapping`-suffix rule, #2883
|
||||
* is still open — so `@PostMapping(...)` extracts zero routes here no matter
|
||||
* how the constant folds. A fixture written in that spelling would be dead
|
||||
* (one was, and CodeQL flagged it). Constant folding and alias recognition are
|
||||
* independent: when #2883 lands, every shape below works unchanged for aliases.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import Parser from 'tree-sitter';
|
||||
import Java from 'tree-sitter-java';
|
||||
import {
|
||||
extractJavaModuleConstants,
|
||||
foldJavaOperands,
|
||||
isJavaConstantFile,
|
||||
parseJavaConstOperands,
|
||||
resolveJavaConstant,
|
||||
resolveJavaImport,
|
||||
type RepoConstants,
|
||||
} from '../../src/core/ingestion/route-extractors/java-const-resolver.js';
|
||||
import { javaProvider } from '../../src/core/ingestion/languages/java.js';
|
||||
import { unquoteSpringLiteral } from '../../src/core/ingestion/route-extractors/spring-shared.js';
|
||||
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(Java);
|
||||
|
||||
function parse(src: string): Parser.Tree {
|
||||
return parser.parse(src);
|
||||
}
|
||||
|
||||
/** Build a RepoConstants map from virtual files: { 'a/b/C.java': source }. */
|
||||
function repoOf(files: Record<string, string>): RepoConstants {
|
||||
const map = new Map();
|
||||
for (const [key, src] of Object.entries(files)) {
|
||||
map.set(key, extractJavaModuleConstants(parse(src)));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// ─── Real WiNEX shapes ────────────────────────────────────────────────────
|
||||
|
||||
const CONSTANTS_FILE = `package com.winning.opt.diagnosis.api.constants;
|
||||
|
||||
import static com.winning.opt.common.constants.api.ApiPath.API_CIS_V1;
|
||||
|
||||
public class ApiPathConstants {
|
||||
|
||||
private ApiPathConstants() {
|
||||
}
|
||||
|
||||
public static final String DIAGNOSIS_SAVE_V1 = "/api/v1/app_record_cis_outpatient_diagnosis/encounter_diagnosis/add";
|
||||
|
||||
public static final String DIAGNOSIS_SAVE_V2 = "/api/v2/app_record_cis_outpatient_diagnosis/encounter_diagnosis/add";
|
||||
|
||||
public static final String API_CIS_SAVE_SUMMARY = API_CIS_V1 + "summary/save";
|
||||
}`;
|
||||
|
||||
const COMMON_API_FILE = `package com.winning.opt.common.constants.api;
|
||||
|
||||
public class ApiPath {
|
||||
|
||||
public static final String API_CIS_V1 = "/api/v1/cis/";
|
||||
}`;
|
||||
|
||||
const CONTROLLER_FILE = `package com.winning.opt.diagnosis.controller;
|
||||
|
||||
import com.winning.opt.diagnosis.api.constants.ApiPathConstants;
|
||||
|
||||
public class DiagnosisController {
|
||||
|
||||
@PostMapping(ApiPathConstants.DIAGNOSIS_SAVE_V1)
|
||||
public String save() { return "{}"; }
|
||||
|
||||
@PostMapping(value = ApiPathConstants.DIAGNOSIS_SAVE_V2)
|
||||
public String saveV2() { return "{}"; }
|
||||
|
||||
@PostMapping(path = ApiPathConstants.API_CIS_SAVE_SUMMARY)
|
||||
public String saveSummary() { return "{}"; }
|
||||
}`;
|
||||
|
||||
const STATIC_IMPORT_CONTROLLER = `package com.winning.opt.cis.controller;
|
||||
|
||||
import static com.winning.opt.diagnosis.api.constants.ApiPathConstants.DIAGNOSIS_SAVE_V1;
|
||||
|
||||
public class CisController {
|
||||
|
||||
@PostMapping(DIAGNOSIS_SAVE_V1)
|
||||
public String save() { return "{}"; }
|
||||
}`;
|
||||
|
||||
const INTERFACE_CONSTANTS_FILE = `package com.winning.opt.labtest.api.constants;
|
||||
|
||||
public interface LabApiPath {
|
||||
String LAB_QUERY_V1 = "/api/v1/labtest/query";
|
||||
}`;
|
||||
|
||||
describe('extractJavaModuleConstants', () => {
|
||||
it('collects static final String literals with class-qualified aliases', () => {
|
||||
const mc = extractJavaModuleConstants(parse(CONSTANTS_FILE));
|
||||
expect(mc.literals.get('DIAGNOSIS_SAVE_V1')).toBe(
|
||||
'/api/v1/app_record_cis_outpatient_diagnosis/encounter_diagnosis/add',
|
||||
);
|
||||
expect(mc.literals.get('ApiPathConstants.DIAGNOSIS_SAVE_V1')).toBe(
|
||||
'/api/v1/app_record_cis_outpatient_diagnosis/encounter_diagnosis/add',
|
||||
);
|
||||
});
|
||||
|
||||
it('records composed constants as operand expressions', () => {
|
||||
const mc = extractJavaModuleConstants(parse(CONSTANTS_FILE));
|
||||
const expr = mc.exprs.get('API_CIS_SAVE_SUMMARY');
|
||||
expect(expr).toEqual([
|
||||
{ kind: 'ref', name: 'API_CIS_V1' },
|
||||
{ kind: 'literal', value: 'summary/save' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('records class and static imports', () => {
|
||||
const mc = extractJavaModuleConstants(parse(CONTROLLER_FILE));
|
||||
expect(mc.imports.get('ApiPathConstants')).toEqual({
|
||||
module: 'com.winning.opt.diagnosis.api.constants.ApiPathConstants',
|
||||
originalName: 'ApiPathConstants',
|
||||
});
|
||||
const mcStatic = extractJavaModuleConstants(parse(STATIC_IMPORT_CONTROLLER));
|
||||
expect(mcStatic.imports.get('DIAGNOSIS_SAVE_V1')).toEqual({
|
||||
module: 'com.winning.opt.diagnosis.api.constants.ApiPathConstants',
|
||||
originalName: 'DIAGNOSIS_SAVE_V1',
|
||||
});
|
||||
});
|
||||
|
||||
it('collects interface constants (implicitly static final)', () => {
|
||||
const mc = extractJavaModuleConstants(parse(INTERFACE_CONSTANTS_FILE));
|
||||
expect(mc.literals.get('LAB_QUERY_V1')).toBe('/api/v1/labtest/query');
|
||||
});
|
||||
|
||||
it('ignores non-static or non-String fields', () => {
|
||||
const src = `package p;
|
||||
public class C {
|
||||
public static final int COUNT = 5;
|
||||
public String instance = "x";
|
||||
static final String PRIVATE_OK = "/ok";
|
||||
}`;
|
||||
const mc = extractJavaModuleConstants(parse(src));
|
||||
expect(mc.literals.has('COUNT')).toBe(false);
|
||||
expect(mc.literals.has('instance')).toBe(false);
|
||||
expect(mc.literals.get('PRIVATE_OK')).toBe('/ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveJavaImport', () => {
|
||||
const keys = new Set([
|
||||
'winning-opt-diagnosis/src/main/java/com/winning/opt/diagnosis/api/constants/ApiPathConstants.java',
|
||||
'winning-opt-common/src/main/java/com/winning/opt/common/constants/api/ApiPath.java',
|
||||
]);
|
||||
|
||||
it('resolves a package import to the unique path-suffix file', () => {
|
||||
const hit = resolveJavaImport(
|
||||
'winning-opt-diagnosis/src/main/java/com/winning/opt/diagnosis/controller/DiagnosisController.java',
|
||||
'com.winning.opt.diagnosis.api.constants.ApiPathConstants',
|
||||
keys,
|
||||
);
|
||||
expect(hit).toBe(
|
||||
'winning-opt-diagnosis/src/main/java/com/winning/opt/diagnosis/api/constants/ApiPathConstants.java',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves a static import (class.member → class file)', () => {
|
||||
const hit = resolveJavaImport(
|
||||
'winning-opt-cis/src/main/java/com/winning/opt/cis/controller/CisController.java',
|
||||
'com.winning.opt.diagnosis.api.constants.ApiPathConstants',
|
||||
keys,
|
||||
);
|
||||
expect(hit).toBe(
|
||||
'winning-opt-diagnosis/src/main/java/com/winning/opt/diagnosis/api/constants/ApiPathConstants.java',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null when the class does not exist in the repo map', () => {
|
||||
const hit = resolveJavaImport('a/A.java', 'com.example.notthere.NoConst', keys);
|
||||
expect(hit).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveJavaConstant end-to-end (real repo shapes)', () => {
|
||||
const repo = repoOf({
|
||||
'winning-opt-diagnosis/src/main/java/com/winning/opt/diagnosis/api/constants/ApiPathConstants.java':
|
||||
CONSTANTS_FILE,
|
||||
'winning-opt-common/src/main/java/com/winning/opt/common/constants/api/ApiPath.java':
|
||||
COMMON_API_FILE,
|
||||
});
|
||||
const controllerKey =
|
||||
'winning-opt-diagnosis/src/main/java/com/winning/opt/diagnosis/controller/DiagnosisController.java';
|
||||
|
||||
it('resolves qualified refs via the class import chain', () => {
|
||||
// The controller imports ApiPathConstants; the ref name is qualified.
|
||||
// Hand-rolled two-step: import resolves the class, qualified alias carries the field.
|
||||
const mc = extractJavaModuleConstants(parse(CONTROLLER_FILE));
|
||||
const targetFile = resolveJavaImport(
|
||||
controllerKey,
|
||||
mc.imports.get('ApiPathConstants')!.module,
|
||||
new Set(repo.keys()),
|
||||
);
|
||||
expect(targetFile).toBeTruthy();
|
||||
const value = resolveJavaConstant(targetFile!, 'ApiPathConstants.DIAGNOSIS_SAVE_V1', repo);
|
||||
expect(value).toBe('/api/v1/app_record_cis_outpatient_diagnosis/encounter_diagnosis/add');
|
||||
});
|
||||
|
||||
it('folds composed constants across files (static import + concat)', () => {
|
||||
const mc = extractJavaModuleConstants(parse(CONSTANTS_FILE));
|
||||
const targetFile = resolveJavaImport(
|
||||
'winning-opt-diagnosis/src/main/java/com/winning/opt/diagnosis/api/constants/ApiPathConstants.java',
|
||||
mc.imports.get('API_CIS_V1')!.module,
|
||||
new Set(repo.keys()),
|
||||
);
|
||||
expect(targetFile).toBe(
|
||||
'winning-opt-common/src/main/java/com/winning/opt/common/constants/api/ApiPath.java',
|
||||
);
|
||||
const value = resolveJavaConstant(
|
||||
'winning-opt-diagnosis/src/main/java/com/winning/opt/diagnosis/api/constants/ApiPathConstants.java',
|
||||
'API_CIS_SAVE_SUMMARY',
|
||||
repo,
|
||||
);
|
||||
expect(value).toBe('/api/v1/cis/summary/save');
|
||||
});
|
||||
|
||||
it('floors to null on unresolvable names (skip, never guess)', () => {
|
||||
expect(resolveJavaConstant(controllerKey, 'NOT_A_THING', repo)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseJavaConstOperands', () => {
|
||||
it('parses a bare identifier ref', () => {
|
||||
const tree = parse(`package p; public class C { static final String X = Y; }`);
|
||||
let valueNode: Parser.SyntaxNode | null = null;
|
||||
const walk = (n: Parser.SyntaxNode): void => {
|
||||
if (n.type === 'variable_declarator') {
|
||||
const v = n.childForFieldName('value');
|
||||
if (v) valueNode = v;
|
||||
}
|
||||
for (const c of n.children ?? []) walk(c);
|
||||
};
|
||||
walk(tree.rootNode);
|
||||
expect(parseJavaConstOperands(valueNode)).toEqual([{ kind: 'ref', name: 'Y' }]);
|
||||
});
|
||||
|
||||
it('parses left-associative + chains', () => {
|
||||
const tree = parse(`package p; public class C { static final String X = A + "/b" + C; }`);
|
||||
let valueNode: Parser.SyntaxNode | null = null;
|
||||
const walk = (n: Parser.SyntaxNode): void => {
|
||||
if (n.type === 'variable_declarator') {
|
||||
const v = n.childForFieldName('value');
|
||||
if (v) valueNode = v;
|
||||
}
|
||||
for (const c of n.children ?? []) walk(c);
|
||||
};
|
||||
walk(tree.rootNode);
|
||||
expect(parseJavaConstOperands(valueNode)).toEqual([
|
||||
{ kind: 'ref', name: 'A' },
|
||||
{ kind: 'literal', value: '/b' },
|
||||
{ kind: 'ref', name: 'C' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns null for calls and non-string shapes', () => {
|
||||
const tree = parse(
|
||||
`package p; public class C { static final String X = String.format("%s", a); }`,
|
||||
);
|
||||
let valueNode: Parser.SyntaxNode | null = null;
|
||||
const walk = (n: Parser.SyntaxNode): void => {
|
||||
if (n.type === 'variable_declarator') {
|
||||
const v = n.childForFieldName('value');
|
||||
if (v) valueNode = v;
|
||||
}
|
||||
for (const c of n.children ?? []) walk(c);
|
||||
};
|
||||
walk(tree.rootNode);
|
||||
expect(parseJavaConstOperands(valueNode)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Ingestion extractor level: constant-referencing annotation values ──
|
||||
// (regression for the review finding where the route loop's `!valueNode`
|
||||
// guard dropped every @value_expr match before the operand branch ran)
|
||||
describe('extractSpringRoutes constant value', () => {
|
||||
it('emits routePathExpr + operands for @Mapping(CONSTS.X)', async () => {
|
||||
const { extractSpringRoutes } =
|
||||
await import('../../src/core/ingestion/route-extractors/spring.js');
|
||||
const tree = parser.parse(`
|
||||
package com.winning.opt.demo;
|
||||
public class DemoController {
|
||||
@org.springframework.web.bind.annotation.PostMapping(ApiPathConstants.DIAGNOSIS_SAVE_V1)
|
||||
public String save() { return "ok"; }
|
||||
}`);
|
||||
const routes = extractSpringRoutes(tree, 'DemoController.java', 0);
|
||||
expect(routes.length).toBe(1);
|
||||
expect(routes[0].httpMethod).toBe('POST');
|
||||
expect(routes[0].routePathExpr).toBe('ApiPathConstants.DIAGNOSIS_SAVE_V1');
|
||||
expect(routes[0].routePathOperands && routes[0].routePathOperands.length > 0).toBeTruthy();
|
||||
expect(routes[0].routePath).toBe('');
|
||||
});
|
||||
|
||||
it('keeps literal routes unchanged', async () => {
|
||||
const { extractSpringRoutes } =
|
||||
await import('../../src/core/ingestion/route-extractors/spring.js');
|
||||
const tree = parser.parse(`
|
||||
package com.winning.opt.demo;
|
||||
public class DemoController {
|
||||
@org.springframework.web.bind.annotation.PostMapping("/literal/path")
|
||||
public String save() { return "ok"; }
|
||||
}`);
|
||||
const routes = extractSpringRoutes(tree, 'DemoController.java', 0);
|
||||
expect(routes.length).toBe(1);
|
||||
expect(routes[0].routePath).toBe('/literal/path');
|
||||
expect(routes[0].routePathExpr).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('qualified-ref recursion cycle guard (maintainer point 5)', () => {
|
||||
it('self-import: qualified self-reference terminates with null, not a stack overflow', () => {
|
||||
const repo = repoOf({
|
||||
'src/main/java/com/example/SelfConsts.java': `package com.example;
|
||||
import com.example.SelfConsts;
|
||||
public class SelfConsts {
|
||||
public static final String X = SelfConsts.X + "/x";
|
||||
}`,
|
||||
});
|
||||
// In-file expr records the qualified ref `SelfConsts.X`; resolving it
|
||||
// re-enters the same file via the (self) import head — must hit the depth
|
||||
// cap, not the V8 stack.
|
||||
expect(
|
||||
resolveJavaConstant('src/main/java/com/example/SelfConsts.java', 'SelfConsts.X', repo),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('mutual imports: A.X -> B.Y -> A.X terminates with null', () => {
|
||||
const repo = repoOf({
|
||||
'src/main/java/com/example/AConsts.java': `package com.example;
|
||||
import com.example.BConsts;
|
||||
public class AConsts {
|
||||
public static final String X = BConsts.Y;
|
||||
}`,
|
||||
'src/main/java/com/example/BConsts.java': `package com.example;
|
||||
import com.example.AConsts;
|
||||
public class BConsts {
|
||||
public static final String Y = AConsts.X;
|
||||
}`,
|
||||
});
|
||||
expect(
|
||||
resolveJavaConstant('src/main/java/com/example/AConsts.java', 'AConsts.X', repo),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Review round 2 regressions (#2980) ───────────────────────────────────
|
||||
|
||||
describe('F4: class nested in an interface is NOT implicitly final', () => {
|
||||
const SRC = `package p;
|
||||
public interface Api {
|
||||
String BASE = "/api";
|
||||
class Holder {
|
||||
String mutable = "/mutable";
|
||||
static final String OK = "/ok";
|
||||
}
|
||||
interface Inner {
|
||||
String IMPLICIT = "/implicit";
|
||||
class Deep {
|
||||
String alsoMutable = "/also";
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
it('harvests the interface own fields and explicit static final nested fields', () => {
|
||||
const mc = extractJavaModuleConstants(parse(SRC));
|
||||
expect(mc.literals.get('BASE')).toBe('/api');
|
||||
expect(mc.literals.get('OK')).toBe('/ok');
|
||||
expect(mc.literals.get('Holder.OK')).toBe('/ok');
|
||||
});
|
||||
|
||||
it('does NOT harvest mutable fields of a class nested in an interface', () => {
|
||||
const mc = extractJavaModuleConstants(parse(SRC));
|
||||
expect(mc.literals.has('mutable')).toBe(false);
|
||||
expect(mc.literals.has('alsoMutable')).toBe(false);
|
||||
expect(mc.literals.has('Holder.mutable')).toBe(false);
|
||||
expect(mc.exprs.has('mutable')).toBe(false);
|
||||
});
|
||||
|
||||
it('still harvests a class directly nested in an interface (own implicit semantics recomputed at each boundary)', () => {
|
||||
const mc = extractJavaModuleConstants(parse(SRC));
|
||||
expect(mc.literals.get('IMPLICIT')).toBe('/implicit');
|
||||
expect(mc.literals.get('Inner.IMPLICIT')).toBe('/implicit');
|
||||
});
|
||||
});
|
||||
|
||||
describe('F5: same-name shadowing across nested types drops the stale entry', () => {
|
||||
const SRC = `package p;
|
||||
public class Outer {
|
||||
public static final String PATH = "/v1";
|
||||
static class Inner {
|
||||
// shadows Outer.PATH with a non-foldable initializer
|
||||
public static final String PATH = compute();
|
||||
static String compute() { return "/v2"; }
|
||||
}
|
||||
}`;
|
||||
|
||||
it('a non-foldable shadow must drop the outer literal, not keep it (skip floor)', () => {
|
||||
const mc = extractJavaModuleConstants(parse(SRC));
|
||||
expect(mc.literals.has('PATH')).toBe(false);
|
||||
expect(mc.exprs.has('PATH')).toBe(false);
|
||||
});
|
||||
|
||||
it('qualified aliases survive per class (Outer.PATH resolvable, Inner.PATH not)', () => {
|
||||
const mc = extractJavaModuleConstants(parse(SRC));
|
||||
expect(mc.literals.get('Outer.PATH')).toBe('/v1');
|
||||
expect(mc.literals.has('Inner.PATH')).toBe(false);
|
||||
});
|
||||
|
||||
it('a foldable shadow REPLACES the outer value (last binding wins in source order)', () => {
|
||||
const src = `package p;
|
||||
public class Outer {
|
||||
public static final String PATH = "/v1";
|
||||
static class Inner {
|
||||
public static final String PATH = "/v2";
|
||||
}
|
||||
}`;
|
||||
const mc = extractJavaModuleConstants(parse(src));
|
||||
expect(mc.literals.get('PATH')).toBe('/v2');
|
||||
expect(mc.literals.get('Outer.PATH')).toBe('/v1');
|
||||
expect(mc.literals.get('Inner.PATH')).toBe('/v2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('F3: multi-segment FQN annotation values and constant initializers', () => {
|
||||
const constValueOf = (src: string): Parser.SyntaxNode => {
|
||||
const cls = parse(src).rootNode.descendantsOfType('class_declaration')[0]!;
|
||||
const body = cls.childForFieldName('body')!;
|
||||
const field = body.children.find((c) => c.type === 'field_declaration')!;
|
||||
const decl = field.children.find((c) => c.type === 'variable_declarator')!;
|
||||
return decl.childForFieldName('value')!;
|
||||
};
|
||||
|
||||
it('parses com.example.ApiPaths.USERS as ONE ref (nested field_access chain flattened)', () => {
|
||||
const ops = parseJavaConstOperands(
|
||||
constValueOf(`package p;
|
||||
public class W {
|
||||
public static final String X = com.example.ApiPaths.USERS;
|
||||
}`),
|
||||
);
|
||||
expect(ops).toEqual([{ kind: 'ref', name: 'com.example.ApiPaths.USERS' }]);
|
||||
});
|
||||
|
||||
it('still rejects call/object-side chains: f().X, this.X, arr[0].X', () => {
|
||||
expect(
|
||||
parseJavaConstOperands(
|
||||
constValueOf(`package p;
|
||||
public class W { public static final String A = f().X; static Object f(){return null;} }`),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseJavaConstOperands(
|
||||
constValueOf(`package p;
|
||||
public class W { public static final String B = this.Y; String Y = "y"; }`),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseJavaConstOperands(
|
||||
constValueOf(`package p;
|
||||
public class W { public static final String C = arr[0].Z; }`),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves an FQN-qualified annotation constant end-to-end (query → operands → fold)', () => {
|
||||
const repo = repoOf({
|
||||
'src/main/java/com/example/ApiPaths.java': `package com.example;
|
||||
public class ApiPaths {
|
||||
public static final String USERS = "/api/v1/users";
|
||||
}`,
|
||||
'src/main/java/com/example/Ctl.java': `package com.example;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
public class Ctl {
|
||||
@PostMapping(com.example.ApiPaths.USERS)
|
||||
public void list() {}
|
||||
}`,
|
||||
});
|
||||
// The whole FQN arrives as one ref operand (verified against the real
|
||||
// tree-sitter-java parse shape); the resolver must follow it via the
|
||||
// longest-prefix import fallback.
|
||||
expect(
|
||||
resolveJavaConstant('src/main/java/com/example/Ctl.java', 'com.example.ApiPaths.USERS', repo),
|
||||
).toBe('/api/v1/users');
|
||||
});
|
||||
});
|
||||
|
||||
describe('escaped characters survive folding (review P1)', () => {
|
||||
// tree-sitter-java splits a string_literal AROUND its escape_sequence
|
||||
// children, so a string_fragment-only join silently deleted every escape:
|
||||
// the standard Spring path-variable constraint `{id:\\d+}` folded to
|
||||
// `{id:d+}` and a pure-escape literal folded to ''. Worse, the LITERAL path
|
||||
// keeps escapes verbatim, so one Java route had two spellings.
|
||||
const cases = [
|
||||
['"/user/{id:\\d+}"', '/user/{id:\\d+}'],
|
||||
['"/a\\tb"', '/a\\tb'],
|
||||
['"/a\\u002Fb"', '/a\\u002Fb'],
|
||||
['"\\t"', '\\t'],
|
||||
['""', ''],
|
||||
['"/plain"', '/plain'],
|
||||
] as const;
|
||||
|
||||
it.each(cases)('keeps %s intact through the constant path', (literal, expected) => {
|
||||
const mc = extractJavaModuleConstants(
|
||||
parse(`public class C { public static final String X = ${literal}; }`),
|
||||
);
|
||||
expect(mc.literals.get('X')).toBe(expected);
|
||||
});
|
||||
|
||||
it.each(cases)('agrees with the literal path for %s', (literal, expected) => {
|
||||
// The constant path and `unquoteSpringLiteral` (what a literal-valued
|
||||
// @GetMapping goes through) must produce the SAME string, or the graph
|
||||
// carries two irreconcilable spellings of one route.
|
||||
expect(unquoteSpringLiteral(literal)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a non-foldable rebind drops the static import too (review P1)', () => {
|
||||
it('returns null rather than the shadowed imported value', () => {
|
||||
const repo = repoOf({
|
||||
'src/main/java/com/x/Base.java': `package com.x;
|
||||
public class Base { public static final String PATH = "/WRONG-imported"; }`,
|
||||
'src/main/java/com/y/C.java': `package com.y;
|
||||
import static com.x.Base.PATH;
|
||||
public class C { public static final String PATH = compute(); }`,
|
||||
});
|
||||
// A local `static final` shadows a static import of the same simple name
|
||||
// inside that class (JLS 6.4.1), so the only correct answer is
|
||||
// "unresolvable". Leaving the import alive made the fold fall through to
|
||||
// it and return the imported literal — a wrong path where the skip floor
|
||||
// is owed (#2393's Python defect, reproduced for Java).
|
||||
expect(repo.get('src/main/java/com/y/C.java')!.imports.has('PATH')).toBe(false);
|
||||
expect(
|
||||
foldJavaOperands('src/main/java/com/y/C.java', [{ kind: 'ref', name: 'PATH' }], repo),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('the drop is file-scoped: a sibling class floors to skip, never to a wrong value', () => {
|
||||
// These maps are file-level by design (nested types flatten into one
|
||||
// namespace), so dropping the import costs a sibling class that
|
||||
// legitimately uses it. javac would answer `/imported/b` here; we answer
|
||||
// null. Pinned deliberately — the alternative direction is a wrong path.
|
||||
const repo = repoOf({
|
||||
'src/main/java/com/x/Base.java': `package com.x;
|
||||
public class Base { public static final String PATH = "/imported"; }`,
|
||||
'src/main/java/com/y/Two.java': `package com.y;
|
||||
import static com.x.Base.PATH;
|
||||
class A { public static final String PATH = compute(); }
|
||||
class B { public static final String USE = PATH + "/b"; }`,
|
||||
});
|
||||
expect(
|
||||
foldJavaOperands('src/main/java/com/y/Two.java', [{ kind: 'ref', name: 'B.USE' }], repo),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('a FOLDABLE rebind still wins over the import', () => {
|
||||
const repo = repoOf({
|
||||
'src/main/java/com/x/Base.java': `package com.x;
|
||||
public class Base { public static final String PATH = "/imported"; }`,
|
||||
'src/main/java/com/y/C.java': `package com.y;
|
||||
import static com.x.Base.PATH;
|
||||
public class C { public static final String PATH = "/local"; }`,
|
||||
});
|
||||
expect(
|
||||
foldJavaOperands('src/main/java/com/y/C.java', [{ kind: 'ref', name: 'PATH' }], repo),
|
||||
).toBe('/local');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isJavaConstantFile — one gate, both subsystems (review P1)', () => {
|
||||
// The ingestion provider and the group extractor's prepareRepo pre-pass used
|
||||
// to spell this gate differently. A constant INTERFACE passed the group's and
|
||||
// failed ingestion's, so the group published a provider contract while the
|
||||
// graph got no Route node — an R4 parity break in the losing direction.
|
||||
const shapes = [
|
||||
[
|
||||
'constant interface (implicitly static final, no import)',
|
||||
`package com.x;
|
||||
public interface ApiPathConstants { String SAVE = "/api/v1/save"; }`,
|
||||
],
|
||||
[
|
||||
'lowercase interface name',
|
||||
`package com.x;
|
||||
public interface apiPaths { String SAVE = "/api/v1/save"; }`,
|
||||
],
|
||||
[
|
||||
'reversed modifier order',
|
||||
`package com.x;
|
||||
public class P { public final static String SAVE = "/api/v1/save"; }`,
|
||||
],
|
||||
[
|
||||
'conventional order',
|
||||
`package com.x;
|
||||
public class P { public static final String SAVE = "/api/v1/save"; }`,
|
||||
],
|
||||
[
|
||||
'modifiers interleaved',
|
||||
`package com.x;
|
||||
public class P { static public final String SAVE = "/api/v1/save"; }`,
|
||||
],
|
||||
[
|
||||
'fully-qualified java.lang.String',
|
||||
`package com.x;
|
||||
public class P { public static final java.lang.String SAVE = "/api/v1/save"; }`,
|
||||
],
|
||||
[
|
||||
'fully-qualified type in an interface',
|
||||
`package com.x;
|
||||
public interface P { java.lang.String SAVE = "/api/v1/save"; }`,
|
||||
],
|
||||
] as const;
|
||||
|
||||
it.each(shapes)('admits %s on BOTH sides', (_name, src) => {
|
||||
expect(isJavaConstantFile(src)).toBe(true);
|
||||
// The provider hook is what the parse worker actually calls — drive it,
|
||||
// not just the regex, so the gate itself is covered and not only the
|
||||
// extractor behind it.
|
||||
expect(javaProvider.moduleConstantHeuristic?.(src)).toBe(true);
|
||||
expect(extractJavaModuleConstants(parse(src)).literals.get('SAVE')).toBe('/api/v1/save');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'no constant-bearing syntax',
|
||||
`package com.x;
|
||||
public class P { void run() { System.out.println("/not-a-constant"); } }`,
|
||||
],
|
||||
[
|
||||
'a local String inside a static method',
|
||||
`package com.x;
|
||||
public class P { static void run() { String s = "/local"; } }`,
|
||||
],
|
||||
[
|
||||
'prose that merely mentions an interface',
|
||||
`/** interface EXTENDS (#1951). */
|
||||
public class A { void f() {} }`,
|
||||
],
|
||||
])('still skips %s', (_name, src) => {
|
||||
expect(isJavaConstantFile(src)).toBe(false);
|
||||
expect(extractJavaModuleConstants(parse(src)).literals.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveJavaImport honours the documented skip floor (review P2)', () => {
|
||||
it('returns null when the same package+class exists in two modules', () => {
|
||||
// A nearest-shared-directory tie-break used to pick one. javac resolves
|
||||
// duplicate FQNs by classpath order, so proximity can hand back a
|
||||
// src/test fixture copy — a silently wrong literal in a resolver whose
|
||||
// contract is skip-or-correct.
|
||||
const keys = new Set([
|
||||
'svc-order/src/main/java/com/x/ApiPaths.java',
|
||||
'svc-user/src/main/java/com/x/ApiPaths.java',
|
||||
]);
|
||||
expect(
|
||||
resolveJavaImport(
|
||||
'svc-order/src/main/java/com/x/web/OrderController.java',
|
||||
'com.x.ApiPaths',
|
||||
keys,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('still resolves a unique full-suffix match', () => {
|
||||
const keys = new Set([
|
||||
'svc-order/src/main/java/com/x/ApiPaths.java',
|
||||
'svc-user/src/main/java/com/y/ApiPaths.java',
|
||||
]);
|
||||
expect(
|
||||
resolveJavaImport(
|
||||
'svc-order/src/main/java/com/x/web/OrderController.java',
|
||||
'com.x.ApiPaths',
|
||||
keys,
|
||||
),
|
||||
).toBe('svc-order/src/main/java/com/x/ApiPaths.java');
|
||||
});
|
||||
});
|
||||
|
||||
describe('enum and record constants are collected', () => {
|
||||
it.each([
|
||||
['enum', 'public enum E { A, B; public static final String P = "/e"; }', 'E'],
|
||||
['record', 'public record R(int x) { public static final String P = "/r"; }', 'R'],
|
||||
])('harvests a static final String declared in a %s', (_kind, src, owner) => {
|
||||
const mc = extractJavaModuleConstants(parse(src));
|
||||
expect(mc.literals.get('P')).toBe(src.includes('enum') ? '/e' : '/r');
|
||||
expect(mc.literals.get(`${owner}.P`)).toBe(src.includes('enum') ? '/e' : '/r');
|
||||
});
|
||||
|
||||
it('does not harvest a non-static field of a record', () => {
|
||||
const mc = extractJavaModuleConstants(parse('public record R(int x) { String p = "/r"; }'));
|
||||
expect(mc.literals.has('p')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('constants composed across files through a qualified ref', () => {
|
||||
it('folds `X = BConsts.Y + "/tail"` across the import', () => {
|
||||
// Operands found INSIDE an initializer used to go straight to the agnostic
|
||||
// fold, which only knows bare names — so a qualified operand missed and
|
||||
// floored the whole chain to null, even acyclically.
|
||||
const repo = repoOf({
|
||||
'src/com/example/AConsts.java': `package com.example;
|
||||
import com.example.BConsts;
|
||||
public class AConsts { public static final String X = BConsts.Y + "/tail"; }`,
|
||||
'src/com/example/BConsts.java': `package com.example;
|
||||
public class BConsts { public static final String Y = "/y"; }`,
|
||||
});
|
||||
expect(resolveJavaConstant('src/com/example/AConsts.java', 'X', repo)).toBe('/y/tail');
|
||||
expect(
|
||||
foldJavaOperands('src/com/example/AConsts.java', [{ kind: 'ref', name: 'AConsts.X' }], repo),
|
||||
).toBe('/y/tail');
|
||||
});
|
||||
|
||||
it('a missing link in the chain still floors to null', () => {
|
||||
const repo = repoOf({
|
||||
'src/com/example/AConsts.java': `package com.example;
|
||||
import com.example.BConsts;
|
||||
public class AConsts { public static final String X = BConsts.MISSING + "/tail"; }`,
|
||||
'src/com/example/BConsts.java': `package com.example;
|
||||
public class BConsts { public static final String Y = "/y"; }`,
|
||||
});
|
||||
expect(resolveJavaConstant('src/com/example/AConsts.java', 'X', repo)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the fold is bounded in time as well as depth', () => {
|
||||
it('folds a 30-level shared-descendant DAG instead of exploring 2^30 paths', () => {
|
||||
// `X_k = X_{k+1} + X_{k+1}` re-folds each child once per reference without a
|
||||
// memo — O(2^depth). MAX_FOLD_LENGTH cannot save it here because every
|
||||
// intermediate value is the EMPTY string, so nothing ever accumulates.
|
||||
// Un-memoized this took 2.7 s at 26 levels and 11 s at 28, on the main
|
||||
// thread, for one route. The assertion is the explicit timeout below: a
|
||||
// regression does not fail this test slowly, it fails it.
|
||||
const lines = ['public static final String X30 = "";'];
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
lines.push(`public static final String X${i} = X${i + 1} + X${i + 1};`);
|
||||
}
|
||||
const repo = repoOf({ 'C.java': `public class C {\n${lines.join('\n')}\n}` });
|
||||
expect(resolveJavaConstant('C.java', 'X0', repo)).toBe('');
|
||||
}, 5_000);
|
||||
|
||||
it('still caps a chain that genuinely produces a huge string', () => {
|
||||
const lines = ['public static final String X30 = "a";'];
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
lines.push(`public static final String X${i} = X${i + 1} + X${i + 1};`);
|
||||
}
|
||||
const repo = repoOf({ 'C.java': `public class C {\n${lines.join('\n')}\n}` });
|
||||
expect(resolveJavaConstant('C.java', 'X0', repo)).toBeNull();
|
||||
}, 5_000);
|
||||
});
|
||||
|
||||
describe('text blocks keep the skip floor', () => {
|
||||
it('does not fold a text-block constant into a path with newlines and indentation', () => {
|
||||
// `unquoteSpringLiteral` has a `"""` arm that slices 3/-3, which would hand
|
||||
// back the raw block — leading newline and incidental indentation included,
|
||||
// both of which Java strips — and nothing downstream normalizes it. The old
|
||||
// fragment-join returned '' here, i.e. a skip; keep the skip.
|
||||
const src = [
|
||||
'public class C {',
|
||||
' public static final String X = """',
|
||||
' /api/v1/tb',
|
||||
' """;',
|
||||
'}',
|
||||
].join('\n');
|
||||
expect(extractJavaModuleConstants(parse(src)).literals.has('X')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -23,6 +23,8 @@ import {
|
|||
type ImportBinding,
|
||||
type RepoConstants,
|
||||
} from '../../src/core/ingestion/route-extractors/python-const-resolver.js';
|
||||
import { pythonProvider } from '../../src/core/ingestion/languages/python.js';
|
||||
import { shouldHarvestModuleConstants } from '../../src/core/ingestion/language-provider.js';
|
||||
|
||||
const lit = (value: string): Operand => ({ kind: 'literal', value });
|
||||
const ref = (name: string): Operand => ({ kind: 'ref', name });
|
||||
|
|
@ -377,3 +379,46 @@ describe('extractPythonModuleConstants — source-order snapshot (#2393)', () =>
|
|||
expect(resolveConstant('m.py', 'C', r)).toBe('/a/b/c');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Python provider harvests unconditionally (#2980 review P2)', () => {
|
||||
// A cheap content gate was added on the provider here and removed on review.
|
||||
// It required NAME immediately followed by `=`, so it silently dropped the
|
||||
// idiomatic typed-FastAPI shapes and every composed constant whose RHS starts
|
||||
// with an identifier — i.e. it REGRESSED routes that already resolve on main.
|
||||
// The parse worker treats a missing heuristic as "harvest"; pin that here so
|
||||
// the gate cannot come back without a decision.
|
||||
it('declares no moduleConstantHeuristic', () => {
|
||||
expect(pythonProvider.moduleConstantHeuristic).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['plain', 'API = "/api/v1"\nUSERS = API + "/users"\n'],
|
||||
['PEP 526 annotated', 'API: str = "/api/v1"\nUSERS: str = API + "/users"\n'],
|
||||
[
|
||||
'Final-annotated',
|
||||
'from typing import Final\nAPI: Final[str] = "/api/v1"\nUSERS: Final[str] = API + "/users"\n',
|
||||
],
|
||||
['composed, identifier RHS', 'API = _base()\nUSERS = API + "/users"\n'],
|
||||
])('the worker GATE admits the %s shape, and the extractor harvests it', (_name, src) => {
|
||||
// Drive the gate the worker actually evaluates, not just the extractor
|
||||
// behind it. Asserting only on `extract(src)` would stay green if the worker
|
||||
// went back to `provider.moduleConstantHeuristic?.(content)` — undefined read
|
||||
// as "skip" — which is precisely the regression this pins.
|
||||
expect(shouldHarvestModuleConstants(pythonProvider, src)).toBe(true);
|
||||
const mc = extract(src);
|
||||
expect(mc.literals.size + mc.exprs.size + mc.imports.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('a provider with no extractModuleConstants is never harvested', () => {
|
||||
expect(shouldHarvestModuleConstants({}, 'API = "/api"')).toBe(false);
|
||||
});
|
||||
|
||||
it('a declared heuristic still gates the harvest', () => {
|
||||
const provider = {
|
||||
extractModuleConstants: pythonProvider.extractModuleConstants,
|
||||
moduleConstantHeuristic: (content: string) => content.includes('ROUTES'),
|
||||
};
|
||||
expect(shouldHarvestModuleConstants(provider, 'ROUTES = "/a"')).toBe(true);
|
||||
expect(shouldHarvestModuleConstants(provider, 'OTHER = "/a"')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -292,12 +292,12 @@ const CASES: ReadonlyMap<SupportedLanguages, ConformanceCase> = new Map([
|
|||
[
|
||||
SupportedLanguages.PHP,
|
||||
{
|
||||
files: ['app/Models/User.php', 'lib/Legacy/Missing.php', 'app/Main.php'],
|
||||
files: ['app/Ghost/Missing.php', 'app/Models/User.php', 'app/Main.php'],
|
||||
fromFile: 'app/Main.php',
|
||||
resolutionConfig: PHP_COMPOSER,
|
||||
external: 'Vendor\\Ghost\\Missing',
|
||||
decoy: 'lib/Legacy/Missing.php',
|
||||
reachesDecoy: 'App\\Models\\User',
|
||||
decoy: 'app/Ghost/Missing.php',
|
||||
reachesDecoy: 'App\\Ghost\\Missing',
|
||||
parsedImport: PHP_FUNCTION_IMPORT,
|
||||
},
|
||||
],
|
||||
|
|
@ -374,7 +374,6 @@ const CASES: ReadonlyMap<SupportedLanguages, ConformanceCase> = new Map([
|
|||
*/
|
||||
const KNOWN_GAPS: ReadonlyMap<SupportedLanguages, string> = new Map<SupportedLanguages, string>([
|
||||
[SupportedLanguages.Ruby, '`rails/generators` -> `lib/generators.rb`'],
|
||||
[SupportedLanguages.PHP, '`Vendor\\Ghost\\Missing` -> `lib/Legacy/Missing.php`'],
|
||||
[SupportedLanguages.Dart, '`package:http/http.dart` -> `lib/http.dart`'],
|
||||
[SupportedLanguages.Swift, '`Foundation` -> `Sources/Foundation/Thing.swift`'],
|
||||
[SupportedLanguages.C, '`stdio.h` -> `src/stdio.h`'],
|
||||
|
|
|
|||
|
|
@ -351,6 +351,7 @@ const NESTED_PSR4 = composer([
|
|||
['App\\Models', 'app/Domain'],
|
||||
]);
|
||||
const ROOT_PSR4 = composer([['App', '']]);
|
||||
const CATCH_ALL_PSR4 = composer([['', 'src']]);
|
||||
const TRAILING_SLASH_PSR4 = composer([['App', 'app/']]);
|
||||
|
||||
/**
|
||||
|
|
@ -609,18 +610,38 @@ const HAND_CASES: readonly HandCase[] = [
|
|||
expectedViaWorkspace: 'app/Models/User.php',
|
||||
},
|
||||
{
|
||||
// KNOWN LIMITATION: an empty `dirPrefix` builds the class-style path as
|
||||
// `'' + '/Models/User' + '.php'` = `/Models/User.php`, with a leading slash
|
||||
// no repo-relative path has — so a root PSR-4 mapping never hits that leg,
|
||||
// and `nsDir` comes out `/Models` which no directory bucket holds either.
|
||||
// The answer is the suffix leg's, and only at path-part 2 (`/User.php`):
|
||||
// `Models/User.php` is the whole path, invisible to `/Models/User.php`.
|
||||
// An empty directory prefix maps the namespace directly to the repository
|
||||
// root. The vendor decoy comes first so suffix fallback would choose it.
|
||||
name: 'psr-4 mapped to the repo root',
|
||||
files: ['Models/User.php'],
|
||||
files: ['vendor/Models/User.php', 'Models/User.php'],
|
||||
target: 'App\\Models\\User',
|
||||
composer: ROOT_PSR4,
|
||||
expected: 'Models/User.php',
|
||||
expectedViaWorkspace: 'Models/User.php',
|
||||
expectedViaWorkspace: 'vendor/Models/User.php',
|
||||
},
|
||||
{
|
||||
name: 'leading namespace separator uses the mapped path',
|
||||
files: ['vendor/App/Models/User.php', 'app/Models/User.php'],
|
||||
target: '\\App\\Models\\User',
|
||||
composer: APP_PSR4,
|
||||
expected: 'app/Models/User.php',
|
||||
expectedViaWorkspace: 'vendor/App/Models/User.php',
|
||||
},
|
||||
{
|
||||
name: 'empty namespace prefix resolves beneath its configured directory',
|
||||
files: ['vendor/Vendor/Ghost/Missing.php', 'src/Vendor/Ghost/Missing.php'],
|
||||
target: 'Vendor\\Ghost\\Missing',
|
||||
composer: CATCH_ALL_PSR4,
|
||||
expected: 'src/Vendor/Ghost/Missing.php',
|
||||
expectedViaWorkspace: 'vendor/Vendor/Ghost/Missing.php',
|
||||
},
|
||||
{
|
||||
name: 'empty namespace prefix does not escape its configured directory',
|
||||
files: ['legacy/Vendor/Ghost/Missing.php'],
|
||||
target: 'Vendor\\Ghost\\Missing',
|
||||
composer: CATCH_ALL_PSR4,
|
||||
expected: null,
|
||||
expectedViaWorkspace: 'legacy/Vendor/Ghost/Missing.php',
|
||||
},
|
||||
{
|
||||
// KNOWN LIMITATION: a mapping kept with its trailing slash concatenates to
|
||||
|
|
@ -996,14 +1017,14 @@ describe('PHP import-target parity with the pre-index implementation (#2901)', (
|
|||
...workspaceHits.map((testCase) => testCase.expectedViaWorkspace),
|
||||
]);
|
||||
|
||||
expect(scopeHits.length).toBe(31);
|
||||
expect(workspaceHits.length).toBe(23);
|
||||
expect(scopeHits.length).toBe(33);
|
||||
expect(workspaceHits.length).toBe(26);
|
||||
expect(distinct.size).toBeGreaterThan(20);
|
||||
// The two adapters must not be the same assertion twice: `composer` and
|
||||
// `context` are visible only through the ScopeResolver one.
|
||||
expect(
|
||||
HAND_CASES.filter((testCase) => testCase.expected !== testCase.expectedViaWorkspace).length,
|
||||
).toBe(9);
|
||||
).toBe(13);
|
||||
});
|
||||
|
||||
it('agrees on every generated target × composer configuration', () => {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
import type { ParsedFile, ParsedImport, SymbolDefinition } from 'gitnexus-shared';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { ComposerConfig } from '../../../../src/core/ingestion/language-config.js';
|
||||
import { resolvePhpImportTargetInternal } from '../../../../src/core/ingestion/languages/php/import-target.js';
|
||||
import {
|
||||
loadComposerConfig,
|
||||
type ComposerConfig,
|
||||
} from '../../../../src/core/ingestion/language-config.js';
|
||||
import {
|
||||
loadPhpComposerConfig,
|
||||
resolvePhpImportTargetInternal,
|
||||
} from '../../../../src/core/ingestion/languages/php/import-target.js';
|
||||
|
||||
const composerConfig: ComposerConfig = { psr4: new Map([['App', 'app']]) };
|
||||
|
||||
|
|
@ -32,9 +41,317 @@ const functionImport: ParsedImport = {
|
|||
};
|
||||
|
||||
describe('resolvePhpImportTargetInternal declaration selection', () => {
|
||||
it('rejects namespaces outside an authoritative PSR-4 map', () => {
|
||||
const files = new Set(['app/Models/User.php', 'lib/Legacy/Missing.php']);
|
||||
|
||||
expect(
|
||||
resolvePhpImportTargetInternal(
|
||||
'Vendor\\Ghost\\Missing',
|
||||
'app/Main.php',
|
||||
files,
|
||||
composerConfig,
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolvePhpImportTargetInternal('App\\Models\\User', 'app/Main.php', files, composerConfig),
|
||||
).toBe('app/Models/User.php');
|
||||
});
|
||||
|
||||
it('rejects ambiguous function and constant declaration fallbacks', () => {
|
||||
const first = 'app/Ghost/First.php';
|
||||
const second = 'app/Ghost/Second.php';
|
||||
const parsedFiles = [
|
||||
parsedFile(first, [
|
||||
definition(first, 'Function', 'missing'),
|
||||
definition(first, 'Variable', 'MISSING'),
|
||||
]),
|
||||
parsedFile(second, [
|
||||
definition(second, 'Function', 'missing'),
|
||||
definition(second, 'Variable', 'MISSING'),
|
||||
]),
|
||||
];
|
||||
const files = new Set(parsedFiles.map((parsed) => parsed.filePath));
|
||||
|
||||
for (const [name, importedSymbolKind] of [
|
||||
['missing', 'function'],
|
||||
['MISSING', 'const'],
|
||||
] as const) {
|
||||
const parsedImport: ParsedImport = {
|
||||
kind: 'named',
|
||||
localName: name,
|
||||
importedName: name,
|
||||
targetRaw: `App\\Ghost\\${name}`,
|
||||
importedSymbolKind,
|
||||
};
|
||||
|
||||
expect(
|
||||
resolvePhpImportTargetInternal(
|
||||
parsedImport.targetRaw,
|
||||
'app/Main.php',
|
||||
files,
|
||||
composerConfig,
|
||||
{ parsedFiles, parsedImport },
|
||||
),
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves suffix fallback without authoritative namespace evidence', () => {
|
||||
const files = new Set(['lib/Legacy/Missing.php']);
|
||||
const importPath = 'Vendor\\Ghost\\Missing';
|
||||
|
||||
expect(resolvePhpImportTargetInternal(importPath, 'app/Main.php', files)).toBe(
|
||||
'lib/Legacy/Missing.php',
|
||||
);
|
||||
expect(
|
||||
resolvePhpImportTargetInternal(importPath, 'app/Main.php', files, { psr4: new Map() }),
|
||||
).toBe('lib/Legacy/Missing.php');
|
||||
expect(
|
||||
resolvePhpImportTargetInternal(importPath, 'app/Main.php', files, {
|
||||
psr4: new Map([['', 'src']]),
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolvePhpImportTargetInternal(importPath, 'app/Main.php', files, {
|
||||
psr4: new Map([['App', 'app']]),
|
||||
hasUnmodeledAutoload: true,
|
||||
}),
|
||||
).toBe('lib/Legacy/Missing.php');
|
||||
});
|
||||
|
||||
it('resolves catch-all PSR-4 class and function imports inside the configured root', () => {
|
||||
const user = '/repo/src/Vendor/Models/User.php';
|
||||
const helpers = '/repo/src/Vendor/Models/helpers.php';
|
||||
const parsedFiles = [
|
||||
parsedFile(user, [definition(user, 'Class', 'Vendor\\Models\\User')]),
|
||||
parsedFile(helpers, [definition(helpers, 'Function', 'Vendor\\Models\\findUser')]),
|
||||
];
|
||||
const config: ComposerConfig = { psr4: new Map([['', '/repo/src']]) };
|
||||
const files = new Set(parsedFiles.map((parsed) => parsed.filePath));
|
||||
|
||||
expect(
|
||||
resolvePhpImportTargetInternal('Vendor\\Models\\User', '/repo/app/Main.php', files, config),
|
||||
).toBe(user);
|
||||
|
||||
const parsedImport: ParsedImport = {
|
||||
kind: 'named',
|
||||
localName: 'findUser',
|
||||
importedName: 'findUser',
|
||||
targetRaw: 'Vendor\\Models\\findUser',
|
||||
importedSymbolKind: 'function',
|
||||
};
|
||||
expect(
|
||||
resolvePhpImportTargetInternal(parsedImport.targetRaw, '/repo/app/Main.php', files, config, {
|
||||
parsedFiles,
|
||||
parsedImport,
|
||||
}),
|
||||
).toBe(helpers);
|
||||
});
|
||||
|
||||
it('does not suffix-resolve outside an authoritative catch-all directory', () => {
|
||||
const decoy = '/repo/legacy/Vendor/Ghost/Missing.php';
|
||||
expect(
|
||||
resolvePhpImportTargetInternal(
|
||||
'Vendor\\Ghost\\Missing',
|
||||
'/repo/app/Main.php',
|
||||
new Set([decoy]),
|
||||
{ psr4: new Map([['', '/repo/src']]) },
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('does not fabricate a class edge from a root-mapped sibling file', () => {
|
||||
const config: ComposerConfig = { psr4: new Map([['App', '']]) };
|
||||
const first = new Set(['Sibling.php', 'Other.php']);
|
||||
const reversed = new Set([...first].reverse());
|
||||
|
||||
expect(resolvePhpImportTargetInternal('App\\Missing', 'Main.php', first, config)).toBeNull();
|
||||
expect(resolvePhpImportTargetInternal('App\\Missing', 'Main.php', reversed, config)).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps function and constant imports inside a relative catch-all root', () => {
|
||||
const decoy = 'legacy/src/Vendor/Ghost/helpers.php';
|
||||
const parsedFiles = [
|
||||
parsedFile(decoy, [
|
||||
definition(decoy, 'Function', 'Vendor\\Ghost\\missing'),
|
||||
definition(decoy, 'Variable', 'Vendor\\Ghost\\MISSING'),
|
||||
]),
|
||||
];
|
||||
const config: ComposerConfig = { psr4: new Map([['', 'src']]) };
|
||||
const files = new Set([decoy]);
|
||||
|
||||
for (const [name, importedSymbolKind] of [
|
||||
['missing', 'function'],
|
||||
['MISSING', 'const'],
|
||||
] as const) {
|
||||
const parsedImport: ParsedImport = {
|
||||
kind: 'named',
|
||||
localName: name,
|
||||
importedName: name,
|
||||
targetRaw: `Vendor\\Ghost\\${name}`,
|
||||
importedSymbolKind,
|
||||
};
|
||||
expect(
|
||||
resolvePhpImportTargetInternal(parsedImport.targetRaw, 'app/Main.php', files, config, {
|
||||
parsedFiles,
|
||||
parsedImport,
|
||||
}),
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('loads production and development PSR-4 mappings', () => {
|
||||
const repo = mkdtempSync(join(tmpdir(), 'gitnexus-php-composer-'));
|
||||
try {
|
||||
writeFileSync(
|
||||
join(repo, 'composer.json'),
|
||||
JSON.stringify({
|
||||
autoload: { 'psr-4': { 'App\\': 'app\\' }, classmap: ['legacy/'] },
|
||||
'autoload-dev': { 'psr-4': { 'Tests\\': ['tests/', 'fallback-tests/'] } },
|
||||
}),
|
||||
);
|
||||
|
||||
const config = loadPhpComposerConfig(repo);
|
||||
expect([...(config?.psr4.entries() ?? [])]).toEqual([
|
||||
['App', 'app'],
|
||||
['Tests', 'tests'],
|
||||
]);
|
||||
expect(config?.hasUnmodeledAutoload).toBe(true);
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('normalizes leading dot segments and preserves catch-all array fallback', () => {
|
||||
const repo = mkdtempSync(join(tmpdir(), 'gitnexus-php-composer-catch-all-'));
|
||||
try {
|
||||
writeFileSync(
|
||||
join(repo, 'composer.json'),
|
||||
JSON.stringify({ autoload: { 'psr-4': { '': ['./src/', './lib/'] } } }),
|
||||
);
|
||||
const config = loadPhpComposerConfig(repo);
|
||||
expect(config?.psr4.get('')).toBe('src');
|
||||
expect(config?.hasUnmodeledAutoload).toBe(true);
|
||||
expect(
|
||||
resolvePhpImportTargetInternal(
|
||||
'Vendor\\Models\\User',
|
||||
'app/Main.php',
|
||||
new Set(['lib/Vendor/Models/User.php']),
|
||||
config,
|
||||
),
|
||||
).toBe('lib/Vendor/Models/User.php');
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('unions package-local Composer mappings using repository-relative roots', () => {
|
||||
const repo = mkdtempSync(join(tmpdir(), 'gitnexus-php-composer-monorepo-'));
|
||||
try {
|
||||
mkdirSync(join(repo, 'packages', 'admin'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(repo, 'composer.json'),
|
||||
JSON.stringify({ autoload: { 'psr-4': { 'App\\': './src/' } } }),
|
||||
);
|
||||
writeFileSync(
|
||||
join(repo, 'packages', 'admin', 'composer.json'),
|
||||
JSON.stringify({ autoload: { 'psr-4': { 'Admin\\': './src/' } } }),
|
||||
);
|
||||
|
||||
const config = loadPhpComposerConfig(repo);
|
||||
expect([...(config?.psr4.entries() ?? [])]).toEqual([
|
||||
['App', 'src'],
|
||||
['Admin', 'packages/admin/src'],
|
||||
]);
|
||||
expect(
|
||||
resolvePhpImportTargetInternal(
|
||||
'Admin\\Controller',
|
||||
'src/Main.php',
|
||||
new Set(['packages/admin/src/Controller.php']),
|
||||
config,
|
||||
),
|
||||
).toBe('packages/admin/src/Controller.php');
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not let autoload-dev establish authority or override production mappings', () => {
|
||||
const repo = mkdtempSync(join(tmpdir(), 'gitnexus-php-composer-dev-'));
|
||||
try {
|
||||
writeFileSync(
|
||||
join(repo, 'composer.json'),
|
||||
JSON.stringify({
|
||||
autoload: { 'psr-4': { 'App\\': 'src/' } },
|
||||
'autoload-dev': { 'psr-4': { 'App\\': 'tests/app/', 'Tests\\': 'tests/' } },
|
||||
}),
|
||||
);
|
||||
const config = loadPhpComposerConfig(repo);
|
||||
expect(config?.psr4.get('App')).toBe('src');
|
||||
expect(config?.authoritativePsr4).toEqual(new Set(['App']));
|
||||
|
||||
writeFileSync(
|
||||
join(repo, 'composer.json'),
|
||||
JSON.stringify({ 'autoload-dev': { 'psr-4': { 'Tests\\': 'tests/' } } }),
|
||||
);
|
||||
const devOnly = loadPhpComposerConfig(repo);
|
||||
expect(devOnly?.authoritativePsr4?.size).toBe(0);
|
||||
expect(
|
||||
resolvePhpImportTargetInternal(
|
||||
'Vendor\\Ghost\\Missing',
|
||||
'tests/Main.php',
|
||||
new Set(['legacy/Vendor/Ghost/Missing.php']),
|
||||
devOnly,
|
||||
),
|
||||
).toBe('legacy/Vendor/Ghost/Missing.php');
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('fails open for unmodeled development autoload and ignores invalid PSR-4 sections', () => {
|
||||
const repo = mkdtempSync(join(tmpdir(), 'gitnexus-php-composer-unmodeled-'));
|
||||
try {
|
||||
writeFileSync(
|
||||
join(repo, 'composer.json'),
|
||||
JSON.stringify({
|
||||
autoload: { 'psr-4': [] },
|
||||
'autoload-dev': { 'psr-0': { Legacy_: 'tests/legacy/' } },
|
||||
}),
|
||||
);
|
||||
const config = loadPhpComposerConfig(repo);
|
||||
expect(config?.psr4.size).toBe(0);
|
||||
expect(config?.hasUnmodeledAutoload).toBe(true);
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps both Composer config loaders conservative for unmodeled autoload entries', async () => {
|
||||
const repo = mkdtempSync(join(tmpdir(), 'gitnexus-php-composer-shared-'));
|
||||
try {
|
||||
writeFileSync(
|
||||
join(repo, 'composer.json'),
|
||||
JSON.stringify({
|
||||
autoload: {
|
||||
'psr-4': { 'App\\': './app/' },
|
||||
files: ['src/helpers.php'],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const config = await loadComposerConfig(repo);
|
||||
expect([...(config?.psr4.entries() ?? [])]).toEqual([['App', 'app']]);
|
||||
expect(config?.hasUnmodeledAutoload).toBe(false);
|
||||
expect(loadPhpComposerConfig(repo)?.hasUnmodeledAutoload).toBe(false);
|
||||
} finally {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('finds a unique function declaration when the symbol name is not a filename', () => {
|
||||
const user = '/repo/app/Models/User.php';
|
||||
const factory = '/repo/app/Models/UserFactory.php';
|
||||
const user = 'app/Models/User.php';
|
||||
const factory = 'app/Models/UserFactory.php';
|
||||
const parsedFiles = [
|
||||
parsedFile(user, [definition(user, 'Class', 'User')]),
|
||||
parsedFile(factory, [definition(factory, 'Function', 'getUser')]),
|
||||
|
|
@ -52,8 +369,8 @@ describe('resolvePhpImportTargetInternal declaration selection', () => {
|
|||
});
|
||||
|
||||
it('reuses directory selection without leaking candidates across namespaces', () => {
|
||||
const models = '/repo/app/Models/functions.php';
|
||||
const services = '/repo/app/Services/functions.php';
|
||||
const models = 'app/Models/functions.php';
|
||||
const services = 'app/Services/functions.php';
|
||||
const parsedFiles = [
|
||||
parsedFile(models, [definition(models, 'Function', 'getUser')]),
|
||||
parsedFile(services, [definition(services, 'Function', 'getUser')]),
|
||||
|
|
@ -79,8 +396,8 @@ describe('resolvePhpImportTargetInternal declaration selection', () => {
|
|||
});
|
||||
|
||||
it('fails closed when the namespace has duplicate function declarations', () => {
|
||||
const first = '/repo/app/Models/First.php';
|
||||
const second = '/repo/app/Models/Second.php';
|
||||
const first = 'app/Models/First.php';
|
||||
const second = 'app/Models/Second.php';
|
||||
const parsedFiles = [
|
||||
parsedFile(first, [definition(first, 'Function', 'getUser')]),
|
||||
parsedFile(second, [definition(second, 'Function', 'getUser')]),
|
||||
|
|
@ -98,8 +415,8 @@ describe('resolvePhpImportTargetInternal declaration selection', () => {
|
|||
});
|
||||
|
||||
it('never resolves into a different root that shares a directory suffix', () => {
|
||||
const app = '/repo/app/Models/functions.php';
|
||||
const vendor = '/repo/vendor/pkg/app/Models/helpers.php';
|
||||
const app = 'app/Models/functions.php';
|
||||
const vendor = 'vendor/pkg/app/Models/helpers.php';
|
||||
const parsedFiles = [
|
||||
parsedFile(app, []),
|
||||
parsedFile(vendor, [definition(vendor, 'Function', 'getUser')]),
|
||||
|
|
@ -117,8 +434,8 @@ describe('resolvePhpImportTargetInternal declaration selection', () => {
|
|||
});
|
||||
|
||||
it('stays out of suffix-colliding roots even when both declare the function', () => {
|
||||
const app = '/repo/app/Models/functions.php';
|
||||
const vendor = '/repo/vendor/pkg/app/Models/helpers.php';
|
||||
const app = 'app/Models/functions.php';
|
||||
const vendor = 'vendor/pkg/app/Models/helpers.php';
|
||||
const parsedFiles = [
|
||||
parsedFile(app, [definition(app, 'Function', 'getUser')]),
|
||||
parsedFile(vendor, [definition(vendor, 'Function', 'getUser')]),
|
||||
|
|
@ -136,7 +453,7 @@ describe('resolvePhpImportTargetInternal declaration selection', () => {
|
|||
});
|
||||
|
||||
it('resolves a constant only when its namespace directory has one candidate file', () => {
|
||||
const constants = '/repo/app/Config/constants.php';
|
||||
const constants = 'app/Config/constants.php';
|
||||
const parsedFiles = [parsedFile(constants, [])];
|
||||
const parsedImport: ParsedImport = {
|
||||
kind: 'named',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue