diff --git a/.github/actions/setup-gitnexus-web/action.yml b/.github/actions/setup-gitnexus-web/action.yml index fb734d3ca..36b0b963c 100644 --- a/.github/actions/setup-gitnexus-web/action.yml +++ b/.github/actions/setup-gitnexus-web/action.yml @@ -19,8 +19,8 @@ runs: # Browsers are installed explicitly by e2e. Typecheck only needs types. PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' - # Compile shared with the web package's TypeScript 5. Do not npm-ci - # gitnexus-shared (TypeScript 7 optional-platform install, ~7 minutes). + # Compile shared with the web package's TypeScript 7. Do not npm-ci + # gitnexus-shared (a second optional-platform install, ~7 minutes). - name: Build gitnexus-shared # node + lib/tsc.js — same on Windows/macOS/Linux. Do not use .bin/tsc # (tsc.cmd on Windows; execFileSync cannot launch .cmd without a shell). diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 535204a38..9527828ac 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -93,6 +93,9 @@ updates: # The extension version is a separate upstream constant, not derivable # from the core version (see vendor/lbug-fts/manifest.json). - dependency-name: '@ladybugdb/core' + - dependency-name: typescript + update-types: + - version-update:semver-major # gitnexus-web (thin frontend client). - package-ecosystem: npm @@ -111,6 +114,10 @@ updates: labels: - dependencies - frontend + ignore: + - dependency-name: typescript + update-types: + - version-update:semver-major # Shared types package. - package-ecosystem: npm @@ -128,3 +135,9 @@ updates: include: scope labels: - dependencies + ignore: + # Keep shared on the same TypeScript major as CLI/web. A shared-only + # major bump reintroduces a compiler split this repo unified. + - dependency-name: typescript + update-types: + - version-update:semver-major diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 6f4bfdbec..bbf5d01af 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -618,6 +618,17 @@ jobs: run: node --import tsx bench/rust-cargo-targets/measure.mjs --check working-directory: gitnexus + - name: Swift Package.swift import-resolve guards (#2964, #2931) + if: ${{ !cancelled() }} + # Build-free: same baseline approach as parse-dispatch-rounds — + # exact declared/SDK/undeclared floors plus a fingerprint, then + # ratio timing only (resolveSwiftImportTarget, swiftPackageStrategy, + # parseSwiftPackageManifest 4n/n). Pins declaration-only resolve, + # https:// factory survival, and #2931 segment-boundary membership. + # See bench/swift-package-imports/measure.mjs. + run: node --import tsx bench/swift-package-imports/measure.mjs --check + working-directory: gitnexus + - name: MCP tools/list countRepos vs listRepos guards (#3259, #3184) if: ${{ !cancelled() }} # Build-free: exact registry cardinality + tool-roster + schema-flag diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c7c85ba03..9075d8c90 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -48,7 +48,7 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 with: languages: ${{ matrix.language }} queries: security-and-quality @@ -87,6 +87,6 @@ jobs: - '.github/scripts/fetch-lbug-fts-artifacts.mjs' - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 with: category: '/language:${{ matrix.language }}' diff --git a/.github/workflows/gitnexus-skill-evolution.yml b/.github/workflows/gitnexus-skill-evolution.yml index 22f0bdfcc..03b3655ca 100644 --- a/.github/workflows/gitnexus-skill-evolution.yml +++ b/.github/workflows/gitnexus-skill-evolution.yml @@ -330,24 +330,20 @@ jobs: set -euo pipefail # The benchmark's task bindings sandbox-copy node_modules from the # monorepo root as well as gitnexus-shared and gitnexus (see the - # sandbox_copy entries in tasks.scenarios.yaml). The two steps below - # install the subpackage trees; the root tree needs its own install - # or capture_task_dependency_binding aborts at task binding on the - # missing root node_modules. + # sandbox_dependencies entries in tasks.scenarios.yaml). gitnexus + # npm ci plus build compiles shared through parent lib/tsc.js; do + # not npm ci gitnexus-shared (second TypeScript 7 optional install). npm ci - - name: Build pinned shared runtime - run: | - set -euo pipefail - npm ci - npm run build - working-directory: gitnexus-shared - - name: Install and build pinned GitNexus runtime run: | set -euo pipefail npm ci npm run build + # Task YAML still mounts gitnexus-shared/node_modules. Keep an empty + # directory so capture_task_dependency_binding does not abort, without + # installing TypeScript 7 inside shared. + mkdir -p ../gitnexus-shared/node_modules working-directory: gitnexus - name: Point the benchmark task repo at the checkout diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 50afe6914..d22add2ff 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -53,6 +53,6 @@ jobs: retention-days: 5 - name: Upload to Security tab - uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 with: sarif_file: results.sarif diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 33c3f3257..106deaf2b 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -76,7 +76,7 @@ jobs: exit-code: '0' - name: Upload to Security tab - uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 with: sarif_file: trivy-${{ matrix.image.name }}.sarif category: trivy-${{ matrix.image.name }} diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml index 15f4e0534..c3aeb5fa8 100644 --- a/.github/workflows/workflow-lint.yml +++ b/.github/workflows/workflow-lint.yml @@ -76,7 +76,7 @@ jobs: continue-on-error: true - name: Upload SARIF - uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 with: sarif_file: zizmor.sarif category: zizmor diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 23a6ae28c..b2a0c540e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,14 +16,19 @@ This project uses the [PolyForm Noncommercial License 1.0.0](https://polyformpro **Prerequisites:** Node.js — `gitnexus/` requires `^22.18.0 || >=24.11.0` and `gitnexus-web/` requires `^20.19.0 || >=22.12.0` (enforced via the `engines` field in each package). Use `nvm install` to match the local version. 1. Clone the repository. -2. **Shared package:** `cd gitnexus-shared && npm install && npm run build` -3. **CLI / MCP package:** `cd ../gitnexus && npm install && npm run build` -4. **Web UI (if needed):** `cd ../gitnexus-web && npm install` -5. Run tests as described in [TESTING.md](TESTING.md). +2. **CLI / MCP package:** `cd gitnexus && npm install && npm run build` + `prepare` / `scripts/build.js` compiles `gitnexus-shared` with + `node …/typescript/lib/tsc.js` from this package. Do not `npm install` or + `npm ci` inside `gitnexus-shared/` — that is a second TypeScript 7 + optional-platform install and is not what `setup-gitnexus` does. +3. **Web UI (if needed):** `cd gitnexus-web && npm install` + If you skipped step 2, compile shared with the web compiler first: + `cd gitnexus-shared && node ../gitnexus-web/node_modules/typescript/lib/tsc.js` +4. Run tests as described in [TESTING.md](TESTING.md). -The CLI build imports `gitnexus-shared`, so a fresh clone must install and build -the shared package before running `npm install` in `gitnexus/`. This is the same -order used by the repository's `setup-gitnexus` CI action. +The CLI build imports `gitnexus-shared`, so `gitnexus-shared/dist` must exist +before `gitnexus` typecheck. That emit uses a parent package's TypeScript 7 +`lib/tsc.js`, matching `setup-gitnexus`, `setup-gitnexus-web`, and Vercel. ### Containerized development (optional) @@ -70,7 +75,7 @@ Commits within a PR may use any style — only the **merged PR title** shows up ## Before you open a PR - [ ] Tests pass for the packages you touched (`gitnexus` and/or `gitnexus-web`). -- [ ] Typecheck passes: `npx tsc --noEmit` in `gitnexus/` and `npx tsc -b --noEmit` in `gitnexus-web/`. +- [ ] Typecheck passes: `npx tsc --noEmit` in `gitnexus/` and `npx tsc -b --noEmit` in `gitnexus-web/`. Those commands use TypeScript 7. The web app tsconfig lists `lib` `DOM`/`DOM.Iterable` and `jsx: react-jsx` so React/JSX typecheck on 7; Vite/`vitest` keep `@vitejs/plugin-react` with the automatic JSX runtime. Build `gitnexus-shared/dist` first with a parent `lib/tsc.js` (a `gitnexus` install/`npm run build` does this). Repo ESLint stays syntax-only on a TypeScript 5.x peer until typescript-eslint supports 7. - [ ] No secrets, tokens, or machine-specific paths committed. - [ ] Documentation updated if behavior or public CLI/MCP contract changes. - [ ] Every new `GITNEXUS_*` environment variable has a row in the **Environment variables** table in [README.md](README.md) — variable, default, effect, and when to tune it. diff --git a/DoD.md b/DoD.md index a0f6eed08..ff2297903 100644 --- a/DoD.md +++ b/DoD.md @@ -113,7 +113,7 @@ Run the commands relevant to the touched area. If something cannot be run in the ### 4.1 Build ordering -- [ ] `gitnexus-shared/` dist is built before consuming packages are typechecked or tested (CI uses the `setup-gitnexus` action for this — local runs must match). +- [ ] `gitnexus-shared/` dist is built before consuming packages are typechecked or tested (CI uses the `setup-gitnexus` action, which compiles shared with the parent TypeScript 7 `lib/tsc.js` — local runs must match). ### 4.2 If `gitnexus/` changed @@ -123,13 +123,13 @@ Run the commands relevant to the touched area. If something cannot be run in the ### 4.3 If `gitnexus-web/` changed -- [ ] `cd gitnexus-web && npx tsc -b --noEmit` +- [ ] `cd gitnexus-web && npx tsc -b --noEmit` (TypeScript 7 typechecks React/JSX: `jsx: react-jsx`, `lib` includes `DOM`) - [ ] `cd gitnexus-web && npm test` - [ ] `cd gitnexus-web && npm run test:e2e` when browser flows or user-facing UI behavior changed ### 4.4 If `gitnexus-shared/` changed -- [ ] Shared package builds cleanly (`npm run build` in `gitnexus-shared/`) +- [ ] Shared package builds cleanly from a parent TypeScript 7 shim after that parent is installed (`cd gitnexus-shared && node ../gitnexus/node_modules/typescript/lib/tsc.js`, or `node ../gitnexus-web/node_modules/typescript/lib/tsc.js` after a web install). Do not `npm install` / `npm ci` in `gitnexus-shared/` for this check. - [ ] Dependent packages still typecheck and test after the shared change — verify both CLI and web consumers together ### 4.5 If CI workflows or release pipelines changed diff --git a/README.md b/README.md index 03892ca05..ccdb988ea 100644 --- a/README.md +++ b/README.md @@ -407,7 +407,8 @@ backoff. Invalid `.gitnexusrc` or ignore-file reloads pause ordinary refreshes until the control file is fixed. Stop the watcher with Ctrl+C. Watch mode accepts `--debounce`, `--workers`, `--worker-timeout`, -`--max-file-size`, `--branch`, `--pdg`, `--skip-fts`, `--name`, `--allow-duplicate-name`, and +`--max-file-size`, `--max-processes`, `--max-process-branching`, +`--max-process-trace-depth`, `--max-entry-point-candidates`, `--branch`, `--pdg`, `--skip-fts`, `--name`, `--allow-duplicate-name`, and `--verbose`. Explicit one-shot options such as `--force`, `--repair-fts`, embedding flags, `--skills`, `--self-commit`, `--index-only`, and `--skip-git` are rejected. Unsupported defaults from `.gitnexusrc` are ignored with a @@ -453,6 +454,8 @@ gitnexus analyze --verbose # Log skipped files when parsers are unavailabl gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses gitnexus analyze --workers # Parse worker pool size (>=1; default: cores-1, capped at 16, # auto-sized to the repo). 0 is rejected — there is no sequential mode. +gitnexus analyze --max-processes # Process-detection process cap (replaces dynamic max(20, round(symbols/10))) +gitnexus analyze --max-entry-point-candidates # Ranked entry-point pool (default 200; raise when the warning names it) gitnexus analyze --spring-actuator ./actuator # Enrich with local Spring Boot Actuator JSON snapshots gitnexus analyze --asyncapi-spec ./docs/asyncapi # Resolve broker addresses from AsyncAPI 3.x documents gitnexus analyze --wal-checkpoint-threshold 67108864 # LadybugDB WAL auto-checkpoint threshold in bytes @@ -576,15 +579,15 @@ Notes: - The default branch is resolved as: `--default-branch` > `.gitnexusrc` `defaultBranch`/`branch` > auto-detected `origin/HEAD` > `main`. - `skipContextFiles` / `skipAiContext` are aliases for `skipAgentsMd` — they skip the `AGENTS.md` / `CLAUDE.md` block only. They do **not** imply `skipSkills`. `indexOnly` is the stronger option that skips all file injection. -- Supported keys: `defaultBranch` (`branch`), `skipAgentsMd` (`skipContextFiles`, `skipAiContext`), `skipSkills`, `indexOnly`, `stats`/`noStats`, `embeddings`, `dropEmbeddings`, `name`, `allowDuplicateName`, `maxFileSize`, `workerTimeout`, `walCheckpointThreshold`, `workers`, `springActuator`, `embeddingThreads`, `embeddingBatchSize`, `embeddingSubBatchSize`, `embeddingDevice`. -- The file is JSON only. Unknown keys and invalid values fail fast with an actionable error before analysis starts. +- Supported keys: `defaultBranch` (`branch`), `skipAgentsMd` (`skipContextFiles`, `skipAiContext`), `skipSkills`, `indexOnly`, `stats`/`noStats`, `embeddings`, `dropEmbeddings`, `name`, `allowDuplicateName`, `maxFileSize`, `workerTimeout`, `walCheckpointThreshold`, `workers`, `maxProcesses`, `maxProcessBranching`, `maxProcessTraceDepth`, `maxEntryPointCandidates`, `springActuator`, `embeddingThreads`, `embeddingBatchSize`, `embeddingSubBatchSize`, `embeddingDevice`. +- The file is JSON only. Unknown keys and wrong JSON types fail fast with an actionable error before analysis starts. Process-detection knobs (`maxProcesses`, `maxProcessBranching`, `maxProcessTraceDepth`, `maxEntryPointCandidates`) that are not a positive integer warn and fall through to env, then the built-in default.
Environment variables -Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max-file-size`, `--verbose`). Use the env-var form when you'd otherwise repeat the same flag every run, or when invoking GitNexus from a long-running host (MCP server, eval-server, CI shell) that already manages its own environment. CLI flags take precedence over env vars; env vars take precedence over built-in defaults. +Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max-file-size`, `--verbose`). Use the env-var form when you'd otherwise repeat the same flag every run, or when invoking GitNexus from a long-running host (MCP server, eval-server, CI shell) that already manages its own environment. CLI flags take precedence over `.gitnexusrc`, which takes precedence over env vars, which take precedence over built-in defaults. | Variable | Default | Effect | Tune when… | | ----------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -601,6 +604,10 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max | `GITNEXUS_PROFILE_DEFERRED_SLOW_MS` | `3000` (verbose) / `5000` | Per-file threshold in ms above which `processCallsFromExtracted` emits a `slow file …` log line. Parsed via `Number()`: accepts integers (`5000`), scientific notation (`2.5e3`), decimals (`.5`), and hex (`0x10`). Non-finite or non-positive values fall back to the default. | Hunting a few outlier files dominating the deferred call-resolution stage; lower to surface more, raise to focus only on the worst. | | `PROF_LBUG_LOAD` | unset | When `1`, emits one `[lbug-load prof]` summary line per `loadGraphToLbug` call breaking the graph-DB persistence wall into stages (`csv-emit` / `copy-nodes` / `copy-rels` / `fallback` / `total`) plus node & edge counts. Zero-cost when unset. | Attributing large-repo analyze wall time across CSV generation vs. LadybugDB `COPY` (issue #2203) — the analyze "emit" timing is the scope-resolution bucket, not this DB-write path. | | `GITNEXUS_MAX_FILE_SIZE` | `512` (KB) | Walker skip threshold in KB. Hard cap is `32768` (tree-sitter buffer ceiling). Equivalent to `--max-file-size `. | Indexing repos with intentionally-large source files (generated parsers, vendored bundles) that should still be parsed. | +| `GITNEXUS_MAX_PROCESSES` | dynamic (`max(20, round(symbols/10))`) | Analyze-time process-detection process cap. Equivalent to `--max-processes ` / `.gitnexusrc` `maxProcesses`. Explicit values replace the dynamic formula (not a multiplier). `0` is invalid, not unlimited. Changing this re-detects flows on the next analyze without `--force`. Distinct from query-time `IMPACT_MAX_CHUNKS`. | `[processes] … whole flows are MISSING` names `--max-processes` after entry points were never traced or flows were dropped. Tracing does not start the next entry once collected traces already reach `maxProcesses * 2`; a started entry can still emit every trace that entry produces. | +| `GITNEXUS_MAX_PROCESS_BRANCHING` | `4` | Analyze-time per-node branching cap during flow tracing. Equivalent to `--max-process-branching `. Shape-only: raising it shortens fewer traces; it does not restore whole missing flows. | A flow is present but `calleesDropped` is high at debug. | +| `GITNEXUS_MAX_PROCESS_TRACE_DEPTH` | `10` | Analyze-time DFS depth cap during flow tracing. Equivalent to `--max-process-trace-depth `. Shape-only. | A reported flow is shorter than the code path (`tracesDepthCapped` at debug). | +| `GITNEXUS_MAX_ENTRY_POINT_CANDIDATES` | `200` | Ranked entry-point candidate pool. Equivalent to `--max-entry-point-candidates `. Raising `--max-processes` alone does not clear `entryPointCandidatesDropped`. Doubling the current cap is the usual first raise; setting it to the full remaining candidate count can exhaust CPU and memory. | The `[processes]` warning reports candidate entry points that never ranked in. | | `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS` | `30000` | Worker idle timeout in milliseconds before retry/fallback. Equivalent to `--worker-timeout ` × 1000. | Slow-parsing files (large minified JS, deeply-nested TS types) that legitimately need more than 30s. | | `GITNEXUS_WORKER_READY_TIMEOUT_MS` | `5000` | Startup budget in milliseconds for a parse worker to load its grammar bindings and report `{type:'ready'}`. Slots that miss it are treated as startup crashes. | Slow or heavily loaded hosts where a full pool cold-starting concurrently needs more than 5s, and analyze aborts with "did not report ready within 5000ms". | | `GITNEXUS_FTS_STEMMER` | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` for matching repository comments. Re-run `gitnexus analyze --repair-fts` after changing it. | Keyword search quality is poor for non-English comments or identifiers under English stemming. | @@ -904,9 +911,10 @@ The web UI uses the same indexing pipeline as the CLI but runs entirely in WebAs ```bash git clone https://github.com/abhigyanpatwari/gitnexus.git -cd gitnexus/gitnexus-shared && npm install && npm run build -cd ../gitnexus-web && npm install -npm run dev +cd gitnexus/gitnexus-web && npm install +# Compile sibling gitnexus-shared with this package's TypeScript 7 (do not npm ci shared). +cd ../gitnexus-shared && node ../gitnexus-web/node_modules/typescript/lib/tsc.js +cd ../gitnexus-web && npm run dev # Then in another terminal, start the backend the frontend connects to: npx gitnexus@latest serve ``` diff --git a/TESTING.md b/TESTING.md index 72833d3ca..9febb8283 100644 --- a/TESTING.md +++ b/TESTING.md @@ -39,6 +39,8 @@ From `gitnexus-web/`: ### Before opening a PR ```bash +# gitnexus-shared/dist must exist first. `npm install` / `npm run build` in +# gitnexus/ compiles it via parent `lib/tsc.js` (do not npm ci gitnexus-shared). cd gitnexus && npx tsc --noEmit && npm test cd ../gitnexus-web && npx tsc -b --noEmit && npm test ``` diff --git a/eval/uv.lock b/eval/uv.lock index 45c13a3ef..ff06e6830 100644 --- a/eval/uv.lock +++ b/eval/uv.lock @@ -170,15 +170,15 @@ wheels = [ [[package]] name = "anyio" -version = "4.12.1" +version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] [[package]] @@ -2513,11 +2513,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] @@ -2574,7 +2574,7 @@ name = "pyroscope-io" version = "0.8.16" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "sys_platform != 'win32'" }, + { name = "cffi" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a8/50/607b38b120ba8adad954119ba512c53590c793f0cf7f009ba6549e4e1d77/pyroscope_io-0.8.16-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:e07edcfd59f5bdce42948b92c9b118c824edbd551730305f095a6b9af401a9e8", size = 3138869, upload-time = "2026-01-22T06:23:24.664Z" }, diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index 7958cfa9d..de49223bf 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -98,6 +98,7 @@ export type { ResolveTypeRefContext } from './scope-resolution/resolve-type-ref. // ScopeExtractor output contracts (RFC §3.2 Phase 1; Ring 2 PKG #919) export type { ParsedFile } from './scope-resolution/parsed-file.js'; +export type { CallResultAssignmentSite } from './scope-resolution/call-result-assignment-site.js'; export type { ReferenceSite, ReferenceKind, diff --git a/gitnexus-shared/src/scope-resolution/call-result-assignment-site.ts b/gitnexus-shared/src/scope-resolution/call-result-assignment-site.ts new file mode 100644 index 000000000..1b9347101 --- /dev/null +++ b/gitnexus-shared/src/scope-resolution/call-result-assignment-site.ts @@ -0,0 +1,14 @@ +import type { Range, ScopeId } from './types.js'; + +/** + * Compact extraction-time identity for `lhs = callee()`. + * + * The call-site range uses the same call-expression anchor as reference + * resolution, allowing downstream passes to join this fact to the exact + * resolved callee id without relying on a possibly polluted type binding. + */ +export interface CallResultAssignmentSite { + readonly callSite: Range; + readonly inScope: ScopeId; + readonly lhs: string; +} diff --git a/gitnexus-shared/src/scope-resolution/parsed-file.ts b/gitnexus-shared/src/scope-resolution/parsed-file.ts index db0833967..140193afc 100644 --- a/gitnexus-shared/src/scope-resolution/parsed-file.ts +++ b/gitnexus-shared/src/scope-resolution/parsed-file.ts @@ -56,6 +56,7 @@ import type { ParsedImport } from './types.js'; import type { SymbolDefinition } from './symbol-definition.js'; import type { ReferenceSite } from './reference-site.js'; import type { CallableFlowSite } from './callable-flow-site.js'; +import type { CallResultAssignmentSite } from './call-result-assignment-site.js'; export interface ParsedFile { readonly filePath: string; @@ -81,6 +82,8 @@ export interface ParsedFile { * syntax remain source-compatible; consumers normalize absence to `[]`. */ readonly callableFlowSites?: readonly CallableFlowSite[]; + /** Exact call-expression → assigned local identity for return-type replay. */ + readonly callResultAssignmentSites?: readonly CallResultAssignmentSite[]; /** * Opaque, language-private serialization of capture-time side-channel * state that a provider's `emitScopeCaptures` populates into module-level diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index 2b0deefda..8795c6141 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -10,10 +10,10 @@ "dependencies": { "@langchain/anthropic": "^1.5.8", "@langchain/core": "^1.2.8", - "@langchain/google-genai": "^2.2.0", + "@langchain/google-genai": "^2.3.1", "@langchain/langgraph": "^1.4.14", "@langchain/ollama": "^1.3.0", - "@langchain/openai": "^1.5.3", + "@langchain/openai": "^1.5.13", "@sigma/edge-curve": "^3.1.0", "@tailwindcss/vite": "^4.3.3", "axios": "^1.20.0", @@ -30,12 +30,12 @@ "i18next-browser-languagedetector": "^8.2.1", "langchain": "^1.5.11", "lru-cache": "^11.5.2", - "lucide-react": "^1.31.0", + "lucide-react": "^1.44.0", "mermaid": "^11.17.2", "mnemonist": "^0.40.4", "pandemonium": "^2.4.0", - "react": "^19.2.5", - "react-dom": "^19.2.8", + "react": "^19.3.0", + "react-dom": "^19.3.0", "react-i18next": "^17.0.13", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", @@ -47,23 +47,23 @@ "zod": "^4.5.4" }, "devDependencies": { - "@babel/types": "^8.0.4", - "@playwright/test": "^1.62.1", - "@testing-library/jest-dom": "^7.0.0", + "@babel/types": "^8.0.5", + "@playwright/test": "^1.63.0", + "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.7", "@types/dompurify": "^3.2.0", - "@types/node": "^26.0.1", - "@types/react": "^19.2.18", - "@types/react-dom": "^19.2.4", + "@types/node": "^26.5.1", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", "@types/react-syntax-highlighter": "^15.5.13", "@vercel/node": "^5.10.2", "@vitejs/plugin-react": "^6.1.1", "@vitest/coverage-v8": "^4.1.11", "jsdom": "^30.0.1", "tree-sitter-wasms": "^0.1.13", - "typescript": "^5.4.5", - "vite": "^8.1.5", + "typescript": "^7.0.2", + "vite": "^8.3.0", "vitest": "^4.1.10", "wait-on": "^9.1.0" }, @@ -237,9 +237,9 @@ } }, "node_modules/@babel/types": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", - "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.5.tgz", + "integrity": "sha512-eVdMqi3ej5aHhyQ2Si6yD2cAWeV8FJK9UrhK5aL0Sd8hu5GhT+YswhVNbVheOGVYMg8kuGuMaUpkB3stjj4z8A==", "dev": true, "license": "MIT", "dependencies": { @@ -494,37 +494,6 @@ "node": ">=16" } }, - "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==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "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==", - "license": "MIT", - "optional": true, - "dependencies": { - "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==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", @@ -1114,9 +1083,9 @@ } }, "node_modules/@langchain/core": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.10.tgz", - "integrity": "sha512-skr9zkyidZSzhoqhWINdULRstPT5aMD+w3tWtE3puxffWH7wxAp8tx1gMqadjKZwlgo6exlK0nHncFJv6m+3Cg==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.2.11.tgz", + "integrity": "sha512-8yuWLLloTSYA453akm2JSadOVa8kGDY8v+kTzQ6kTY6aIETTEIxgysjZWyKrWQLo3UazctsSoGJ8JrdCGFL4/w==", "license": "MIT", "dependencies": { "@cfworker/json-schema": "^4.0.2", @@ -1132,9 +1101,9 @@ } }, "node_modules/@langchain/google-genai": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@langchain/google-genai/-/google-genai-2.2.0.tgz", - "integrity": "sha512-1mDqbmB6+iC6ZBQY15r5xJg9wPErnQ774inpKh6qi6BrrjadDwaPHoklJW5IXU94edKiDpm1akIzJCrQDWe6yA==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@langchain/google-genai/-/google-genai-2.3.1.tgz", + "integrity": "sha512-xoJVzzPA2zgGgHCy4pqTAlXSBKncutXcfzjtYdl9g8biRKjjvczpdpVs2gB0OxsYVX15fgaIJaCmo64+72cj2w==", "license": "MIT", "dependencies": { "@google/generative-ai": "^0.24.1" @@ -1143,7 +1112,7 @@ "node": ">=20" }, "peerDependencies": { - "@langchain/core": "^1.2.0" + "@langchain/core": "^1.2.10" } }, "node_modules/@langchain/langgraph": { @@ -1252,20 +1221,20 @@ } }, "node_modules/@langchain/openai": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.5.3.tgz", - "integrity": "sha512-OStS2AUvy9oe/hEf/3ndBOFztUDOfuJYLNXh89m3iiJAI2Cp5Dp0n/pvpO27MO0b+VgENd+xSHVyQZ7fe+ulxg==", + "version": "1.5.13", + "resolved": "https://registry.npmjs.org/@langchain/openai/-/openai-1.5.13.tgz", + "integrity": "sha512-SA1QWNgM/p8xhdwU82CJ7OofhHEExpAaIwJ7j9ELIyVW795Mn0jDRQO/5yChawLcPhuLkeL+jMesi0FnXbImSg==", "license": "MIT", "dependencies": { "js-tiktoken": "^1.0.12", - "openai": "^6.41.0", + "openai": "^7.10.0", "zod": "^3.25.76 || ^4" }, "engines": { - "node": ">=20" + "node": ">=22" }, "peerDependencies": { - "@langchain/core": "^1.2.1" + "@langchain/core": "^1.2.11" } }, "node_modules/@langchain/protocol": { @@ -1305,24 +1274,6 @@ "@chevrotain/types": "~11.1.2" } }, - "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==", - "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/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1362,22 +1313,22 @@ } }, "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.149.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", + "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", "license": "MIT", "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" } }, "node_modules/@playwright/test": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", - "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.62.1" + "playwright": "1.63.0" }, "bin": { "playwright": "cli.js" @@ -1386,10 +1337,26 @@ "node": ">=20" } }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", + "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "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.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", + "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", "cpu": [ "arm64" ], @@ -1403,9 +1370,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.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", + "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", "cpu": [ "arm64" ], @@ -1419,9 +1386,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.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", + "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", "cpu": [ "x64" ], @@ -1435,9 +1402,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.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", + "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", "cpu": [ "x64" ], @@ -1451,9 +1418,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.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", + "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", "cpu": [ "arm" ], @@ -1467,9 +1434,9 @@ } }, "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.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", + "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", "cpu": [ "arm64" ], @@ -1483,9 +1450,9 @@ } }, "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.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", + "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", "cpu": [ "arm64" ], @@ -1499,9 +1466,9 @@ } }, "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.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", + "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", "cpu": [ "ppc64" ], @@ -1515,9 +1482,9 @@ } }, "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.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", + "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", "cpu": [ "s390x" ], @@ -1531,9 +1498,9 @@ } }, "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.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", + "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", "cpu": [ "x64" ], @@ -1547,9 +1514,9 @@ } }, "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.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", + "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", "cpu": [ "x64" ], @@ -1563,9 +1530,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.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", + "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", "cpu": [ "arm64" ], @@ -1578,28 +1545,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" - ], - "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-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.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", + "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", "cpu": [ "arm64" ], @@ -1613,9 +1562,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.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", + "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", "cpu": [ "x64" ], @@ -2017,9 +1966,9 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz", - "integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", "dev": true, "license": "MIT", "dependencies": { @@ -2036,7 +1985,13 @@ "yarn": ">=1" }, "peerDependencies": { - "@testing-library/dom": ">=10 <11" + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } } }, "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { @@ -2132,16 +2087,6 @@ "node": "*" } }, - "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==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -2493,13 +2438,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz", - "integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==", + "version": "26.5.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.1.tgz", + "integrity": "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==", "devOptional": true, "license": "MIT", "dependencies": { - "undici-types": "~8.3.0" + "undici-types": "~8.9.0" } }, "node_modules/@types/prismjs": { @@ -2509,22 +2454,22 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.18", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", - "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", "license": "MIT", "dependencies": { "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", "dev": true, "license": "MIT", "peerDependencies": { - "@types/react": "^19.2.0" + "@types/react": "^19.3.0" } }, "node_modules/@types/react-syntax-highlighter": { @@ -2550,6 +2495,346 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -2659,6 +2944,20 @@ "undici-types": "~5.26.4" } }, + "node_modules/@vercel/node/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/@vercel/node/node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", @@ -5582,9 +5881,9 @@ } }, "node_modules/lucide-react": { - "version": "1.31.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.31.0.tgz", - "integrity": "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg==", + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.44.0.tgz", + "integrity": "sha512-2egNApH4hX4j/qdCgRublh88+9u3mEhz9iSlW5ckm4kaQEqZbXbMr0l5u5JZLy8nmWRx2dbHGQkEDYz6C9aCgw==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -6752,9 +7051,9 @@ } }, "node_modules/nanoid": { - "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==", + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "funding": [ { "type": "github", @@ -6845,15 +7144,19 @@ } }, "node_modules/openai": { - "version": "6.45.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.45.0.tgz", - "integrity": "sha512-5DQVNErssk0afNpTTHUm/qZPU4iKR9OYdNid8Ib4puq4gHNNvGWZht2zY4h9a8JMF949Ik6m8gQutllVPbjdnw==", + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-7.15.0.tgz", + "integrity": "sha512-2DIwesnPSduw68MFx7nAdgvshrgvS8WOan5W8+tgUMlAy/tY+Q41g80Pa+w/v64rHA6rtn+dketa8QvMVxU96w==", "license": "Apache-2.0", + "engines": { + "node": ">=22.0.0" + }, "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", - "ws": "^8.18.0", + "undici": ">=5 <9", + "ws": "^8.21.0", "zod": "^3.25 || ^4.0" }, "peerDependenciesMeta": { @@ -6866,6 +7169,9 @@ "@smithy/signature-v4": { "optional": true }, + "undici": { + "optional": true + }, "ws": { "optional": true }, @@ -7056,9 +7362,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "license": "MIT", "engines": { "node": ">=12" @@ -7079,28 +7385,25 @@ } }, "node_modules/playwright": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", - "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.62.1" + "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" }, "engines": { "node": ">=20" - }, - "optionalDependencies": { - "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", - "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -7110,21 +7413,6 @@ "node": ">=20" } }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/points-on-curve": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", @@ -7142,9 +7430,9 @@ } }, "node_modules/postcss": { - "version": "8.5.25", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", - "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "funding": [ { "type": "opencollective", @@ -7161,7 +7449,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -7261,24 +7549,24 @@ "license": "MIT" }, "node_modules/react": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", - "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", - "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", "license": "MIT", "dependencies": { - "scheduler": "^0.27.0" + "scheduler": "^0.28.0" }, "peerDependencies": { - "react": "^19.2.8" + "react": "^19.3.0" } }, "node_modules/react-i18next": { @@ -7521,12 +7809,12 @@ "license": "Unlicense" }, "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.8", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", + "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.139.0", + "@oxc-project/types": "=0.149.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -7536,21 +7824,21 @@ "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-arm-eabi": "1.2.8", + "@rolldown/binding-android-arm64": "1.2.8", + "@rolldown/binding-darwin-arm64": "1.2.8", + "@rolldown/binding-darwin-x64": "1.2.8", + "@rolldown/binding-freebsd-x64": "1.2.8", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", + "@rolldown/binding-linux-arm64-gnu": "1.2.8", + "@rolldown/binding-linux-arm64-musl": "1.2.8", + "@rolldown/binding-linux-ppc64-gnu": "1.2.8", + "@rolldown/binding-linux-s390x-gnu": "1.2.8", + "@rolldown/binding-linux-x64-gnu": "1.2.8", + "@rolldown/binding-linux-x64-musl": "1.2.8", + "@rolldown/binding-openharmony-arm64": "1.2.8", + "@rolldown/binding-win32-arm64-msvc": "1.2.8", + "@rolldown/binding-win32-x64-msvc": "1.2.8" } }, "node_modules/roughjs": { @@ -7625,9 +7913,9 @@ } }, "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", "license": "MIT" }, "node_modules/semver": { @@ -8007,7 +8295,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "devOptional": true, + "dev": true, "license": "0BSD" }, "node_modules/tsx": { @@ -8031,17 +8319,38 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "devOptional": true, "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "tsc": "bin/tsc" }, "engines": { - "node": ">=14.17" + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } }, "node_modules/ufo": { @@ -8054,16 +8363,16 @@ "version": "6.28.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18.17" } }, "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", "devOptional": true, "license": "MIT" }, @@ -8205,15 +8514,15 @@ } }, "node_modules/vite": { - "version": "8.1.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", - "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.17", - "rolldown": "~1.1.5", + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "bin": { @@ -8230,7 +8539,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", + "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -8281,6 +8590,255 @@ } } }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "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/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "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" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/vitest": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index 841f8b875..50b58ab71 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -20,10 +20,10 @@ "dependencies": { "@langchain/anthropic": "^1.5.8", "@langchain/core": "^1.2.8", - "@langchain/google-genai": "^2.2.0", + "@langchain/google-genai": "^2.3.1", "@langchain/langgraph": "^1.4.14", "@langchain/ollama": "^1.3.0", - "@langchain/openai": "^1.5.3", + "@langchain/openai": "^1.5.13", "@sigma/edge-curve": "^3.1.0", "@tailwindcss/vite": "^4.3.3", "axios": "^1.20.0", @@ -40,12 +40,12 @@ "i18next-browser-languagedetector": "^8.2.1", "langchain": "^1.5.11", "lru-cache": "^11.5.2", - "lucide-react": "^1.31.0", + "lucide-react": "^1.44.0", "mermaid": "^11.17.2", "mnemonist": "^0.40.4", "pandemonium": "^2.4.0", - "react": "^19.2.5", - "react-dom": "^19.2.8", + "react": "^19.3.0", + "react-dom": "^19.3.0", "react-i18next": "^17.0.13", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", @@ -57,23 +57,23 @@ "zod": "^4.5.4" }, "devDependencies": { - "@babel/types": "^8.0.4", - "@playwright/test": "^1.62.1", - "@testing-library/jest-dom": "^7.0.0", + "@babel/types": "^8.0.5", + "@playwright/test": "^1.63.0", + "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.7", "@types/dompurify": "^3.2.0", - "@types/node": "^26.0.1", - "@types/react": "^19.2.18", - "@types/react-dom": "^19.2.4", + "@types/node": "^26.5.1", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", "@types/react-syntax-highlighter": "^15.5.13", "@vercel/node": "^5.10.2", "@vitejs/plugin-react": "^6.1.1", "@vitest/coverage-v8": "^4.1.11", "jsdom": "^30.0.1", "tree-sitter-wasms": "^0.1.13", - "typescript": "^5.4.5", - "vite": "^8.1.5", + "typescript": "^7.0.2", + "vite": "^8.3.0", "vitest": "^4.1.10", "wait-on": "^9.1.0" }, diff --git a/gitnexus-web/test/unit/tsconfig-react.test.ts b/gitnexus-web/test/unit/tsconfig-react.test.ts new file mode 100644 index 000000000..d1d805ad8 --- /dev/null +++ b/gitnexus-web/test/unit/tsconfig-react.test.ts @@ -0,0 +1,16 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +describe('TypeScript 7 + React frontend toolchain', () => { + it('keeps Vite and Vitest on the automatic JSX runtime that matches react-jsx', () => { + for (const configFile of ['vite.config.ts', 'vitest.config.ts'] as const) { + const source = readFileSync(path.join(webRoot, configFile), 'utf8'); + expect(source).toContain('@vitejs/plugin-react'); + expect(source).toContain("jsxRuntime: 'automatic'"); + } + }); +}); diff --git a/gitnexus-web/tsconfig.app.json b/gitnexus-web/tsconfig.app.json index 2133377fd..34864f2a0 100644 --- a/gitnexus-web/tsconfig.app.json +++ b/gitnexus-web/tsconfig.app.json @@ -1,26 +1,29 @@ { "compilerOptions": { "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], "useDefineForClassFields": true, "module": "ESNext", "moduleResolution": "Bundler", + "moduleDetection": "force", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", + "jsxImportSource": "react", "strict": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "forceConsistentCasingInFileNames": true, "skipLibCheck": true, - "baseUrl": "./", + "noUncheckedSideEffectImports": true, + "rootDir": "./src", "paths": { - "@/*": ["./src/*"], - "@shared/*": ["../shared/*"] + "@/*": ["./src/*"] }, "types": ["vite/client"] }, - "include": ["src", "../shared"], + "include": ["src"], "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] } diff --git a/gitnexus-web/tsconfig.node.json b/gitnexus-web/tsconfig.node.json index 8b6c9f152..cc7fd0674 100644 --- a/gitnexus-web/tsconfig.node.json +++ b/gitnexus-web/tsconfig.node.json @@ -2,11 +2,15 @@ "compilerOptions": { "composite": true, "skipLibCheck": true, + "target": "ESNext", + "lib": ["ESNext"], "module": "ESNext", "moduleResolution": "Bundler", + "moduleDetection": "force", "allowSyntheticDefaultImports": true, "esModuleInterop": true, "noEmit": true, + "rootDir": ".", "types": ["node"] }, "include": ["vite.config.ts"] diff --git a/gitnexus-web/vercel.json b/gitnexus-web/vercel.json index 284b15735..3cd408211 100644 --- a/gitnexus-web/vercel.json +++ b/gitnexus-web/vercel.json @@ -1,3 +1,3 @@ { - "installCommand": "cd ../gitnexus-shared && npm install && npm run build && cd ../gitnexus-web && npm ci --include=dev" + "installCommand": "npm ci --include=dev && cd ../gitnexus-shared && node ../gitnexus-web/node_modules/typescript/lib/tsc.js" } diff --git a/gitnexus-web/vite.config.ts b/gitnexus-web/vite.config.ts index a62b9e586..cdd411b16 100644 --- a/gitnexus-web/vite.config.ts +++ b/gitnexus-web/vite.config.ts @@ -8,7 +8,7 @@ const _require = createRequire(import.meta.url); const gitnexusPkg = _require('../gitnexus/package.json'); export default defineConfig({ - plugins: [react(), tailwindcss()], + plugins: [react({ jsxRuntime: 'automatic' }), tailwindcss()], define: { __REQUIRED_NODE_VERSION__: JSON.stringify(gitnexusPkg.engines.node.replace(/[>=^~\s]/g, '')), }, diff --git a/gitnexus-web/vitest.config.ts b/gitnexus-web/vitest.config.ts index 1de8ef065..2b7196b2e 100644 --- a/gitnexus-web/vitest.config.ts +++ b/gitnexus-web/vitest.config.ts @@ -7,7 +7,7 @@ const _require = createRequire(import.meta.url); const gitnexusPkg = _require('../gitnexus/package.json'); export default defineConfig({ - plugins: [react()], + plugins: [react({ jsxRuntime: 'automatic' })], define: { __REQUIRED_NODE_VERSION__: JSON.stringify(gitnexusPkg.engines.node.replace(/[>=^~\s]/g, '')), }, diff --git a/gitnexus/README.md b/gitnexus/README.md index b0ea3e5d5..393bef867 100644 --- a/gitnexus/README.md +++ b/gitnexus/README.md @@ -245,6 +245,8 @@ gitnexus analyze --skip-agents-md # Preserve custom AGENTS.md/CLAUDE.md gitnexu gitnexus analyze --skip-skills # Skip installing standard .claude/skills/gitnexus-* skill files gitnexus analyze --skip-git # Index folders that are not Git repositories gitnexus analyze --workers # Parse worker pool size (>=1; default: cores-1, capped at 16) +gitnexus analyze --max-processes # Process-detection process cap (replaces dynamic max(20, round(symbols/10))) +gitnexus analyze --max-entry-point-candidates # Ranked entry-point pool (default 200; raise when the warning names it) gitnexus analyze --spring-actuator ./actuator # Enrich with local Spring Boot Actuator JSON snapshots gitnexus analyze --verbose # Log skipped files when parsers are unavailable gitnexus analyze --max-file-size 1024 # Skip files larger than N KB (default: 512, cap: 32768) @@ -298,7 +300,8 @@ installation. Run a one-shot `gitnexus analyze` when those generated files need updating. Stop watch mode with Ctrl+C. Watch mode accepts `--debounce`, `--workers`, `--worker-timeout`, -`--max-file-size`, `--branch`, `--pdg`, `--name`, `--allow-duplicate-name`, and +`--max-file-size`, `--max-processes`, `--max-process-branching`, +`--max-process-trace-depth`, `--max-entry-point-candidates`, `--branch`, `--pdg`, `--name`, `--allow-duplicate-name`, and `--verbose`. Explicit one-shot options such as `--force`, `--repair-fts`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git` @@ -764,6 +767,22 @@ npx gitnexus analyze Values above **32768 KB (32 MB)** are clamped to the tree-sitter parser ceiling; invalid values fall back to the 512 KB default with a one-time warning. When an override is active, `analyze` prints the effective threshold in its startup banner (e.g. `GITNEXUS_MAX_FILE_SIZE: effective threshold 2048KB (default 512KB)`). +### Process detection reports missing flows + +On a large repository, `analyze` may warn that `[processes] … whole flows are MISSING`. That means ranked entry points or completed flows were sampled away by the analyze-time detection budget — not that the code path is absent, and not the query-time `IMPACT_MAX_CHUNKS` cap. + +Defaults stay in place when nothing is set: dynamic `maxProcesses = max(20, round(non-File symbols / 10))`, branching `4`, trace depth `10`, entry-point candidate pool `200`. Raise a knob only when the warning names it: + +```bash +# Usual first move when entryPointCandidatesDropped is the loud counter +npx gitnexus analyze --max-entry-point-candidates 400 + +# When ranked entry points were never traced, or flows were dropped at maxProcesses +npx gitnexus analyze --max-processes 80 +``` + +Equivalent `.gitnexusrc` keys: `maxProcesses`, `maxProcessBranching`, `maxProcessTraceDepth`, `maxEntryPointCandidates`. Equivalent env vars: `GITNEXUS_MAX_PROCESSES`, `GITNEXUS_MAX_PROCESS_BRANCHING`, `GITNEXUS_MAX_PROCESS_TRACE_DEPTH`, `GITNEXUS_MAX_ENTRY_POINT_CANDIDATES`. Precedence is CLI > `.gitnexusrc` > env > default. `0` is invalid, not unlimited. Changing these knobs re-runs process detection on the next `analyze` without `--force`. Raising them increases CPU and memory; this is not a heap-OOM fix. + ### Analyze reports a worker timeout Worker parse timeouts are recoverable. GitNexus retries stalled worker jobs with backoff, splits large jobs to isolate slow files, and quarantines a file that repeatedly crashes its worker (respawning the slot so the pool keeps going). If a large repository needs more time per worker job, use either: diff --git a/gitnexus/bench/import-target/baselines.json b/gitnexus/bench/import-target/baselines.json index 37fa78535..7b9a9932d 100644 --- a/gitnexus/bench/import-target/baselines.json +++ b/gitnexus/bench/import-target/baselines.json @@ -659,6 +659,11 @@ "path_segments": 13, "probe": "ExternalPkg0" }, + "context": { + "target": "App", + "with_context": "Sources/App/Lib.swift,Sources/Models/User.swift", + "without_context": "Sources/App/Lib.swift" + }, "_measured": { "collide_ms": 0.819, "collide_scaling_ratio": 3.454, diff --git a/gitnexus/bench/import-target/measure.mjs b/gitnexus/bench/import-target/measure.mjs index 35c8361a1..b66932d77 100644 --- a/gitnexus/bench/import-target/measure.mjs +++ b/gitnexus/bench/import-target/measure.mjs @@ -627,7 +627,7 @@ const HEAP_BUDGETED = [ * their hooks declare three or four parameters — so their numbers stay exactly * where they were. */ -const CONTEXT_LANGS = ['php', 'java', 'kotlin', 'python']; +const CONTEXT_LANGS = ['php', 'java', 'kotlin', 'python', 'swift']; /** * Needs `node --expose-gc` to force collection for a clean delta; without it @@ -1869,7 +1869,7 @@ function resolveOne(lang, from, target, pass) { if (lang === 'swift') { return resolveSwiftImportTarget( { kind: 'namespace', localName: 'X', importedName: 'X', targetRaw: target }, - { fromFile: from, allFilePaths }, + { fromFile: from, allFilePaths, parsedFiles: pass.parsedFiles }, ); } if (lang === 'rust') return resolveRustImportTarget(target, from, allFilePaths, undefined); @@ -2326,6 +2326,26 @@ const CONTEXT_PROBE = { probeFile('app/main.py', [['Function', 'app.main.run']]), ], }, + /** + * `import App` where App `@_exported import`s Models. With parsedFiles the + * re-export closure unions Models' files; without it the adapter returns + * only App's own file. Two distinct non-null answers, so a dropped + * `parsedFiles` cannot look like a miss. + */ + swift: { + from: 'Sources/Client/Main.swift', + target: 'App', + parsedFiles: [ + { + ...probeFile('Sources/App/Lib.swift', [['Class', 'App.Lib']]), + parsedImports: [ + { kind: 'reexport', localName: 'Models', importedName: 'Models', targetRaw: 'Models' }, + ], + }, + probeFile('Sources/Models/User.swift', [['Class', 'Models.User']]), + probeFile('Sources/Client/Main.swift', [['Class', 'Client.Main']]), + ], + }, }; /** Resolve the probe twice through `resolveOne` — once with the pass's parsed @@ -3065,7 +3085,7 @@ expectNoOrphanKeys( // against a claim in a comment. `run.ts` passes the fifth argument to every // provider; which ones can OBSERVE it is decided by how many parameters each // hook declares, and that is a number the registry can be asked for. Today -// exactly four answer 5 (php, java, kotlin, python) and the other thirteen answer 3 or 4 — +// exactly five answer 5 (php, java, kotlin, python, swift) and the other twelve answer 3 or 4 — // which is why thirteen arms can ignore this whole question and their numbers // did not move when it was fixed. // diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 94fc262f6..c715f59b7 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -117,7 +117,7 @@ "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83 -> 1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57." }, "swift": { - "fingerprint": "adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9", + "fingerprint": "decf74c01af0c7f403e203b19c2bd92dbf95b4562f6da2b0ac5117ef872204da", "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 5f923c6604d825d12b249f31c155b0f4d13a8379d532e5dde64a0f9b15cf4725 -> 7687ee2466e16020a12440a03fbda53e63aa05f94b4481f6133c09867a0d560d; scaling 1.042 < 1.5.", "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Swift function-value callable flow facts with invocation-result suppression. Prior 180ac68e780bdf6f9089d53f51cbb9a66aed3e7774631cc3fcbaae5020213998 -> 5f923c6604d825d12b249f31c155b0f4d13a8379d532e5dde64a0f9b15cf4725; scaling 1.043 < 1.5.", @@ -125,7 +125,13 @@ "_rebaselined_2522_review_fixes": "PR #2522 review fixes: assignment target:/result: fields join the shared fallback. Prior 7687ee2466e16020a12440a03fbda53e63aa05f94b4481f6133c09867a0d560d -> 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248; scaling ratio re-verified within budget.", "_rebaselined_receiver_chain_2747": "#2747 receiver-chain rollout: call matches whose receiver is itself an expression now carry `@reference.receiver-chain`, a compact encoding of the receiver's structure, so resolution types it by folding instead of re-parsing receiver source text. Capture GROUP counts are unchanged \u2014 the tag is added to existing call matches, never a new match \u2014 so this is digest drift only. Prior 115c5da807e36bb12fdeba28e44f2b6484ef322ff26c19fa0f191febaf774248 -> a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b.", "_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior a6fca5f052ae5ec635b56051e28a168c864a988b2221a3279ddd69807378ba0b -> 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7.", - "_rebaselined_inferred_field_receiver_2807": "#2807: optional property annotations (`var a: Outer?`) now emit a type binding. The prior pattern required the `user_type` to be a DIRECT child of the annotation, so an `optional_type` wrapper meant an optional field was never typed at all and its receiver could not resolve. ADDS @type-binding.annotation captures on the optional form only; no capture is removed. Prior 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7 -> adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9; scaling 1.023 < 1.5." + "_rebaselined_inferred_field_receiver_2807": "#2807: optional property annotations (`var a: Outer?`) now emit a type binding. The prior pattern required the `user_type` to be a DIRECT child of the annotation, so an `optional_type` wrapper meant an optional field was never typed at all and its receiver could not resolve. ADDS @type-binding.annotation captures on the optional form only; no capture is removed. Prior 2f04ae960123cf50138a49fabdc5a146c2963170cecf5755c552b23c9055a9e7 -> adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9; scaling 1.023 < 1.5.", + "_rebaselined_nested_constructor_3262": "#3262: the existing nested-constructor fixture now uses the qualified legal form `extension Outer.Container`, covering full extension-owner recovery. Fixture-only capture drift; no capture logic changed. Prior adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9 -> 3ac80b64776969effb75c8d32aa0bc38fc6a41c6c115a33829c502559fa404f9; scaling 1.025 < 1.5.", + "_rebaselined_3309_call_result_assignment": "#3309: the Swift protocol-extension regression fixture adds one file with an untyped local initialized from a helper call, plus a compound-field receiver call. The capture query is unchanged; this is fixture-corpus growth only. capture_groups_fp 1235 -> 1272 and fixture_count 62 -> 64; synthetic scale counts remain 5012/16012. Prior adef9284feaecd39cb490aebce83876e15b9150c7a04b00a396feb78b7e1e0a9 -> 4d32535d454cd79086f0fcaec3b75de855c1314e73630321460e569f19aa484f; measured scaling 1.020 < 1.5.", + "_rebaselined_3309_exact_return_replay": "#3309 follow-up: Swift callable declarations now emit exact return-type captures, and untyped local initializers emit call-result assignment captures through direct, try, await, and try-await forms. This is intentional capture-set growth covered by exact-callable collision and wrapper regressions. Prior 4d32535d454cd79086f0fcaec3b75de855c1314e73630321460e569f19aa484f -> 724553c91e8ebcf872d7302b14105d59476c230f9b623acdacc0b58e39912329; hosted benchmark scaling remained within the existing 1.5 budget.", + "_rebaselined_3308_merge_main": "Merge origin/main into #3308: combine #3262 fixture-owner recovery with #3309 protocol-extension capture growth. Prior 3ac80b64776969effb75c8d32aa0bc38fc6a41c6c115a33829c502559fa404f9 + 724553c91e8ebcf872d7302b14105d59476c230f9b623acdacc0b58e39912329 -> b0c89f8de1a4ce30409182e79c1c14cbc49a509d18d853c0d7c5f22f1f66be06; capture_groups_fp 1297, fixture_count 67.", + "_rebaselined_3308_public_extension": "#3308 review: the nested-constructor fixture now uses `public extension Outer.Container` so owner recovery is gated on modifier-prefixed headers. Fixture-only digest drift. Prior b0c89f8de1a4ce30409182e79c1c14cbc49a509d18d853c0d7c5f22f1f66be06 -> a6d61e3749c9c02b9a54611e42d03db02f8fabfded3d7ce53a1b7229d3d38d59; capture_groups_fp 1297, fixture_count 67.", + "_rebaselined_3308_public_enclosing_types": "#3308 review: `Outer` / `Container` / `Entry` in the nested-constructor fixture are now `public` so the `public extension` is valid Swift. Fixture-only digest drift (Types.swift capture text). Prior a6d61e3749c9c02b9a54611e42d03db02f8fabfded3d7ce53a1b7229d3d38d59 -> decf74c01af0c7f403e203b19c2bd92dbf95b4562f6da2b0ac5117ef872204da; capture_groups_fp 1297." }, "dart": { "fingerprint": "3a8ddabbeb1cba47a4757451d4f79d726ca230fd15e860772b11526fbb1c6687", diff --git a/gitnexus/bench/swift-package-imports/baselines.json b/gitnexus/bench/swift-package-imports/baselines.json new file mode 100644 index 000000000..2163b9f72 --- /dev/null +++ b/gitnexus/bench/swift-package-imports/baselines.json @@ -0,0 +1,52 @@ +{ + "_what": "Baselines for bench/swift-package-imports/measure.mjs --check. Guards Swift Package.swift declaration resolve after #2964 / #2931: declared-target hits, SDK and undeclared-folder misses, empty declaredTargets fail-closed, @_exported union, #2931 nested-prefix membership, first-wins grouping, and https:// factory survival. Same approach as bench/parse-dispatch-rounds/baselines.json — exact floors and a fingerprint first; the only timing arms are ratios.", + + "_triage": "READ THIS BEFORE RE-RUNNING. targets, files, imports, declared_resolved, sdk_external, undeclared_external, empty_declared_external, reexport_extra, nested_repeat_resolved, first_wins, parse_targets, parse_binary_skipped, url_comment_targets, parse_complete and layout_fingerprint are DETERMINISTIC: a re-run never changes them, and none may be re-baselined to make CI green. resolve_scaling_ratio, strategy_scaling_ratio and parse_scaling_ratio are the only timing arms; runner contention dominates them, so re-run on an idle machine before investigating and read the reported `reps` first. If exactly one arm fails and it is a timing arm, suspect the machine.", + + "targets": 16, + "files": 260, + "imports": 12288, + "_shape_note": "THE FLOOR. Without these three, every arm below is a ceiling over nothing. declared_resolved only asserts something while the corpus still walks many files and imports. Shrink it to one happy-path module and declared_resolved still reads 32 and still passes, asserting a property the corpus no longer has. Files-per-target is 16 and does not scale — 4x grows target count so a hit returns a constant-size file list. Scaling files-per-target instead makes the return-list copy look quadratic even when the index is linear (the same trap bench/import-target documents for Swift collide_scaling).", + + "declared_resolved": 32, + "sdk_external": 32, + "undeclared_external": 16, + "empty_declared_external": 1, + "reexport_extra": 1, + "nested_repeat_resolved": 1, + "first_wins": 1, + "_membership_note": "Exact resolve/grouping counts on the unique query set (one File0 per target). declared_resolved=32 is two declared spellings (Mod{t+1} and Mod{t+1}.Model) across 16 targets. sdk_external=32 is Foundation+UIKit. undeclared_external=16 is the in-repo CoreUI folder that Package.swift does not declare — the hole bench/import-target's Swift arm cannot see. empty_declared_external=1 pins an empty declaredTargets map. reexport_extra=1 pins import Mod0 including Mod1 after @_exported. nested_repeat_resolved=1 pins vendor/Sources/Mod0/Sources/Mod0/Nested.swift (#2931). first_wins=1 pins a two-prefix file into Mod0 only.", + + "parse_targets": 3, + "parse_binary_skipped": 1, + "url_comment_targets": 1, + "parse_complete": 1, + "_parse_note": "Exact parse floors on a fixed manifest, not the scaling corpus. parse_targets=3 is Models+App+AppTests. parse_binary_skipped=1 means Lib/Gen/CFoo never entered the map. url_comment_targets=1 pins https:// on the same line as a later .target. parse_complete=1 pins both manifests readable.", + + "layout_fingerprint": "2b220baa1df63778489949f9596879ba1c89a49e62c57bd9d2336fd8d74c17bd", + "_layout_fingerprint_note": "sha256 over sorted from|target->files rows on the unique query set. A change here is a BEHAVIOUR change — the resolver returned a different target set. Explain it, never re-baseline it alone.", + + "resolve_scaling_budget": 1.9, + "strategy_scaling_budget": 1.6, + "parse_scaling_budget": 1.9, + "_resolve_scaling_note": "(t_4n / t_n) / 4 for resolveSwiftImportTarget over the declared corpus; ~1.0 is linear. A RATIO rather than a millisecond ceiling, deliberately: wall-clock is runner-speed-dependent, and this repo has already been bitten by a fixed ms budget. A per-import allFilePaths scan scores ~4. Budget is 1.9, i.e. 1.51x the measured maximum 1.261 — this file's siblings use ~1.5x on ratios. min-of-15 estimator.", + "_strategy_scaling_note": "(t_4n / t_n) / 4 for swiftPackageStrategy (the import-config WeakMap index). Measured maximum 1.030; budget 1.6 is 1.55x, matching parse-dispatch-rounds' pack_scaling_budget.", + "_parse_scaling_note": "(t_4n / t_n) / 4 for parseSwiftPackageManifest over 256 vs 1024 factories. Measured maximum 1.276; budget 1.9 is 1.49x. The small cell is still sub-millisecond, so this budget is looser than strategy on purpose.", + + "_measured": { + "resolve_scaling_ratio": 1.261, + "resolve_scaling_ratio_samples": [1.261, 1.201, 1.182, 1.241, 1.212], + "strategy_scaling_ratio": 1.03, + "strategy_scaling_ratio_samples": [1.019, 0.994, 0.97, 1.015, 1.03], + "parse_scaling_ratio": 1.276, + "parse_scaling_ratio_samples": [1.25, 1.276, 1.215, 1.224, 1.227], + "resolve_ms": 3.5, + "resolve_ms_4x": 17.16, + "strategy_ms": 0.67, + "strategy_ms_4x": 2.65, + "parse_ms": 0.41, + "parse_ms_4x": 2.01, + "reps": 15 + }, + "_measured_note": "Maxima (and sample lists) over 5 consecutive local runs using the min-of-15 estimator from parse-dispatch-rounds. Milliseconds are diagnostic context only — nothing gates on them." +} diff --git a/gitnexus/bench/swift-package-imports/measure.mjs b/gitnexus/bench/swift-package-imports/measure.mjs new file mode 100644 index 000000000..6d41728b6 --- /dev/null +++ b/gitnexus/bench/swift-package-imports/measure.mjs @@ -0,0 +1,475 @@ +/** + * Build-free bench for Swift Package.swift declaration resolve (#2964 / #2931). + * + * WHY THIS EXISTS. `bench/import-target`'s Swift arm never threads a + * `resolutionConfig`. It times the no-manifest directory-segment index + * (`getSwiftModuleIndex`) and cannot see a revert that starts scanning + * `allFilePaths` per `import`, drops declaration-only resolve, treats + * `https://` as a comment, or matches a target dir inside another path + * segment (#2931). Graph output on a tiny fixture is identical either way. + * This file is the same shape as `bench/parse-dispatch-rounds`: exact floors + * first, ratio timing only, never a millisecond ceiling. + * + * ARMS: + * + * - `targets` / `files` / `imports` — EXACT, and they are the FLOOR. + * `declared_resolved` only asserts something while the corpus still has + * many files and imports to walk. Shrink it to one happy-path module and + * the resolve arm still passes, gating a property the corpus no longer has. + * + * - `declared_resolved` / `sdk_external` / `undeclared_external` / + * `empty_declared_external` / `reexport_extra` / `nested_repeat_resolved` / + * `first_wins` — EXACT. Declaration map hits stay hits. Foundation / UIKit + * and an in-repo `CoreUI` folder that is NOT in Package.swift stay + * external. An empty `declaredTargets` map fails every name closed. Import + * of a module that `@_exported import`s another unions that module's files. + * A `#2931` nested `Sources/Mod0` path still belongs to Mod0. Grouping + * assigns a two-prefix file to the FIRST target only. + * + * - `parse_targets` / `parse_binary_skipped` / `url_comment_targets` / + * `parse_complete` — EXACT. Source factories are kept; binary/plugin + * factories are not modules; `https://` on the same line does not hide a + * later `.target`. + * + * - `layout_fingerprint` — EXACT. sha256 over sorted `from|target->files` + * rows on the unique query set. Catches a target-set change that leaves + * the counts intact. + * + * - `resolve_scaling_ratio` / `strategy_scaling_ratio` / `parse_scaling_ratio` + * — the only timing arms, RATIOS not millisecond ceilings. + * `(t_4n / t_n) / 4` divides the machine out; ~1.0 is linear. + * Files-per-target is FIXED while target count (and therefore files and + * imports) grow 4x, so a hit returns a constant-size file list. A correct + * once-per-pass index is then linear in imports; a per-import scan of + * allFilePaths scores ~4. Parse scales factory count 4x on a fixed-shape + * manifest. + * + * Usage: + * node --import tsx bench/swift-package-imports/measure.mjs + * node --import tsx bench/swift-package-imports/measure.mjs --check + */ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { performance } from 'node:perf_hooks'; +import { parseSwiftPackageManifest } from '../../src/core/ingestion/language-config.ts'; +import { swiftPackageStrategy } from '../../src/core/ingestion/import-resolvers/configs/swift.ts'; +import { resolveSwiftImportTarget } from '../../src/core/ingestion/languages/swift/import-target.ts'; +import { groupSwiftFilesBySpmTarget } from '../../src/core/ingestion/languages/swift/target-grouping.ts'; + +const baselines = JSON.parse(readFileSync(new URL('./baselines.json', import.meta.url), 'utf8')); + +const REPS = 15; +const TARGETS_SMALL = 16; +const FILES_PER_TARGET = 16; +const QUERY_REPEATS = 8; +const PARSE_TARGETS = 256; +const SCALE = 4; + +function queryKinds(targetCount) { + return [ + (t) => ({ raw: `Mod${(t + 1) % targetCount}`, expect: 'declared' }), + () => ({ raw: 'Foundation', expect: 'sdk' }), + () => ({ raw: 'CoreUI', expect: 'undeclared' }), + (t) => ({ raw: `Mod${(t + 1) % targetCount}.Model`, expect: 'declared' }), + () => ({ raw: 'UIKit', expect: 'sdk' }), + () => ({ raw: 'Ghost', expect: 'miss' }), + ]; +} + +function parsedImport(targetRaw) { + return { kind: 'namespace', localName: targetRaw, importedName: targetRaw, targetRaw }; +} + +function stubFile(filePath, parsedImports = []) { + return { + filePath, + moduleScope: `module:${filePath}`, + scopes: [], + parsedImports, + localDefs: [], + referenceSites: [], + }; +} + +function targetMap(targetCount) { + const targets = new Map(); + for (let t = 0; t < targetCount; t++) targets.set(`Mod${t}`, `Sources/Mod${t}`); + return targets; +} + +function declaredConfig(targets) { + return { origin: 'package.swift', targets, declaredTargets: targets }; +} + +function buildCorpus(targetCount) { + const targets = targetMap(targetCount); + const files = []; + const parsedFiles = []; + for (let t = 0; t < targetCount; t++) { + for (let i = 0; i < FILES_PER_TARGET; i++) { + const filePath = `Sources/Mod${t}/File${i}.swift`; + files.push(filePath); + const imports = + t === 0 && i === 0 + ? [{ kind: 'reexport', localName: 'Mod1', importedName: 'Mod1', targetRaw: 'Mod1' }] + : []; + parsedFiles.push(stubFile(filePath, imports)); + } + } + + const foundation = 'Sources/Foundation/Thing.swift'; + const coreUi = 'Sources/CoreUI/View.swift'; + const nested = 'vendor/Sources/Mod0/Sources/Mod0/Nested.swift'; + const clash = 'Sources/Mod0/Vendor/Sources/Mod1/Clash.swift'; + for (const extra of [foundation, coreUi, nested, clash]) { + files.push(extra); + parsedFiles.push(stubFile(extra)); + } + + const kinds = queryKinds(targetCount); + const queries = []; + for (let t = 0; t < targetCount; t++) { + for (let i = 0; i < FILES_PER_TARGET; i++) { + const from = `Sources/Mod${t}/File${i}.swift`; + for (let r = 0; r < QUERY_REPEATS; r++) { + for (const kind of kinds) { + const { raw, expect } = kind(t); + queries.push({ from, raw, expect, unique: r === 0 && i === 0 }); + } + } + } + } + + return { + files, + parsedFiles, + queries, + targets, + config: declaredConfig(targets), + extras: { foundation, coreUi, nested, clash }, + targetCount, + }; +} + +function outcomeKey(from, raw, result) { + if (result == null) return `${from}|${raw}->`; + const list = typeof result === 'string' ? [result] : [...result]; + return `${from}|${raw}->${list.sort().join(',')}`; +} + +function resultFiles(result) { + if (result == null) return []; + return typeof result === 'string' ? [result] : [...result]; +} + +function resolveOne(query, allFilePaths, config, parsedFiles) { + return resolveSwiftImportTarget(parsedImport(query.raw), { + fromFile: query.from, + allFilePaths, + resolutionConfig: config, + parsedFiles, + }); +} + +function strategyCtx(files, config) { + return { + allFilePaths: new Set(files), + allFileList: files, + normalizedFileList: files.map((p) => p.replace(/\\/g, '/')), + index: {}, + resolveCache: new Map(), + configs: { + tsconfigPaths: null, + goModule: null, + composerConfig: null, + swiftPackageConfig: config, + csharpConfigs: [], + }, + }; +} + +function resolvePass(corpus) { + const allFilePaths = new Set(corpus.files); + let resolved = 0; + for (const query of corpus.queries) { + if (resolveOne(query, allFilePaths, corpus.config, corpus.parsedFiles) != null) resolved++; + } + return resolved; +} + +function strategyPass(corpus) { + const ctx = strategyCtx(corpus.files, corpus.config); + let resolved = 0; + for (const query of corpus.queries) { + if (swiftPackageStrategy(query.raw, query.from, ctx) != null) resolved++; + } + return resolved; +} + +function manifestFor(targetCount) { + const rows = []; + for (let i = 0; i < targetCount; i++) { + rows.push( + ` .target(name: "Mod${i}", dependencies: [.product(name: "X", package: "https://example.com/x")]),`, + ); + if (i % 8 === 0) { + rows.push( + ` .binaryTarget(name: "Bin${i}", url: "https://example.com/b${i}.xcframework"),`, + ); + } + } + return `let package = Package(\n name: "Demo",\n targets: [\n${rows.join('\n')}\n ]\n)\n`; +} + +function fastest(fn, reps) { + fn(); + let best = Infinity; + for (let r = 0; r < reps; r++) { + const t0 = performance.now(); + fn(); + best = Math.min(best, performance.now() - t0); + } + return best; +} + +function correctness(corpus) { + const allFilePaths = new Set(corpus.files); + const records = []; + let declaredResolved = 0; + let sdkExternal = 0; + let undeclaredExternal = 0; + + const unique = corpus.queries.filter((q) => q.unique); + for (const query of unique) { + const result = resolveOne(query, allFilePaths, corpus.config, corpus.parsedFiles); + records.push(outcomeKey(query.from, query.raw, result)); + if (query.expect === 'declared' && result != null) declaredResolved++; + if (query.expect === 'sdk' && result == null) sdkExternal++; + if (query.expect === 'undeclared' && result == null) undeclaredExternal++; + } + + const fromOther = 'Sources/Mod2/File0.swift'; + const reexport = resultFiles( + resolveOne({ from: fromOther, raw: 'Mod0' }, allFilePaths, corpus.config, corpus.parsedFiles), + ); + const reexportExtra = + reexport.includes('Sources/Mod0/File0.swift') && reexport.includes('Sources/Mod1/File0.swift') + ? 1 + : 0; + const nestedRepeatResolved = reexport.includes(corpus.extras.nested) ? 1 : 0; + + const empty = resolveOne( + { from: fromOther, raw: 'Mod0' }, + allFilePaths, + { origin: 'package.swift', targets: corpus.targets, declaredTargets: new Map() }, + corpus.parsedFiles, + ); + const emptyDeclaredExternal = empty == null ? 1 : 0; + + const groups = groupSwiftFilesBySpmTarget(corpus.files, (p) => p, corpus.targets); + const firstWins = + groups.get('Mod0')?.includes(corpus.extras.clash) === true && + groups.get('Mod1')?.includes(corpus.extras.clash) !== true + ? 1 + : 0; + + const parseFixed = parseSwiftPackageManifest(` +let package = Package( + name: "Demo", + dependencies: [.package(url: "https://example.com/foo.git", from: "1.0.0")], + targets: [ + .target(name: "Models"), + .target(name: "App"), + .binaryTarget(name: "Lib", url: "https://example.com/Lib.xcframework", checksum: "abc"), + .testTarget(name: "AppTests"), + .plugin(name: "Gen"), + .systemLibrary(name: "CFoo"), + ] +) +`); + const urlComment = parseSwiftPackageManifest( + 'let package = Package(name: "Demo", dependencies: [.package(url: "https://example.com/foo.git", from: "1.0.0")], targets: [.target(name: "T")])', + ); + + return { + declaredResolved, + sdkExternal, + undeclaredExternal, + emptyDeclaredExternal, + reexportExtra, + nestedRepeatResolved, + firstWins, + parseTargets: parseFixed.targets.size, + parseBinarySkipped: + parseFixed.targets.has('Lib') || + parseFixed.targets.has('Gen') || + parseFixed.targets.has('CFoo') + ? 0 + : 1, + urlCommentTargets: urlComment.targets.get('T') === 'Sources/T' ? 1 : 0, + parseComplete: parseFixed.complete && urlComment.complete ? 1 : 0, + fingerprint: createHash('sha256').update(records.sort().join('\n')).digest('hex'), + }; +} + +const small = buildCorpus(TARGETS_SMALL); +const large = buildCorpus(TARGETS_SMALL * SCALE); +const shape = correctness(small); + +const smallResolveMs = fastest(() => resolvePass(small), REPS); +const largeResolveMs = fastest(() => resolvePass(large), REPS); +const resolveScaling = largeResolveMs / smallResolveMs / SCALE; + +const smallStrategyMs = fastest(() => strategyPass(small), REPS); +const largeStrategyMs = fastest(() => strategyPass(large), REPS); +const strategyScaling = largeStrategyMs / smallStrategyMs / SCALE; + +const parseSmallSrc = manifestFor(PARSE_TARGETS); +const parseLargeSrc = manifestFor(PARSE_TARGETS * SCALE); +const parseSmall = parseSwiftPackageManifest(parseSmallSrc); +const parseLarge = parseSwiftPackageManifest(parseLargeSrc); +const smallParseMs = fastest(() => parseSwiftPackageManifest(parseSmallSrc), REPS); +const largeParseMs = fastest(() => parseSwiftPackageManifest(parseLargeSrc), REPS); +const parseScaling = largeParseMs / smallParseMs / SCALE; + +const files = small.files.length; +const imports = small.queries.length; + +console.log(`targets : ${small.targetCount} (expect ${baselines.targets})`); +console.log(`files : ${files} (expect ${baselines.files})`); +console.log(`imports : ${imports} (expect ${baselines.imports})`); +console.log( + `declared_resolved : ${shape.declaredResolved} (expect ${baselines.declared_resolved})`, +); +console.log(`sdk_external : ${shape.sdkExternal} (expect ${baselines.sdk_external})`); +console.log( + `undeclared_external : ${shape.undeclaredExternal} (expect ${baselines.undeclared_external})`, +); +console.log( + `empty_declared_external: ${shape.emptyDeclaredExternal} (expect ${baselines.empty_declared_external})`, +); +console.log( + `reexport_extra : ${shape.reexportExtra} (expect ${baselines.reexport_extra})`, +); +console.log( + `nested_repeat_resolved : ${shape.nestedRepeatResolved} (expect ${baselines.nested_repeat_resolved})`, +); +console.log(`first_wins : ${shape.firstWins} (expect ${baselines.first_wins})`); +console.log(`parse_targets : ${shape.parseTargets} (expect ${baselines.parse_targets})`); +console.log( + `parse_binary_skipped : ${shape.parseBinarySkipped} (expect ${baselines.parse_binary_skipped})`, +); +console.log( + `url_comment_targets : ${shape.urlCommentTargets} (expect ${baselines.url_comment_targets})`, +); +console.log( + `parse_complete : ${shape.parseComplete} (expect ${baselines.parse_complete})`, +); +console.log(`layout_fingerprint : ${shape.fingerprint}`); +console.log( + `resolve_scaling_ratio : ${resolveScaling.toFixed(3)} (budget <= ${baselines.resolve_scaling_budget}; ~1.0 is linear)`, +); +console.log( + `strategy_scaling_ratio : ${strategyScaling.toFixed(3)} (budget <= ${baselines.strategy_scaling_budget}; ~1.0 is linear)`, +); +console.log( + `parse_scaling_ratio : ${parseScaling.toFixed(3)} (budget <= ${baselines.parse_scaling_budget}; ~1.0 is linear)`, +); +console.log( + `reps : ${REPS} resolve ${smallResolveMs.toFixed(2)}ms / 4x ${largeResolveMs.toFixed(2)}ms strategy ${smallStrategyMs.toFixed(2)}ms / 4x ${largeStrategyMs.toFixed(2)}ms parse ${smallParseMs.toFixed(2)}ms / 4x ${largeParseMs.toFixed(2)}ms`, +); +console.log( + `parse_scale_shape : ${parseSmall.targets.size} -> ${parseLarge.targets.size} targets (expect ${PARSE_TARGETS} -> ${PARSE_TARGETS * SCALE})`, +); + +if (process.argv.includes('--check')) { + let failed = false; + + if (shape.fingerprint !== baselines.layout_fingerprint) { + failed = true; + console.error( + `\nFAIL layout_fingerprint: ${shape.fingerprint}\n` + + ` expected ${baselines.layout_fingerprint}\n` + + ` Unique-query target set moved. Explain it; do not re-baseline alone.`, + ); + } + + const exact = [ + ['targets', small.targetCount], + ['files', files], + ['imports', imports], + ['declared_resolved', shape.declaredResolved], + ['sdk_external', shape.sdkExternal], + ['undeclared_external', shape.undeclaredExternal], + ['empty_declared_external', shape.emptyDeclaredExternal], + ['reexport_extra', shape.reexportExtra], + ['nested_repeat_resolved', shape.nestedRepeatResolved], + ['first_wins', shape.firstWins], + ['parse_targets', shape.parseTargets], + ['parse_binary_skipped', shape.parseBinarySkipped], + ['url_comment_targets', shape.urlCommentTargets], + ['parse_complete', shape.parseComplete], + ]; + for (const [name, value] of exact) { + if (value !== baselines[name]) { + failed = true; + console.error(`\nFAIL ${name}: ${value}, expected exactly ${baselines[name]}.`); + } + } + + if ( + small.targetCount !== baselines.targets || + files !== baselines.files || + imports !== baselines.imports + ) { + failed = true; + console.error( + `\nFAIL shape: the corpus must stay large enough that declared_resolved still measures a walk.`, + ); + } + + if ( + parseSmall.targets.size !== PARSE_TARGETS || + parseLarge.targets.size !== PARSE_TARGETS * SCALE + ) { + failed = true; + console.error( + `\nFAIL parse scale shape: ${parseSmall.targets.size} -> ${parseLarge.targets.size}, ` + + `expected ${PARSE_TARGETS} -> ${PARSE_TARGETS * SCALE}.`, + ); + } + + if (resolveScaling > baselines.resolve_scaling_budget) { + failed = true; + console.error( + `\nFAIL resolve_scaling_ratio: ${resolveScaling.toFixed(3)} exceeds ` + + `${baselines.resolve_scaling_budget} (~1.0 is linear).\n` + + ` resolveSwiftImportTarget grew superlinearly in file+import count — a\n` + + ` per-import scan of allFilePaths scores ~4 here. Re-run on an idle\n` + + ` machine before investigating, and check \`reps\` first.`, + ); + } + + if (strategyScaling > baselines.strategy_scaling_budget) { + failed = true; + console.error( + `\nFAIL strategy_scaling_ratio: ${strategyScaling.toFixed(3)} exceeds ` + + `${baselines.strategy_scaling_budget} (~1.0 is linear).\n` + + ` swiftPackageStrategy grew superlinearly — usually the WeakMap target\n` + + ` index falling back to O(imports × files). Re-run idle; check \`reps\`.`, + ); + } + + if (parseScaling > baselines.parse_scaling_budget) { + failed = true; + console.error( + `\nFAIL parse_scaling_ratio: ${parseScaling.toFixed(3)} exceeds ` + + `${baselines.parse_scaling_budget} (~1.0 is linear).\n` + + ` parseSwiftPackageManifest grew superlinearly in factory count.\n` + + ` Re-run on an idle machine before investigating, and check \`reps\` first.`, + ); + } + + if (failed) process.exit(1); + console.log('\nOK — within budget.'); +} diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index d39daf3c1..75a343058 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -68,7 +68,7 @@ "@vitest/coverage-v8": "^4.0.18", "gitnexus-shared": "file:../gitnexus-shared", "tsx": "^4.0.0", - "typescript": "^5.4.5", + "typescript": "^7.0.2", "vitest": "^4.0.18" }, "engines": { @@ -1331,6 +1331,346 @@ "@types/node": "*" } }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/@vitest/coverage-v8": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", @@ -2908,9 +3248,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.1.tgz", - "integrity": "sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==", + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.2.tgz", + "integrity": "sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==", "funding": [ { "type": "github", @@ -3809,9 +4149,9 @@ "license": "MIT" }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -3819,6 +4159,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/pump": { @@ -4671,17 +5015,38 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", "dev": true, "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "tsc": "bin/tsc" }, "engines": { - "node": ">=14.17" + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" } }, "node_modules/typical": { diff --git a/gitnexus/package.json b/gitnexus/package.json index 1e2bc42ad..c5ab6f3c6 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -129,7 +129,7 @@ "@vitest/coverage-v8": "^4.0.18", "gitnexus-shared": "file:../gitnexus-shared", "tsx": "^4.0.0", - "typescript": "^5.4.5", + "typescript": "^7.0.2", "vitest": "^4.0.18" }, "overrides": { diff --git a/gitnexus/scripts/build.js b/gitnexus/scripts/build.js index 6cead2784..b97f6e3b6 100644 --- a/gitnexus/scripts/build.js +++ b/gitnexus/scripts/build.js @@ -54,8 +54,8 @@ if (!fs.existsSync(SHARED_ROOT)) { // Launch tsc as `node typescript/lib/tsc.js` on every OS. The `.bin/tsc` / // `tsc.cmd` shims are Windows-only wrappers; `execFileSync` cannot spawn a -// `.cmd` without a shell, and a separate `npm ci` in gitnexus-shared pulls -// TypeScript 7 optional platform packages (7+ minutes in CI). +// `.cmd` without a shell, and a separate `npm ci` in gitnexus-shared would +// pull a second TypeScript 7 optional-platform install (7+ minutes in CI). const tscJs = path.join(ROOT, 'node_modules', 'typescript', 'lib', 'tsc.js'); if (!fs.existsSync(tscJs)) { console.error( diff --git a/gitnexus/src/cli/analyze-config.ts b/gitnexus/src/cli/analyze-config.ts index 49896538e..3d2219db2 100644 --- a/gitnexus/src/cli/analyze-config.ts +++ b/gitnexus/src/cli/analyze-config.ts @@ -33,8 +33,10 @@ import path from 'node:path'; import { readRepoControlFile } from '../config/repo-control-file.js'; import { InvalidBranchError, + sanitizeDetectedBranch, validateBranchName as validateBranchNameCore, } from '../core/git-ref.js'; +export { sanitizeDetectedBranch }; import type { AnalyzeOptions } from './analyze-options.js'; export const GITNEXUS_RC_FILENAME = '.gitnexusrc'; @@ -100,6 +102,10 @@ const KEY_SPECS: Record = { workerTimeout: { target: 'workerTimeout', kind: 'numeric-string' }, walCheckpointThreshold: { target: 'walCheckpointThreshold', kind: 'numeric-string' }, workers: { target: 'workers', kind: 'numeric-string' }, + maxProcesses: { target: 'maxProcesses', kind: 'numeric-string' }, + maxProcessBranching: { target: 'maxProcessBranching', kind: 'numeric-string' }, + maxProcessTraceDepth: { target: 'maxProcessTraceDepth', kind: 'numeric-string' }, + maxEntryPointCandidates: { target: 'maxEntryPointCandidates', kind: 'numeric-string' }, embeddingThreads: { target: 'embeddingThreads', kind: 'numeric-string' }, embeddingBatchSize: { target: 'embeddingBatchSize', kind: 'numeric-string' }, embeddingSubBatchSize: { target: 'embeddingSubBatchSize', kind: 'numeric-string' }, @@ -172,20 +178,6 @@ export function validateBranchName(value: string, source: string): string { } } -/** - * Best-effort validation for an auto-detected branch (from git). Never throws — - * returns `undefined` for anything unusable so the resolver falls back to the - * next precedence tier. - */ -export function sanitizeDetectedBranch(value: string | null | undefined): string | undefined { - if (!value) return undefined; - try { - return validateBranchName(value, 'detected branch'); - } catch { - return undefined; - } -} - const normalizeValue = (kind: ValueKind, value: unknown, key: string): unknown => { const source = `${GITNEXUS_RC_FILENAME} "${key}"`; switch (kind) { diff --git a/gitnexus/src/cli/analyze-options.ts b/gitnexus/src/cli/analyze-options.ts index ffa439dab..97e969a25 100644 --- a/gitnexus/src/cli/analyze-options.ts +++ b/gitnexus/src/cli/analyze-options.ts @@ -115,6 +115,14 @@ export interface AnalyzeOptions { walCheckpointThreshold?: string; /** Parse worker pool size (>=1); 0 is rejected (no sequential mode). */ workers?: string; + /** Process-detection process cap. Positive integer string; `0` is invalid. */ + maxProcesses?: string; + /** Process-detection per-node branching cap. Positive integer string. */ + maxProcessBranching?: string; + /** Process-detection DFS depth cap. Positive integer string. */ + maxProcessTraceDepth?: string; + /** Ranked entry-point candidate pool. Positive integer string. */ + maxEntryPointCandidates?: string; embeddingThreads?: string; embeddingBatchSize?: string; embeddingSubBatchSize?: string; diff --git a/gitnexus/src/cli/analyze-watch.ts b/gitnexus/src/cli/analyze-watch.ts index 32a4de2f1..2c3186a24 100644 --- a/gitnexus/src/cli/analyze-watch.ts +++ b/gitnexus/src/cli/analyze-watch.ts @@ -22,6 +22,10 @@ import { import type { AnalyzeOptions } from './analyze-options.js'; import { ensureHeap } from './analyze.js'; import { cliError, cliInfo, cliWarn } from './cli-message.js'; +import { + formatInvalidProcessDetectionOverride, + parseProcessDetectionBudgetStrings, +} from '../core/ingestion/process-detection-budget.js'; import { WATCH_FULL_REFRESH_PATH, WatchRefreshQueue, @@ -164,6 +168,17 @@ export async function resolveWatchOptions( const workerPoolSize = positiveInteger(merged.workers, '--workers'); const workerTimeoutSeconds = positiveInteger(merged.workerTimeout, 'workerTimeout'); const maxFileSize = positiveInteger(merged.maxFileSize, 'maxFileSize', MAX_FILE_SIZE_KB); + const processDetection = parseProcessDetectionBudgetStrings( + { + maxProcesses: merged.maxProcesses, + maxProcessBranching: merged.maxProcessBranching, + maxProcessTraceDepth: merged.maxProcessTraceDepth, + maxEntryPointCandidates: merged.maxEntryPointCandidates, + }, + (flag, raw) => { + cliWarn(formatInvalidProcessDetectionOverride(flag, raw)); + }, + ); setEnvironment( 'GITNEXUS_MAX_FILE_SIZE', @@ -183,6 +198,10 @@ export async function resolveWatchOptions( registryName: merged.name, allowDuplicateName: merged.allowDuplicateName, workerPoolSize, + maxProcesses: processDetection.maxProcesses, + maxProcessBranching: processDetection.maxProcessBranching, + maxProcessTraceDepth: processDetection.maxProcessTraceDepth, + maxEntryPointCandidates: processDetection.maxEntryPointCandidates, fetchWrappers: merged.fetchWrappers, skipAgentsMd: true, skipSkills: true, diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 04efddfd8..dcaf55934 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -54,6 +54,12 @@ import type { AnalyzeOptions } from './analyze-options.js'; import { runFullAnalysis } from '../core/run-analyze.js'; import { getRuntimeFingerprint } from '../core/platform/capabilities.js'; import { getMaxFileSizeBannerMessage } from '../core/ingestion/utils/max-file-size.js'; +import { + formatInvalidProcessDetectionOverride, + formatProcessDetectionBudgetBanner, + parseProcessDetectionBudgetStrings, + resolveProcessDetectionBudget, +} from '../core/ingestion/process-detection-budget.js'; import { warnMissingOptionalGrammars, getOptionalGrammarExtensions } from './optional-grammars.js'; import { glob } from 'glob'; import fs from 'fs/promises'; @@ -947,6 +953,18 @@ const analyzeCommandImpl = async ( workerPoolSize = parsedWorkers; } + const processDetectionFromFlags = parseProcessDetectionBudgetStrings( + { + maxProcesses: options.maxProcesses, + maxProcessBranching: options.maxProcessBranching, + maxProcessTraceDepth: options.maxProcessTraceDepth, + maxEntryPointCandidates: options.maxEntryPointCandidates, + }, + (flag, raw) => { + cliWarn(` ${formatInvalidProcessDetectionOverride(flag, raw)}\n`); + }, + ); + // Parse `--embeddings [limit]`: `true` → default cap, string → numeric cap // (0 disables the cap entirely). Validated up here so failures match the // sibling-validation pattern (exit before bar.start() — otherwise @@ -1236,6 +1254,12 @@ const analyzeCommandImpl = async ( if (maxFileSizeBanner) { console.log(`${maxFileSizeBanner}\n`); } + const processDetectionBanner = formatProcessDetectionBudgetBanner( + resolveProcessDetectionBudget(processDetectionFromFlags), + ); + if (processDetectionBanner) { + console.log(`${processDetectionBanner}\n`); + } // ── CLI progress bar setup ───────────────────────────────────────── const barOptions: cliProgress.Options & { terminal?: CliProgressTerminal } = { @@ -1376,6 +1400,10 @@ const analyzeCommandImpl = async ( // GITNEXUS_WORKER_POOL_SIZE env mutation. `undefined` defers to the // env / auto-formula fallback inside the pipeline. workerPoolSize, + maxProcesses: processDetectionFromFlags.maxProcesses, + maxProcessBranching: processDetectionFromFlags.maxProcessBranching, + maxProcessTraceDepth: processDetectionFromFlags.maxProcessTraceDepth, + maxEntryPointCandidates: processDetectionFromFlags.maxEntryPointCandidates, // Extra fetch-wrapper names from `.gitnexusrc` (#1589/#1852 residual); // forwarded to the routes phase consumer scan. fetchWrappers: options.fetchWrappers, diff --git a/gitnexus/src/cli/help-i18n.ts b/gitnexus/src/cli/help-i18n.ts index ede409763..10d69263f 100644 --- a/gitnexus/src/cli/help-i18n.ts +++ b/gitnexus/src/cli/help-i18n.ts @@ -72,6 +72,10 @@ const OPTION_DESCRIPTION_KEYS = { 'analyze|--worker-timeout ': 'help.option.analyze.workerTimeout', 'analyze|--wal-checkpoint-threshold ': 'help.option.analyze.walCheckpointThreshold', 'analyze|--workers ': 'help.option.analyze.workers', + 'analyze|--max-processes ': 'help.option.analyze.maxProcesses', + 'analyze|--max-process-branching ': 'help.option.analyze.maxProcessBranching', + 'analyze|--max-process-trace-depth ': 'help.option.analyze.maxProcessTraceDepth', + 'analyze|--max-entry-point-candidates ': 'help.option.analyze.maxEntryPointCandidates', 'analyze|--embedding-threads ': 'help.option.analyze.embeddingThreads', 'analyze|--embedding-batch-size ': 'help.option.analyze.embeddingBatchSize', 'analyze|--embedding-sub-batch-size ': 'help.option.analyze.embeddingSubBatchSize', diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index 8dc7f45d7..1198fdafd 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -254,6 +254,14 @@ export const en = { 'LadybugDB WAL auto-checkpoint threshold in bytes during analyze (integer >= -1; default: 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).', 'help.option.analyze.workers': 'Parse worker pool size (>=1). Default: cores-1 capped at 16, auto-sized to the repo.', + 'help.option.analyze.maxProcesses': + 'Process-detection process cap (positive integer). Replaces the dynamic max(20, round(symbols/10)) formula. Default: dynamic.', + 'help.option.analyze.maxProcessBranching': + 'Process-detection per-node branching cap (positive integer). Default: 4.', + 'help.option.analyze.maxProcessTraceDepth': + 'Process-detection DFS depth cap (positive integer). Default: 10.', + 'help.option.analyze.maxEntryPointCandidates': + 'Ranked entry-point candidate pool (positive integer). Default: 200. Raise when the warning names this knob; doubling is the usual first raise.', 'help.option.analyze.embeddingThreads': 'Limit local ONNX embedding CPU threads', 'help.option.analyze.embeddingBatchSize': 'Number of nodes per embedding batch', 'help.option.analyze.embeddingSubBatchSize': 'Number of chunks per embedding model call', @@ -358,5 +366,5 @@ export const en = { 'help.identityCache.environment': '\nAnalyzer identity cache:\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir\n Operator-trusted persistent cache for warm cross-process status. The directory must pre-exist, be outside the GitNexus package/build roots, and contain no symlink or junction components. Defaults remain fail-closed on platforms without POSIX ownership APIs.', 'help.analyze.environment': - '\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_STORAGE_PATH=/absolute/index Complete external index directory. Preserves the existing configuration semantics and overrides GITNEXUS_STORAGE_ROOT when both are set.\n GITNEXUS_STORAGE_ROOT=/absolute/root External index root; each repository uses an isolated -/ slot.\n GITNEXUS_CONTENT_RETENTION=full Source-text retention profile: full, symbol, or none. Default full.\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir Operator-trusted persistent analyzer identity cache; must pre-exist, be outside package/build roots, and contain no symlink/junction components.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N Max wait at pool shutdown for a retired worker still inside native code (terminated at its next safe point instead of aborting the process). Default 30000.\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning. Default 20000.\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 Retry per-attempt HTTP embedding timeouts through GITNEXUS_EMBEDDING_MAX_ATTEMPTS (default off; timeouts stay terminal).\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for MCP, 0.5 elsewhere.\n\nFlags override the corresponding env vars when both are provided.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).', + '\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_STORAGE_PATH=/absolute/index Complete external index directory. Preserves the existing configuration semantics and overrides GITNEXUS_STORAGE_ROOT when both are set.\n GITNEXUS_STORAGE_ROOT=/absolute/root External index root; each repository uses an isolated -/ slot.\n GITNEXUS_CONTENT_RETENTION=full Source-text retention profile: full, symbol, or none. Default full.\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir Operator-trusted persistent analyzer identity cache; must pre-exist, be outside package/build roots, and contain no symlink/junction components.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N Max wait at pool shutdown for a retired worker still inside native code (terminated at its next safe point instead of aborting the process). Default 30000.\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning. Default 20000.\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 Retry per-attempt HTTP embedding timeouts through GITNEXUS_EMBEDDING_MAX_ATTEMPTS (default off; timeouts stay terminal).\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for MCP, 0.5 elsewhere.\n GITNEXUS_MAX_PROCESSES=N Process-detection process cap (positive integer). Replaces the dynamic max(20, round(symbols/10)) formula. Distinct from query-time IMPACT_MAX_CHUNKS.\n GITNEXUS_MAX_PROCESS_BRANCHING=N Process-detection per-node branching cap. Default 4.\n GITNEXUS_MAX_PROCESS_TRACE_DEPTH=N Process-detection DFS depth cap. Default 10.\n GITNEXUS_MAX_ENTRY_POINT_CANDIDATES=N Ranked entry-point candidate pool. Default 200. Raise when the warning names this knob; doubling is the usual first raise.\n\nCLI flags take precedence over `.gitnexusrc`, which takes precedence over env vars, which take precedence over built-in defaults.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).', } as const; diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index bef315752..03f48adba 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -235,6 +235,12 @@ export const zhCN = { 'analyze 期间 LadybugDB WAL 自动 checkpoint 阈值(字节,整数 >= -1;默认:67108864 = 64 MiB;-1 保持 Ladybug 默认约 16 MiB)。', 'help.option.analyze.workers': '解析 worker 池大小(>=1)。默认:cores-1,最多 16,按仓库规模自适应。', + 'help.option.analyze.maxProcesses': + '流程检测的流程数量上限(正整数)。覆盖动态的 max(20, round(symbols/10)) 公式。默认:动态。', + 'help.option.analyze.maxProcessBranching': '流程检测的单节点分支上限(正整数)。默认:4。', + 'help.option.analyze.maxProcessTraceDepth': '流程检测的 DFS 深度上限(正整数)。默认:10。', + 'help.option.analyze.maxEntryPointCandidates': + '排序后的入口点候选池(正整数)。默认:200。仅在警告点名该上限时提高;那时通常先翻倍。', 'help.option.analyze.embeddingThreads': '限制本地 ONNX 嵌入 CPU 线程数', 'help.option.analyze.embeddingBatchSize': '每个嵌入批次的节点数', 'help.option.analyze.embeddingSubBatchSize': '每次嵌入模型调用的分块数', @@ -331,5 +337,5 @@ export const zhCN = { 'help.identityCache.environment': '\n分析器身份缓存:\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir\n 由操作员明确信任的持久缓存,用于跨进程快速查询状态。目录必须预先存在、位于 GitNexus 包/构建根目录之外,且路径中不得包含符号链接或 junction。缺少 POSIX 所有权 API 的平台默认保持故障关闭。', 'help.analyze.environment': - '\n环境变量:\n GITNEXUS_NO_GITIGNORE=1 跳过 .gitignore 解析(仍读取 .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N 覆盖大文件跳过阈值(KB)。默认 512,最大 32768。\n GITNEXUS_STORAGE_PATH=/absolute/index 完整外部索引目录。保留既有配置语义;与 GITNEXUS_STORAGE_ROOT 同时设置时优先使用。\n GITNEXUS_STORAGE_ROOT=/absolute/root 外部索引根目录;每个仓库使用独立的 <仓库名>-<规范路径哈希>/ 子目录。\n GITNEXUS_CONTENT_RETENTION=full 源码文本保留策略:full、symbol 或 none。默认 full。\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir 由操作员明确信任的持久分析器身份缓存;目录必须预先存在、位于包/构建根目录之外,且路径中不得包含符号链接或 junction。\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker 空闲超时(毫秒)。默认 30000。\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL 自动 checkpoint 阈值(字节,默认 67108864 = 64 MiB;-1 保持 Ladybug 默认约 16 MiB)。\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker 作业字节预算。默认 8388608。\n GITNEXUS_WORKER_POOL_SIZE=N 解析 worker 数量覆盖值。默认 cores-1,最多 16。\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N 并发进行中的解析分块数。默认 2。\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N 每个 slot 丢弃前允许的最大替换进程数。默认 3。\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N 每个作业的总重试墙钟时间。默认 5 倍子批次超时。\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N 每个 slot 触发熔断的死亡次数。默认 max(3, poolSize)。\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N 线程池关闭时等待仍在原生代码中的已退役 worker 的最长时间(到达安全点后再终止,避免进程级 abort)。默认 30000。\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N C++ 捕获提取的每文件墙钟预算;超出后该文件保留部分捕获并输出警告。默认 20000。\n GITNEXUS_EMBEDDING_THREADS=N 限制 --embeddings 的本地 ONNX CPU 线程数。\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 将单次 HTTP 嵌入超时纳入 GITNEXUS_EMBEDDING_MAX_ATTEMPTS 重试(默认关闭,超时仍为终止错误)。\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N exact-scan 回退的最大嵌入分块数。默认 10000。\n GITNEXUS_VECTOR_MAX_DISTANCE=N 语义/向量搜索接受的最大余弦距离(0 < N <= 2;超出则钳制为 2)。MCP 默认 0.6,其他路径默认 0.5。\n\n当参数和对应环境变量同时提供时,参数优先。\n\n提示:`.gitnexusignore` 支持 `.gitignore` 风格的取反。比如添加\n `!__tests__/` 可以索引默认自动过滤的目录(#771)。', + '\n环境变量:\n GITNEXUS_NO_GITIGNORE=1 跳过 .gitignore 解析(仍读取 .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N 覆盖大文件跳过阈值(KB)。默认 512,最大 32768。\n GITNEXUS_STORAGE_PATH=/absolute/index 完整外部索引目录。保留既有配置语义;与 GITNEXUS_STORAGE_ROOT 同时设置时优先使用。\n GITNEXUS_STORAGE_ROOT=/absolute/root 外部索引根目录;每个仓库使用独立的 <仓库名>-<规范路径哈希>/ 子目录。\n GITNEXUS_CONTENT_RETENTION=full 源码文本保留策略:full、symbol 或 none。默认 full。\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir 由操作员明确信任的持久分析器身份缓存;目录必须预先存在、位于包/构建根目录之外,且路径中不得包含符号链接或 junction。\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker 空闲超时(毫秒)。默认 30000。\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL 自动 checkpoint 阈值(字节,默认 67108864 = 64 MiB;-1 保持 Ladybug 默认约 16 MiB)。\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker 作业字节预算。默认 8388608。\n GITNEXUS_WORKER_POOL_SIZE=N 解析 worker 数量覆盖值。默认 cores-1,最多 16。\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N 并发进行中的解析分块数。默认 2。\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N 每个 slot 丢弃前允许的最大替换进程数。默认 3。\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N 每个作业的总重试墙钟时间。默认 5 倍子批次超时。\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N 每个 slot 触发熔断的死亡次数。默认 max(3, poolSize)。\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N 线程池关闭时等待仍在原生代码中的已退役 worker 的最长时间(到达安全点后再终止,避免进程级 abort)。默认 30000。\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N C++ 捕获提取的每文件墙钟预算;超出后该文件保留部分捕获并输出警告。默认 20000。\n GITNEXUS_EMBEDDING_THREADS=N 限制 --embeddings 的本地 ONNX CPU 线程数。\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 将单次 HTTP 嵌入超时纳入 GITNEXUS_EMBEDDING_MAX_ATTEMPTS 重试(默认关闭,超时仍为终止错误)。\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N exact-scan 回退的最大嵌入分块数。默认 10000。\n GITNEXUS_VECTOR_MAX_DISTANCE=N 语义/向量搜索接受的最大余弦距离(0 < N <= 2;超出则钳制为 2)。MCP 默认 0.6,其他路径默认 0.5。\n GITNEXUS_MAX_PROCESSES=N 流程检测的流程数量上限(正整数)。覆盖动态的 max(20, round(symbols/10)) 公式。与查询时的 IMPACT_MAX_CHUNKS 无关。\n GITNEXUS_MAX_PROCESS_BRANCHING=N 流程检测的单节点分支上限。默认 4。\n GITNEXUS_MAX_PROCESS_TRACE_DEPTH=N 流程检测的 DFS 深度上限。默认 10。\n GITNEXUS_MAX_ENTRY_POINT_CANDIDATES=N 排序后的入口点候选池。默认 200。仅在警告点名该上限时提高;那时通常先翻倍。\n\nCLI 参数优先于 `.gitnexusrc`,后者优先于环境变量,环境变量优先于内置默认值。\n\n提示:`.gitnexusignore` 支持 `.gitignore` 风格的取反。比如添加\n `!__tests__/` 可以索引默认自动过滤的目录(#771)。', } satisfies EnglishMessages; diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 22bb3c0dd..a46606eca 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -163,6 +163,22 @@ program '--workers ', 'Parse worker pool size (>=1). Default: cores-1 capped at 16, auto-sized to the repo.', ) + .option( + '--max-processes ', + 'Process-detection process cap (positive integer). Replaces the dynamic max(20, round(symbols/10)) formula. Default: dynamic.', + ) + .option( + '--max-process-branching ', + 'Process-detection per-node branching cap (positive integer). Default: 4.', + ) + .option( + '--max-process-trace-depth ', + 'Process-detection DFS depth cap (positive integer). Default: 10.', + ) + .option( + '--max-entry-point-candidates ', + 'Ranked entry-point candidate pool (positive integer). Default: 200. Raise when the warning names this knob; doubling is the usual first raise.', + ) .option( '--spring-actuator ', 'Import local Spring Boot Actuator JSON snapshots (mappings, beans, conditions, ' + diff --git a/gitnexus/src/core/embeddings/embedding-restore-spill.ts b/gitnexus/src/core/embeddings/embedding-restore-spill.ts new file mode 100644 index 000000000..10ca51b45 --- /dev/null +++ b/gitnexus/src/core/embeddings/embedding-restore-spill.ts @@ -0,0 +1,483 @@ +/** + * Disk-backed restore cache for CodeEmbedding rows (#3306). + * + * `loadCachedEmbeddings` used to `getAll()` the table and `map(Number)` every + * vector into a JS `number[]`. On a large already-indexed repo that single + * structure OOMs the V8 heap during "Caching embeddings..." even when the + * incremental diff is a handful of nodes. + * + * This module keeps metadata in RAM and writes vectors to a temp Float32 + * spill. Restore materializes only the rows that Phase 3.5 will re-insert, + * in the existing 200-row batches. + */ +import { closeSync, openSync, readSync, unlinkSync, writeSync } from 'node:fs'; +import { AsyncLocalStorage } from 'node:async_hooks'; +import { randomBytes } from 'node:crypto'; +import os from 'node:os'; +import path from 'node:path'; +import type { CachedEmbedding } from './types.js'; + +/** In-RAM vector copies stay below this row count; larger tables use the spill. */ +export const DEFAULT_EMBEDDING_CACHE_IN_MEMORY_ROW_LIMIT = 2048; + +const SPILL_MAGIC = 'GNXE'; +const SPILL_VERSION = 1; +const SPILL_HEADER_BYTES = 12; + +export interface CachedEmbeddingMeta { + nodeId: string; + chunkIndex: number; + startLine: number; + endLine: number; + contentHash?: string; + /** Row order in the spill file (and in `embeddings` when in-memory). */ + vectorIndex: number; +} + +export interface EmbeddingVectorSpill { + path: string; + dims: number; + rowCount: number; +} + +export interface CachedEmbeddingsSnapshot { + embeddingNodeIds: Set; + /** Populated only when the table is at or under the in-memory row limit. */ + embeddings: CachedEmbedding[]; + rows: CachedEmbeddingMeta[]; + spill?: EmbeddingVectorSpill; +} + +export interface LoadCachedEmbeddingsOptions { + /** + * Keep full `number[]` vectors in RAM at or below this many rows. + * `0` always spills. Default {@link DEFAULT_EMBEDDING_CACHE_IN_MEMORY_ROW_LIMIT} + * or `GITNEXUS_EMBEDDING_CACHE_IN_MEMORY_LIMIT`. + */ + inMemoryRowLimit?: number; + /** Directory for the spill file (default `os.tmpdir()`). */ + spillDir?: string; +} + +export interface CachedEmbeddingsBuilder { + embeddingNodeIds: Set; + rows: CachedEmbeddingMeta[]; + /** Float32 vectors kept in RAM until the in-memory row limit is exceeded. */ + inMemory: Float32Array[] | null; + inMemoryRowLimit: number; + writer: EmbeddingSpillWriter; +} + +export function emptyCachedEmbeddingsSnapshot(): CachedEmbeddingsSnapshot { + return { embeddingNodeIds: new Set(), embeddings: [], rows: [] }; +} + +export function resolveEmbeddingCacheInMemoryRowLimit(override?: number): number { + if (override !== undefined) { + if (!Number.isFinite(override) || override < 0) { + return DEFAULT_EMBEDDING_CACHE_IN_MEMORY_ROW_LIMIT; + } + return Math.floor(override); + } + const raw = process.env.GITNEXUS_EMBEDDING_CACHE_IN_MEMORY_LIMIT; + if (raw === undefined || raw === '') return DEFAULT_EMBEDDING_CACHE_IN_MEMORY_ROW_LIMIT; + const parsed = parseInt(raw, 10); + return Number.isFinite(parsed) && parsed >= 0 + ? parsed + : DEFAULT_EMBEDDING_CACHE_IN_MEMORY_ROW_LIMIT; +} + +export function normalizeCachedEmbeddings(raw: { + embeddingNodeIds?: Set; + embeddings?: CachedEmbedding[]; + rows?: CachedEmbeddingMeta[]; + spill?: EmbeddingVectorSpill; +}): CachedEmbeddingsSnapshot { + const embeddings = raw.embeddings ?? []; + const embeddingNodeIds = raw.embeddingNodeIds ?? new Set(embeddings.map((row) => row.nodeId)); + const rows = + raw.rows ?? + embeddings.map((row, vectorIndex) => ({ + nodeId: row.nodeId, + chunkIndex: row.chunkIndex, + startLine: row.startLine, + endLine: row.endLine, + contentHash: row.contentHash, + vectorIndex, + })); + return { embeddingNodeIds, embeddings, rows, spill: raw.spill }; +} + +export function cacheRowCount(snapshot: CachedEmbeddingsSnapshot): number { + return snapshot.rows.length > 0 ? snapshot.rows.length : snapshot.embeddings.length; +} + +export function snapshotEmbeddingDims(snapshot: CachedEmbeddingsSnapshot): number | undefined { + if (snapshot.spill && snapshot.spill.dims > 0) return snapshot.spill.dims; + const dims = snapshot.embeddings[0]?.embedding.length; + return dims && dims > 0 ? dims : undefined; +} + +export function coerceEmbeddingToFloat32(embedding: unknown): Float32Array | null { + if (embedding == null) return null; + if (embedding instanceof Float32Array) { + return embedding.length > 0 ? embedding : null; + } + if (ArrayBuffer.isView(embedding) && !(embedding instanceof DataView)) { + const view = embedding as Exclude & { length: number }; + if (view.length === 0) return null; + return Float32Array.from({ length: view.length }, (_, i) => Number(view[i])); + } + if ( + typeof embedding === 'object' && + typeof (embedding as Iterable)[Symbol.iterator] === 'function' + ) { + const arr = Array.isArray(embedding) + ? (embedding as unknown[]) + : Array.from(embedding as Iterable); + if (arr.length === 0) return null; + return Float32Array.from(arr, (value) => Number(value)); + } + return null; +} + +export function float32ToNumberArray(vec: Float32Array): number[] { + const out = new Array(vec.length); + for (let i = 0; i < vec.length; i++) out[i] = vec[i]!; + return out; +} + +/** + * `fs.writeSync` can return a short byte count. Loop until the whole buffer + * lands, matching `sync-csv-writer.ts`, so a partial write never advances + * `rowCount` on a truncated vector. + */ +function writeAllSync(fd: number, data: Uint8Array): void { + let offset = 0; + while (offset < data.length) { + const n = writeSync(fd, data, offset, data.length - offset); + if (n <= 0) { + throw new Error(`embedding spill short write: wrote ${n} of ${data.length - offset} bytes`); + } + offset += n; + } +} + +function unlinkBestEffort(filePath: string): void { + try { + unlinkSync(filePath); + } catch { + /* ENOENT or already removed */ + } +} + +const liveSpillPaths = new Set(); +const spillScope = new AsyncLocalStorage>(); +let spillExitHookInstalled = false; + +function trackLiveSpillPath(filePath: string): void { + liveSpillPaths.add(filePath); + spillScope.getStore()?.add(filePath); + if (!spillExitHookInstalled) { + spillExitHookInstalled = true; + process.on('exit', () => { + for (const spillPath of liveSpillPaths) { + unlinkBestEffort(spillPath); + } + }); + } +} + +function untrackLiveSpillPath(filePath: string): void { + liveSpillPaths.delete(filePath); +} + +/** Best-effort unlink of every tracked spill. Safe to call more than once. */ +export function discardLiveEmbeddingSpills(): void { + for (const spillPath of [...liveSpillPaths]) { + unlinkBestEffort(spillPath); + liveSpillPaths.delete(spillPath); + } +} + +/** Run `fn` so later {@link discardScopedEmbeddingSpills} only unlinks this run. */ +export function withEmbeddingSpillScope(fn: () => T): T { + return spillScope.run(new Set(), fn); +} + +/** Unlink spills created inside the current {@link withEmbeddingSpillScope}. */ +export function discardScopedEmbeddingSpills(): void { + const owned = spillScope.getStore(); + if (!owned) return; + for (const spillPath of [...owned]) { + unlinkBestEffort(spillPath); + liveSpillPaths.delete(spillPath); + owned.delete(spillPath); + } +} + +export class EmbeddingSpillWriter { + readonly path: string; + dims = 0; + rowCount = 0; + private fd: number | null = null; + private closed = false; + + constructor(dir: string) { + this.path = path.join( + dir, + `gitnexus-embed-restore-${process.pid}-${randomBytes(8).toString('hex')}.bin`, + ); + } + + append(vec: Float32Array): void { + if (this.closed) { + throw new Error('embedding spill writer already closed'); + } + if (this.fd === null) { + this.dims = vec.length; + this.fd = openSync(this.path, 'wx', 0o600); + trackLiveSpillPath(this.path); + const header = Buffer.alloc(SPILL_HEADER_BYTES); + header.write(SPILL_MAGIC, 0, 4, 'ascii'); + header.writeUInt8(SPILL_VERSION, 4); + header.writeUInt32LE(this.dims, 5); + writeAllSync(this.fd, header); + } else if (vec.length !== this.dims) { + throw new Error( + `embedding dim mismatch while spilling: got ${vec.length}, expected ${this.dims}`, + ); + } + writeAllSync(this.fd, Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength)); + this.rowCount++; + } + + finish(): EmbeddingVectorSpill | undefined { + if (this.fd !== null) { + closeSync(this.fd); + this.fd = null; + } + this.closed = true; + if (this.rowCount === 0) { + this.unlinkQuiet(); + return undefined; + } + return { path: this.path, dims: this.dims, rowCount: this.rowCount }; + } + + abort(): void { + const opened = this.fd !== null; + if (this.fd !== null) { + try { + closeSync(this.fd); + } catch { + /* already closed */ + } + this.fd = null; + } + this.closed = true; + if (opened || this.rowCount > 0) { + this.unlinkQuiet(); + } + } + + private unlinkQuiet(): void { + unlinkBestEffort(this.path); + untrackLiveSpillPath(this.path); + } +} + +/** Validates the spill header once and reads vectors without reopening the file. */ +export class EmbeddingSpillReader { + private fd: number | null = null; + private readonly bytesPerVec: number; + readonly dims: number; + readonly rowCount: number; + + constructor(spill: EmbeddingVectorSpill) { + this.rowCount = spill.rowCount; + const fd = openSync(spill.path, 'r'); + try { + const header = Buffer.alloc(SPILL_HEADER_BYTES); + const headerRead = readSync(fd, header, 0, SPILL_HEADER_BYTES, 0); + if (headerRead !== SPILL_HEADER_BYTES || header.toString('ascii', 0, 4) !== SPILL_MAGIC) { + throw new Error(`invalid embedding spill header: ${spill.path}`); + } + if (header.readUInt8(4) !== SPILL_VERSION) { + throw new Error(`unsupported embedding spill version in ${spill.path}`); + } + const dims = header.readUInt32LE(5); + if (dims !== spill.dims) { + throw new Error(`embedding spill dim mismatch: file ${dims}, expected ${spill.dims}`); + } + this.dims = dims; + this.bytesPerVec = dims * 4; + this.fd = fd; + } catch (err) { + closeSync(fd); + throw err; + } + } + + read(indices: readonly number[]): Float32Array[] { + if (this.fd === null) { + throw new Error('embedding spill reader already closed'); + } + const out: Float32Array[] = []; + for (const index of indices) { + if (!Number.isInteger(index) || index < 0 || index >= this.rowCount) { + throw new Error(`embedding spill index out of range: ${index}`); + } + const offset = SPILL_HEADER_BYTES + index * this.bytesPerVec; + const copy = new Float32Array(this.dims); + const bytes = new Uint8Array(copy.buffer, copy.byteOffset, this.bytesPerVec); + const n = readSync(this.fd, bytes, 0, this.bytesPerVec, offset); + if (n !== this.bytesPerVec) { + throw new Error(`short embedding spill read at index ${index}`); + } + out.push(copy); + } + return out; + } + + close(): void { + if (this.fd === null) return; + closeSync(this.fd); + this.fd = null; + } +} + +export function readSpillVectors( + spill: EmbeddingVectorSpill, + indices: readonly number[], +): Float32Array[] { + const reader = new EmbeddingSpillReader(spill); + try { + return reader.read(indices); + } finally { + reader.close(); + } +} + +export function disposeEmbeddingSpill(spill?: EmbeddingVectorSpill): void { + if (!spill?.path) return; + unlinkBestEffort(spill.path); + untrackLiveSpillPath(spill.path); +} + +export function createCachedEmbeddingsBuilder( + options?: LoadCachedEmbeddingsOptions, +): CachedEmbeddingsBuilder { + const inMemoryRowLimit = resolveEmbeddingCacheInMemoryRowLimit(options?.inMemoryRowLimit); + return { + embeddingNodeIds: new Set(), + rows: [], + inMemory: inMemoryRowLimit <= 0 ? null : [], + inMemoryRowLimit, + writer: new EmbeddingSpillWriter(options?.spillDir ?? os.tmpdir()), + }; +} + +export function ingestCachedEmbeddingRow( + builder: CachedEmbeddingsBuilder, + row: Record | unknown[], + hasContentHash: boolean, +): void { + const rec = row as Record & unknown[]; + const nodeId = String(rec.nodeId ?? rec[0] ?? ''); + if (!nodeId) return; + const embedding = rec.embedding ?? rec[4]; + const f32 = coerceEmbeddingToFloat32(embedding); + if (!f32) return; + + builder.embeddingNodeIds.add(nodeId); + const meta: CachedEmbeddingMeta = { + nodeId, + chunkIndex: Number(rec.chunkIndex ?? rec[1] ?? 0), + startLine: Number(rec.startLine ?? rec[2] ?? 0), + endLine: Number(rec.endLine ?? rec[3] ?? 0), + contentHash: hasContentHash + ? ((rec.contentHash ?? rec[5] ?? undefined) as string | undefined) + : undefined, + vectorIndex: builder.rows.length, + }; + builder.rows.push(meta); + + if (builder.inMemory && builder.rows.length <= builder.inMemoryRowLimit) { + builder.inMemory.push(f32); + return; + } + + if (builder.inMemory) { + for (const prior of builder.inMemory) { + builder.writer.append(prior); + } + builder.inMemory = null; + } + builder.writer.append(f32); +} + +export function finalizeCachedEmbeddingsSnapshot( + builder: CachedEmbeddingsBuilder, +): CachedEmbeddingsSnapshot { + const inMemory = builder.inMemory; + if (inMemory) { + builder.writer.abort(); + return { + embeddingNodeIds: builder.embeddingNodeIds, + embeddings: builder.rows.map((meta, i) => ({ + nodeId: meta.nodeId, + chunkIndex: meta.chunkIndex, + startLine: meta.startLine, + endLine: meta.endLine, + contentHash: meta.contentHash, + embedding: float32ToNumberArray(inMemory[i]!), + })), + rows: builder.rows, + }; + } + return { + embeddingNodeIds: builder.embeddingNodeIds, + embeddings: [], + rows: builder.rows, + spill: builder.writer.finish(), + }; +} + +export function abortCachedEmbeddingsBuilder(builder: CachedEmbeddingsBuilder): void { + builder.writer.abort(); +} + +export function materializeCachedEmbeddings( + snapshot: CachedEmbeddingsSnapshot, + metas: readonly CachedEmbeddingMeta[], + spillReader?: EmbeddingSpillReader, +): CachedEmbedding[] { + if (metas.length === 0) return []; + if (snapshot.spill && snapshot.embeddings.length === 0) { + const indices = metas.map((meta) => meta.vectorIndex); + const vectors = spillReader + ? spillReader.read(indices) + : readSpillVectors(snapshot.spill, indices); + return metas.map((meta, i) => ({ + nodeId: meta.nodeId, + chunkIndex: meta.chunkIndex, + startLine: meta.startLine, + endLine: meta.endLine, + contentHash: meta.contentHash, + embedding: float32ToNumberArray(vectors[i]!), + })); + } + if (snapshot.embeddings.length === 0) return []; + const byKey = new Map( + snapshot.embeddings.map((row) => [`${row.nodeId}:${row.chunkIndex}`, row] as const), + ); + return metas.map((meta) => { + const hit = + byKey.get(`${meta.nodeId}:${meta.chunkIndex}`) ?? snapshot.embeddings[meta.vectorIndex]; + if (!hit) { + throw new Error(`missing cached embedding ${meta.nodeId}:${meta.chunkIndex}`); + } + return hit; + }); +} diff --git a/gitnexus/src/core/git-ref.ts b/gitnexus/src/core/git-ref.ts index e7d101eed..e712dd3a2 100644 --- a/gitnexus/src/core/git-ref.ts +++ b/gitnexus/src/core/git-ref.ts @@ -1,10 +1,10 @@ /** * Git ref-name validation used by both the CLI and the HTTP analyze route. * - * Lives in `core/` so `server/api.ts` does not import `cli/analyze-config` - * (that import closed a cli → server → cli cycle: `cli/serve.ts` already - * imports `createServer`). The CLI keeps a thin wrapper that rethrows - * {@link InvalidBranchError} as `GitNexusRcError`. + * Lives in `core/` so `server/api.ts` and `run-analyze.ts` do not import + * `cli/analyze-config` (that import closed a cli → server → cli cycle: + * `cli/serve.ts` already imports `createServer`). The CLI keeps a thin + * wrapper that rethrows {@link InvalidBranchError} as `GitNexusRcError`. */ /** Git refs longer than this are almost certainly a mistake / injection attempt. */ @@ -126,3 +126,49 @@ export function validateBranchName(value: string, source: string): string { } return trimmed; } + +/** + * Best-effort validation for an auto-detected branch (from git). Returns the + * trimmed name, or `undefined` for anything unusable so callers fall back to + * the next precedence tier or leave the index unlabeled. Swallows + * {@link InvalidBranchError} only; unexpected errors are rethrown. + */ +export function sanitizeDetectedBranch(value: string | null | undefined): string | undefined { + if (!value) return undefined; + try { + return validateBranchName(value, 'detected branch'); + } catch (err) { + if (err instanceof InvalidBranchError) return undefined; + throw err; + } +} + +/** + * Render a rejected checkout name for `onLog`. Hidden / bidi / control code + * points become `\uXXXX` so a git-legal U+202E name cannot reverse the + * warning in a terminal. C1 controls (U+0080–U+009F, including NEL U+0085) + * and remaining Unicode whitespace (`/\s/` — NBSP, U+2028/U+2029, ideographic + * space, etc.) are not all in {@link isHiddenOrControl}; escape them the same + * way so the ASCII escape survives `stripControlCharacters` and the warning + * stays one line. ASCII `"` is escaped; other characters (including backticks) + * stay visible. + */ +export function formatRejectedBranchForLog(value: string): string { + const shouldEscapeRejectedBranchChar = (cp: number, ch: string): boolean => + isHiddenOrControl(cp) || (cp >= 0x80 && cp <= 0x9f) || /\s/.test(ch); + + let out = ''; + for (const ch of value) { + const cp = ch.codePointAt(0); + if (cp !== undefined && shouldEscapeRejectedBranchChar(cp, ch)) { + out += `\\u${cp.toString(16).padStart(4, '0')}`; + continue; + } + if (ch === '"') { + out += '\\"'; + continue; + } + out += ch; + } + return out; +} diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/swift.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/swift.ts index 05e4b13be..67e88dd32 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/configs/swift.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/swift.ts @@ -13,18 +13,22 @@ * every strategy invocation, per `import-processor`'s build-once * context). Lookup per import is then O(1). * - * Behavior is preserved bit-for-bit: a file is attributed to a target - * iff its **forward-slash (backslash-normalized), case-sensitive** path - * starts with `/`, matching the old - * `normalizedFileList[i].startsWith(targetDir + '/')` comparison - * (`normalizedFileList` is only backslash→forward-slash normalized — NOT - * lowercased — so the match is case-sensitive); the returned paths are - * the original-case `allFileList` entries; and the per-target file ORDER - * follows `allFileList`, so the emitted `{ kind: 'files', files }` set and - * ordering are identical to the old scan. + * A file is attributed to a target iff its **forward-slash + * (backslash-normalized), case-sensitive** path matches at a + * **segment boundary**: it starts with `/` or contains + * `//`. `normalizedFileList` is + * only backslash→forward-slash normalized — NOT lowercased — so the + * match is case-sensitive. The returned paths are the original-case + * `allFileList` entries, and the per-target file ORDER follows + * `allFileList`. + * + * Import-config fans a file to every matching declared target. Grouping + * (`groupSwiftFilesBySpmTarget`) is first-target-wins. That divergence + * is intentional. */ import { SupportedLanguages } from 'gitnexus-shared'; +import { coerceDeclaredSwiftTargets, swiftDeclaredTargetPrefix } from '../../language-config.js'; import type { ImportResolutionConfig, ImportResolverStrategy, ResolveCtx } from '../types.js'; interface SwiftTargetIndex { @@ -66,10 +70,10 @@ function getSwiftTargetIndex( // Pre-compute each target's directory prefix once (original case, to // match the legacy comparison against the forward-slash-normalized, // case-sensitive file list — see module docstring). - const targetPrefixes: { name: string; prefix: string }[] = []; + const targetDirs: { name: string; prefix: string }[] = []; const byTarget = new Map(); for (const [name, dir] of targets) { - targetPrefixes.push({ name, prefix: dir + '/' }); + targetDirs.push({ name, prefix: swiftDeclaredTargetPrefix(dir) }); byTarget.set(name, []); } @@ -82,10 +86,10 @@ function getSwiftTargetIndex( for (let i = 0; i < ctx.allFileList.length; i++) { const norm = ctx.normalizedFileList[i]; if (!norm.endsWith('.swift')) continue; - for (const { name, prefix } of targetPrefixes) { - if (norm.startsWith(prefix)) { - byTarget.get(name)!.push(ctx.allFileList[i]); - } + for (const { name, prefix } of targetDirs) { + if (!norm.startsWith(prefix) && !norm.includes(`/${prefix}`)) continue; + const bucket = byTarget.get(name); + if (bucket !== undefined) bucket.push(ctx.allFileList[i]); } } @@ -97,19 +101,19 @@ function getSwiftTargetIndex( /** Swift Package.swift target map resolution strategy. */ export const swiftPackageStrategy: ImportResolverStrategy = (rawImportPath, _filePath, ctx) => { const swiftPackageConfig = ctx.configs.swiftPackageConfig; - if (swiftPackageConfig) { - // Only the targets map is needed; build the index lazily so repos - // without a Package.swift config pay nothing. - if (swiftPackageConfig.targets.has(rawImportPath)) { - const index = getSwiftTargetIndex(ctx, swiftPackageConfig.targets); - const files = index.byTarget.get(rawImportPath); - if (files !== undefined && files.length > 0) { - // Copy so callers can't mutate the cached index bucket. - return { kind: 'files', files: [...files] }; - } - } + if (swiftPackageConfig == null) return null; + const declared = coerceDeclaredSwiftTargets(swiftPackageConfig); + if (declared == null) return null; + const moduleName = rawImportPath.split('.')[0]; + if (moduleName === '' || !declared.has(moduleName)) { + return null; } - return null; // External framework (Foundation, UIKit, etc.) + const index = getSwiftTargetIndex(ctx, declared); + const files = index.byTarget.get(moduleName); + if (files !== undefined && files.length > 0) { + return { kind: 'files', files: [...files] }; + } + return null; }; export const swiftImportConfig: ImportResolutionConfig = { diff --git a/gitnexus/src/core/ingestion/language-config.ts b/gitnexus/src/core/ingestion/language-config.ts index 390e6a32f..759eee48f 100644 --- a/gitnexus/src/core/ingestion/language-config.ts +++ b/gitnexus/src/core/ingestion/language-config.ts @@ -175,9 +175,55 @@ export function csharpScanToEvidence(scan: CSharpProjectScan): CSharpNamespaceEv } /** Swift Package Manager module config */ +export type SwiftPackageConfigOrigin = 'package.swift' | 'directories'; + export interface SwiftPackageConfig { /** Map of target name -> source directory path (e.g., "SiuperModel" -> "Package/Sources/SiuperModel") */ targets: Map; + /** + * `package.swift` — extracted from a readable Package.swift with no + * completeness hazards. Explicit import resolve may treat this as a + * declaration map (empty means every name is external). + * `directories` — inferred from `Sources/*` (or Package/Sources / src) + * when no usable declaration exists. Grouping uses this; import resolve + * must not. + * Omitted on hand-built test configs: treated as a declaration map so + * existing `{ targets }` fixtures stay valid. + */ + origin?: SwiftPackageConfigOrigin; + /** + * Declaration map when `origin` is `package.swift` (may be empty). + * Grouping uses `targets`, which is this map when it is non-empty and the + * inferred `Sources/*` map when the declaration is empty — so a + * binary-only Package.swift does not collapse every file into `__default__`. + */ + declaredTargets?: Map; +} + +/** + * Declaration view for explicit import resolve. `origin: 'directories'` + * is grouping-only. A hand-built `{ targets }` with no origin stays a + * declaration so existing fixtures keep working. + */ +export function coerceDeclaredSwiftTargets( + resolutionConfig: unknown, +): ReadonlyMap | null { + const config = resolutionConfig as Partial | null | undefined; + if (config == null) return null; + if (config.origin === 'directories') return null; + if (config.declaredTargets instanceof Map) return config.declaredTargets; + if (config.targets instanceof Map) return config.targets; + return null; +} + +/** Segment-boundary prefix for a Package.swift `path:`. `"."` / `"./"` is the package root. */ +export function swiftDeclaredTargetPrefix(dir: string): string { + let norm = dir.replace(/\\/g, '/'); + while (norm.startsWith('./')) { + norm = norm.slice(2); + } + norm = norm.replace(/\/+$/, ''); + return norm === '' || norm === '.' ? '' : `${norm}/`; } /** Zig package config parsed from build.zig.zon and the root build.zig */ @@ -616,12 +662,768 @@ async function collectDeclaredNamespaces( return structure.incomplete ? 'truncated' : 'ok'; } -export async function loadSwiftPackageConfig(repoRoot: string): Promise { - // Swift imports are module-name based (e.g., `import SiuperModel`) - // SPM convention: Sources// or Package/Sources// - // We scan for these directories to build a target map - const targets = new Map(); +const SWIFT_SOURCE_FACTORY_NAMES = ['target', 'executableTarget', 'testTarget', 'macro'] as const; +const SWIFT_SKIP_FACTORY_NAMES = ['binaryTarget', 'plugin', 'systemLibrary'] as const; +const SWIFT_SKIP_FACTORIES = new Set(SWIFT_SKIP_FACTORY_NAMES); +const SWIFT_FACTORY_RE = new RegExp( + `\\.(${[...SWIFT_SOURCE_FACTORY_NAMES, ...SWIFT_SKIP_FACTORY_NAMES].join('|')})\\s*\\(`, + 'g', +); +function extractBalancedParen(source: string, openIndex: number): string | null { + let depth = 0; + let inString: '"' | "'" | null = null; + let escape = false; + let inLineComment = false; + let blockCommentDepth = 0; + for (let i = openIndex; i < source.length; i++) { + const ch = source[i]; + const next = source[i + 1]; + if (inLineComment) { + if (ch === '\n') inLineComment = false; + continue; + } + if (blockCommentDepth > 0) { + if (ch === '*' && next === '/') { + blockCommentDepth--; + i++; + } else if (ch === '/' && next === '*') { + blockCommentDepth++; + i++; + } + continue; + } + if (inString !== null) { + if (escape) { + escape = false; + continue; + } + if (ch === '\\') { + escape = true; + continue; + } + if (ch === inString) inString = null; + continue; + } + if (ch === '"' || ch === "'") { + inString = ch; + continue; + } + if (ch === '/' && next === '/') { + // `https://` lives inside a string, already excluded above. + inLineComment = true; + i++; + continue; + } else if (ch === '/' && next === '*') { + blockCommentDepth = 1; + i++; + continue; + } + if (ch === '(') depth++; + else if (ch === ')') { + depth--; + if (depth === 0) return source.slice(openIndex + 1, i); + } + } + return null; +} +function isSwiftIdentCont(ch: string | undefined): boolean { + return ch !== undefined && /[A-Za-z0-9_]/.test(ch); +} + +function skipSwiftWsAndComments(source: string, start: number): number | null { + let i = start; + while (i < source.length) { + const ch = source[i]; + const next = source[i + 1]; + if (/\s/.test(ch)) { + i++; + continue; + } + if (ch === '/' && next === '/') { + const nl = source.indexOf('\n', i + 2); + if (nl === -1) return null; + i = nl + 1; + continue; + } + if (ch === '/' && next === '*') { + let depth = 1; + i += 2; + while (i < source.length && depth > 0) { + if (source[i] === '/' && source[i + 1] === '*') { + depth++; + i += 2; + } else if (source[i] === '*' && source[i + 1] === '/') { + depth--; + i += 2; + } else { + i++; + } + } + if (depth !== 0) return null; + continue; + } + return i; + } + return null; +} + +/** First `name:` / `path:` string outside comments. Escapes and interpolations are unreadable. */ +function readSwiftFactoryField( + block: string, + field: 'name' | 'path', +): { value: string | undefined; keyPresent: boolean } { + let inString: '"' | "'" | null = null; + let escape = false; + let inLineComment = false; + let blockCommentDepth = 0; + for (let i = 0; i < block.length; i++) { + const ch = block[i]; + const next = block[i + 1]; + if (inLineComment) { + if (ch === '\n') inLineComment = false; + continue; + } + if (blockCommentDepth > 0) { + if (ch === '*' && next === '/') { + blockCommentDepth--; + i++; + } else if (ch === '/' && next === '*') { + blockCommentDepth++; + i++; + } + continue; + } + if (inString !== null) { + if (escape) { + escape = false; + continue; + } + if (ch === '\\') { + escape = true; + continue; + } + if (ch === inString) inString = null; + continue; + } + if (ch === '"' || ch === "'") { + inString = ch; + continue; + } + if (ch === '/' && next === '/') { + inLineComment = true; + i++; + continue; + } + if (ch === '/' && next === '*') { + blockCommentDepth = 1; + i++; + continue; + } + if (!/[A-Za-z_]/.test(ch)) continue; + let j = i + 1; + while (j < block.length && isSwiftIdentCont(block[j])) j++; + if (block.slice(i, j) !== field) { + i = j - 1; + continue; + } + const colonAt = skipSwiftWsAndComments(block, j); + if (colonAt === null || block[colonAt] !== ':') { + i = j - 1; + continue; + } + const valueAt = skipSwiftWsAndComments(block, colonAt + 1); + if (valueAt === null) return { value: undefined, keyPresent: true }; + const quote = block[valueAt]; + if (quote !== '"' && quote !== "'") return { value: undefined, keyPresent: true }; + const parsed = readSwiftSimpleQuotedString(block, valueAt); + if (parsed === null) return { value: undefined, keyPresent: true }; + return { value: parsed, keyPresent: true }; + } + return { value: undefined, keyPresent: false }; +} + +/** Quoted literal with no escapes. Any `\` (including `\u{…}` and `\(`) is unreadable. */ +function readSwiftSimpleQuotedString(source: string, openIndex: number): string | null { + const quote = source[openIndex]; + let i = openIndex + 1; + while (i < source.length) { + const ch = source[i]; + if (ch === '\\') return null; + if (ch === quote) return source.slice(openIndex + 1, i); + if (ch === '\n') return null; + i++; + } + return null; +} + +function swiftManifestHasCompletenessHazard(source: string): boolean { + let inString: '"' | "'" | null = null; + let escape = false; + let inLineComment = false; + let blockCommentDepth = 0; + let atLineStart = true; + for (let i = 0; i < source.length; i++) { + const ch = source[i]; + const next = source[i + 1]; + if (inLineComment) { + if (ch === '\n') { + inLineComment = false; + atLineStart = true; + } + continue; + } + if (blockCommentDepth > 0) { + if (ch === '*' && next === '/') { + blockCommentDepth--; + i++; + } else if (ch === '/' && next === '*') { + blockCommentDepth++; + i++; + } else if (ch === '\n') { + atLineStart = true; + } + continue; + } + if (inString !== null) { + if (escape) { + escape = false; + continue; + } + if (ch === '\\') { + escape = true; + continue; + } + if (ch === inString) inString = null; + else if (ch === '\n') atLineStart = true; + continue; + } + if (ch === '"' || ch === "'") { + inString = ch; + atLineStart = false; + continue; + } + if (ch === '/' && next === '/') { + inLineComment = true; + i++; + atLineStart = false; + continue; + } + if (ch === '/' && next === '*') { + blockCommentDepth = 1; + i++; + atLineStart = false; + continue; + } + if (ch === '\n') { + atLineStart = true; + continue; + } + if (atLineStart && /\s/.test(ch)) continue; + if (atLineStart && ch === '#') { + if (source.startsWith('if', i + 1) && !isSwiftIdentCont(source[i + 3])) return true; + if (source.startsWith('elseif', i + 1) && !isSwiftIdentCont(source[i + 7])) return true; + } + atLineStart = false; + } + return false; +} + +interface SwiftCommentScan { + i: number; + inString: '"' | "'" | null; + escape: boolean; + inLineComment: boolean; + blockCommentDepth: number; +} + +function newSwiftCommentScan(): SwiftCommentScan { + return { i: 0, inString: null, escape: false, inLineComment: false, blockCommentDepth: 0 }; +} + +/** Resume the comment/string walk up to `upTo`. Matches are left-to-right, so this is O(n) over the file. */ +function advanceSwiftCommentScan(source: string, state: SwiftCommentScan, upTo: number): void { + let { i, inString, escape, inLineComment, blockCommentDepth } = state; + for (; i < upTo; i++) { + const ch = source[i]; + const next = source[i + 1]; + if (inLineComment) { + if (ch === '\n') inLineComment = false; + continue; + } + if (blockCommentDepth > 0) { + if (ch === '*' && next === '/') { + blockCommentDepth--; + i++; + } else if (ch === '/' && next === '*') { + blockCommentDepth++; + i++; + } + continue; + } + if (inString !== null) { + if (escape) { + escape = false; + continue; + } + if (ch === '\\') { + escape = true; + continue; + } + if (ch === inString) inString = null; + continue; + } + if (ch === '"' || ch === "'") { + inString = ch; + continue; + } + if (ch === '/' && next === '/') { + inLineComment = true; + i++; + continue; + } + if (ch === '/' && next === '*') { + blockCommentDepth = 1; + i++; + } + } + state.i = i; + state.inString = inString; + state.escape = escape; + state.inLineComment = inLineComment; + state.blockCommentDepth = blockCommentDepth; +} + +function swiftPathIsUnreadable(customPath: string | undefined, hasPathKey: boolean): boolean { + if (!hasPathKey) return false; + return customPath === undefined || customPath === '' || customPath.includes('\\('); +} + +/** Heuristic Package.swift scan. Never shells out to `swift package dump-package`. */ +export function parseSwiftPackageManifest(source: string): { + targets: Map; + complete: boolean; +} { + const targets = new Map(); + if (swiftManifestHasCompletenessHazard(source)) { + return { targets, complete: false }; + } + + const packageTargets = inspectSwiftPackageTargets(source); + + SWIFT_FACTORY_RE.lastIndex = 0; + let match: RegExpExecArray | null; + let sawUnreadableFactory = false; + const commentScan = newSwiftCommentScan(); + let coveredEnd = -1; + while ((match = SWIFT_FACTORY_RE.exec(source)) !== null) { + advanceSwiftCommentScan(source, commentScan, match.index); + if ( + commentScan.inLineComment || + commentScan.blockCommentDepth > 0 || + commentScan.inString !== null + ) { + continue; + } + if ( + packageTargets.sawPackage && + !packageTargets.arraySpans.some(([lo, hi]) => match.index >= lo && match.index <= hi) + ) { + continue; + } + if (match.index > 0 && match.index < coveredEnd) continue; + const kind = match[1]; + const paren = source.indexOf('(', match.index); + const block = extractBalancedParen(source, paren); + if (block === null) { + sawUnreadableFactory = true; + continue; + } + coveredEnd = Math.max(coveredEnd, paren + 1 + block.length + 1); + if (SWIFT_SKIP_FACTORIES.has(kind)) continue; + const nameField = readSwiftFactoryField(block, 'name'); + if (nameField.value === undefined || nameField.value === '') { + sawUnreadableFactory = true; + continue; + } + const name = nameField.value; + const pathField = readSwiftFactoryField(block, 'path'); + const customPath = pathField.value; + if (swiftPathIsUnreadable(customPath, pathField.keyPresent)) { + sawUnreadableFactory = true; + continue; + } + const dir = customPath ?? (kind === 'testTarget' ? `Tests/${name}` : `Sources/${name}`); + const existing = targets.get(name); + if (existing === undefined) { + targets.set(name, dir); + } else if (customPath !== undefined && existing === `Sources/${name}`) { + // A later `.target(name:path:)` wins over an earlier same-name + // factory that only implied the default path. + targets.set(name, customPath); + } + } + + return { + targets, + complete: !sawUnreadableFactory && !packageTargets.helperBuilt, + }; +} + +interface SwiftPackageTargetsInspection { + helperBuilt: boolean; + sawPackage: boolean; + arraySpans: Array<[number, number]>; +} + +const SWIFT_ALL_FACTORY_NAMES = new Set([ + ...SWIFT_SOURCE_FACTORY_NAMES, + ...SWIFT_SKIP_FACTORY_NAMES, +]); + +/** Locate `Package(...)`'s `targets:` argument. Product `targets:` stay nested. */ +function inspectSwiftPackageTargets(source: string): SwiftPackageTargetsInspection { + const arraySpans: Array<[number, number]> = []; + let helperBuilt = false; + const seen = { package: false }; + const unreadable = forEachSwiftPackageArgs( + source, + (args, argsStart) => { + const found = inspectPackageTargetsArg(args); + if (found.helperBuilt) { + helperBuilt = true; + return true; + } + if (found.arrayStart !== null && found.arrayEnd !== null) { + arraySpans.push([argsStart + found.arrayStart, argsStart + found.arrayEnd]); + } + return false; + }, + seen, + ); + return { helperBuilt: helperBuilt || unreadable, sawPackage: seen.package, arraySpans }; +} + +/** Walk `Package(` calls outside comments/strings. Unclosed `Package(` is incomplete. */ +function forEachSwiftPackageArgs( + source: string, + visit: (args: string, argsStart: number) => boolean, + seen: { package: boolean }, +): boolean { + let inString: '"' | "'" | null = null; + let escape = false; + let inLineComment = false; + let blockCommentDepth = 0; + for (let i = 0; i < source.length; i++) { + const ch = source[i]; + const next = source[i + 1]; + if (inLineComment) { + if (ch === '\n') inLineComment = false; + continue; + } + if (blockCommentDepth > 0) { + if (ch === '*' && next === '/') { + blockCommentDepth--; + i++; + } else if (ch === '/' && next === '*') { + blockCommentDepth++; + i++; + } + continue; + } + if (inString !== null) { + if (escape) { + escape = false; + continue; + } + if (ch === '\\') { + escape = true; + continue; + } + if (ch === inString) inString = null; + continue; + } + if (ch === '"' || ch === "'") { + inString = ch; + continue; + } + if (ch === '/' && next === '/') { + inLineComment = true; + i++; + continue; + } + if (ch === '/' && next === '*') { + blockCommentDepth = 1; + i++; + continue; + } + if ( + !source.startsWith('Package', i) || + isSwiftIdentCont(source[i + 7]) || + (i > 0 && isSwiftIdentCont(source[i - 1])) + ) { + continue; + } + const parenAt = skipSwiftWsAndComments(source, i + 7); + if (parenAt === null || source[parenAt] !== '(') continue; + seen.package = true; + const args = extractBalancedParen(source, parenAt); + if (args === null) return true; + if (visit(args, parenAt + 1)) return true; + i = parenAt + args.length + 1; + } + return false; +} + +function inspectPackageTargetsArg(args: string): { + helperBuilt: boolean; + arrayStart: number | null; + arrayEnd: number | null; +} { + let inString: '"' | "'" | null = null; + let escape = false; + let inLineComment = false; + let blockCommentDepth = 0; + let paren = 0; + for (let i = 0; i < args.length; i++) { + const ch = args[i]; + const next = args[i + 1]; + if (inLineComment) { + if (ch === '\n') inLineComment = false; + continue; + } + if (blockCommentDepth > 0) { + if (ch === '*' && next === '/') { + blockCommentDepth--; + i++; + } else if (ch === '/' && next === '*') { + blockCommentDepth++; + i++; + } + continue; + } + if (inString !== null) { + if (escape) { + escape = false; + continue; + } + if (ch === '\\') { + escape = true; + continue; + } + if (ch === inString) inString = null; + continue; + } + if (ch === '"' || ch === "'") { + inString = ch; + continue; + } + if (ch === '/' && next === '/') { + inLineComment = true; + i++; + continue; + } + if (ch === '/' && next === '*') { + blockCommentDepth = 1; + i++; + continue; + } + if (ch === '(') { + paren++; + continue; + } + if (ch === ')') { + paren--; + continue; + } + if (paren !== 0) continue; + if ( + !args.startsWith('targets', i) || + isSwiftIdentCont(args[i + 7]) || + (i > 0 && isSwiftIdentCont(args[i - 1])) + ) { + continue; + } + const colonAt = skipSwiftWsAndComments(args, i + 7); + if (colonAt === null || args[colonAt] !== ':') { + i += 6; + continue; + } + return classifyPackageTargetsValue(args, colonAt + 1); + } + return { helperBuilt: false, arrayStart: null, arrayEnd: null }; +} + +function classifyPackageTargetsValue( + args: string, + afterColon: number, +): { + helperBuilt: boolean; + arrayStart: number | null; + arrayEnd: number | null; +} { + const start = skipSwiftWsAndComments(args, afterColon); + if (start === null) return { helperBuilt: true, arrayStart: null, arrayEnd: null }; + if (args[start] === '[') { + const close = matchSwiftSquare(args, start); + if (close === null) return { helperBuilt: true, arrayStart: null, arrayEnd: null }; + const next = skipSwiftWsAndComments(args, close + 1); + if (next !== null && args[next] === '+') { + return { helperBuilt: true, arrayStart: start, arrayEnd: close }; + } + if (packageTargetsArrayHasComputed(args, start, close)) { + return { helperBuilt: true, arrayStart: start, arrayEnd: close }; + } + return { helperBuilt: false, arrayStart: start, arrayEnd: close }; + } + return { helperBuilt: true, arrayStart: null, arrayEnd: null }; +} + +function packageTargetsArrayHasComputed(source: string, open: number, close: number): boolean { + let inString: '"' | "'" | null = null; + let escape = false; + let inLineComment = false; + let blockCommentDepth = 0; + let paren = 0; + let bracket = 0; + for (let i = open; i < close; i++) { + const ch = source[i]; + const next = source[i + 1]; + if (inLineComment) { + if (ch === '\n') inLineComment = false; + continue; + } + if (blockCommentDepth > 0) { + if (ch === '*' && next === '/') { + blockCommentDepth--; + i++; + } else if (ch === '/' && next === '*') { + blockCommentDepth++; + i++; + } + continue; + } + if (inString !== null) { + if (escape) { + escape = false; + continue; + } + if (ch === '\\') { + escape = true; + continue; + } + if (ch === inString) inString = null; + continue; + } + if (ch === '"' || ch === "'") { + inString = ch; + continue; + } + if (ch === '/' && next === '/') { + inLineComment = true; + i++; + continue; + } + if (ch === '/' && next === '*') { + blockCommentDepth = 1; + i++; + continue; + } + if (ch === '[') { + bracket++; + continue; + } + if (ch === ']') { + bracket--; + continue; + } + if (ch === '(') { + paren++; + continue; + } + if (ch === ')') { + paren--; + continue; + } + if (bracket !== 1 || paren !== 0) continue; + if (ch === ',' || /\s/.test(ch)) continue; + if (ch === '.') { + let j = i + 1; + while (j < close && isSwiftIdentCont(source[j])) j++; + const name = source.slice(i + 1, j); + const after = skipSwiftWsAndComments(source, j); + if (after !== null && source[after] === '(' && SWIFT_ALL_FACTORY_NAMES.has(name)) { + const block = extractBalancedParen(source, after); + if (block === null) return true; + i = after + block.length + 1; + continue; + } + return true; + } + return true; + } + return false; +} + +function matchSwiftSquare(source: string, openIndex: number): number | null { + let depth = 0; + let inString: '"' | "'" | null = null; + let escape = false; + let inLineComment = false; + let blockCommentDepth = 0; + for (let i = openIndex; i < source.length; i++) { + const ch = source[i]; + const next = source[i + 1]; + if (inLineComment) { + if (ch === '\n') inLineComment = false; + continue; + } + if (blockCommentDepth > 0) { + if (ch === '*' && next === '/') { + blockCommentDepth--; + i++; + } else if (ch === '/' && next === '*') { + blockCommentDepth++; + i++; + } + continue; + } + if (inString !== null) { + if (escape) { + escape = false; + continue; + } + if (ch === '\\') { + escape = true; + continue; + } + if (ch === inString) inString = null; + continue; + } + if (ch === '"' || ch === "'") { + inString = ch; + continue; + } + if (ch === '/' && next === '/') { + inLineComment = true; + i++; + continue; + } else if (ch === '/' && next === '*') { + blockCommentDepth = 1; + i++; + continue; + } + if (ch === '[') depth++; + else if (ch === ']') { + depth--; + if (depth === 0) return i; + } + } + return null; +} + +async function inferSwiftDirectoryTargets(repoRoot: string): Promise> { + const targets = new Map(); const sourceDirs = ['Sources', 'Package/Sources', 'src']; for (const sourceDir of sourceDirs) { try { @@ -636,12 +1438,41 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise 0) { - if (isDev) { - logger.info(`📦 Loaded ${targets.size} Swift package targets`); +export async function loadSwiftPackageConfig(repoRoot: string): Promise { + try { + const source = await fs.readFile(path.join(repoRoot, 'Package.swift'), 'utf-8'); + const parsed = parseSwiftPackageManifest(source); + if (parsed.complete) { + if (isDev) { + logger.info(`📦 Loaded ${parsed.targets.size} Swift package targets from Package.swift`); + } + if (parsed.targets.size > 0) { + return { + targets: parsed.targets, + origin: 'package.swift', + declaredTargets: parsed.targets, + }; + } + const inferred = await inferSwiftDirectoryTargets(repoRoot); + return { + targets: inferred, + origin: 'package.swift', + declaredTargets: parsed.targets, + }; } - return { targets }; + } catch { + // Missing or unreadable — fall through to inferred folders. + } + + const inferred = await inferSwiftDirectoryTargets(repoRoot); + if (inferred.size > 0) { + if (isDev) { + logger.info(`📦 Inferred ${inferred.size} Swift source folders`); + } + return { targets: inferred, origin: 'directories' }; } return null; } diff --git a/gitnexus/src/core/ingestion/languages/swift/captures.ts b/gitnexus/src/core/ingestion/languages/swift/captures.ts index 736c6e962..56f165b21 100644 --- a/gitnexus/src/core/ingestion/languages/swift/captures.ts +++ b/gitnexus/src/core/ingestion/languages/swift/captures.ts @@ -48,6 +48,7 @@ import { synthesizeSwiftReceiverBinding, } from './receiver-binding.js'; import { synthesizeSwiftSignatureBindings } from './signature-bindings.js'; +import { swiftMethodConfig } from '../../method-extractors/configs/swift.js'; import { getSwiftParser, getSwiftScopeQuery } from './query.js'; import { preprocessSwiftConditionalDirectives } from './conditional-directive-preprocess.js'; import { recordCacheHit, recordCacheMiss } from './cache-stats.js'; @@ -198,6 +199,26 @@ export function emitSwiftScopeCaptures( continue; } + // The query deliberately recognizes the compact `lhs = call()` shape; + // enforce "untyped lhs" here because tree-sitter queries cannot express + // absence of Swift's sibling type_annotation robustly. A typed declaration + // remains authoritative and must never enter return-type replay. + if (grouped['@call-result-assignment.call'] !== undefined) { + const callNode = nodeIfType(nodeMap['@call-result-assignment.call'], 'call_expression'); + let property = callNode?.parent; + while (property?.type === 'await_expression' || property?.type === 'try_expression') { + property = property.parent; + } + if ( + property?.type !== 'property_declaration' || + property.namedChildren.some((child) => child.type === 'type_annotation') + ) { + continue; + } + out.push(grouped); + continue; + } + // ── Field accesses: a `navigation_expression` (`obj.field`) is one of // three things. Drop it when it's a call's callee (`u.save` in // `u.save()` — the @reference.call.member query already covers that). @@ -314,7 +335,17 @@ export function emitSwiftScopeCaptures( nodeMap['@declaration.constructor'], ...FUNCTION_NODE_TYPES, ); - if (fnNodeForArity !== null) attachArityMetadata(grouped, fnNodeForArity); + if (fnNodeForArity !== null) { + attachArityMetadata(grouped, fnNodeForArity); + const returnType = swiftMethodConfig.extractReturnType?.(fnNodeForArity); + if (returnType !== undefined && returnType !== '') { + grouped['@declaration.return-type'] = syntheticCapture( + '@declaration.return-type', + fnNodeForArity, + returnType, + ); + } + } // Structural receiver chain for a call whose receiver is itself an // expression, so resolution can type it by folding over structure // instead of re-parsing the receiver's source text. Self-gating: a @@ -338,7 +369,17 @@ export function emitSwiftScopeCaptures( const declTag = FUNCTION_DECL_TAGS.find((t) => grouped[t] !== undefined); if (declTag !== undefined) { const fnNode = nodeIfType(nodeMap[declTag], ...FUNCTION_NODE_TYPES); - if (fnNode !== null) attachArityMetadata(grouped, fnNode); + if (fnNode !== null) { + attachArityMetadata(grouped, fnNode); + const returnType = swiftMethodConfig.extractReturnType?.(fnNode); + if (returnType !== undefined && returnType !== '') { + grouped['@declaration.return-type'] = syntheticCapture( + '@declaration.return-type', + fnNode, + returnType, + ); + } + } } // ── Constructor calls: Swift has no `new`, so `Foo()` is a free call diff --git a/gitnexus/src/core/ingestion/languages/swift/implicit-imports.ts b/gitnexus/src/core/ingestion/languages/swift/implicit-imports.ts index a850045b8..b11f416cf 100644 --- a/gitnexus/src/core/ingestion/languages/swift/implicit-imports.ts +++ b/gitnexus/src/core/ingestion/languages/swift/implicit-imports.ts @@ -14,12 +14,15 @@ * Module identity: Swift has no in-source `package X` marker. Module * membership is the SPM target *subtree* (`Sources//…`), threaded * in via the SPM target map (`resolutionConfig` → `coerceSwiftTargets`) - * and grouped by `groupSwiftFilesBySpmTarget` — replicating legacy - * `groupSwiftFilesByTarget`. With no scanned source dir the map is null + * and grouped by `groupSwiftFilesBySpmTarget`. The helper preserves the + * legacy `groupSwiftFilesByTarget` bucketing contract for ordinary layouts + * while intentionally fixing #2931's repeated-prefix edge case. With no + * target map (no Package.swift and no scanned `Sources/*`) the map is null * and all files form one `__default__` module (single-Xcode-project - * assumption). Every pair of distinct `.swift` files in the same module - * gets a directed IMPORTS edge in both directions (whole-module - * visibility is symmetric). + * assumption). An inferred folder map must keep sibling folders isolated. + * Every pair of distinct `.swift` + * files in the same module gets a directed IMPORTS edge in both directions + * (whole-module visibility is symmetric). * * Node identity + edge construction mirror the generic `emitImportEdges` * convention (`graph-bridge/imports-to-edges.ts`): `generateId('File', path)` @@ -41,8 +44,6 @@ export function emitSwiftImplicitImportEdges( _nodeLookup: GraphNodeLookup, resolutionConfig?: unknown, ): void { - // Group files by SPM target subtree (the module). No-source-dir → all - // files in one `__default__` bucket. const targets = coerceSwiftTargets(resolutionConfig); const filesByTarget = groupSwiftFilesBySpmTarget( parsedFiles, @@ -51,16 +52,15 @@ export function emitSwiftImplicitImportEdges( ); for (const [, group] of filesByTarget) { - if (group.length < 2) continue; // no siblings to import + if (group.length < 2) continue; for (const source of group) { - for (const target of group) { - if (source.filePath === target.filePath) continue; // no self-import - const dedupKey = `${source.filePath}->${target.filePath}`; - + for (const dest of group) { + if (source.filePath === dest.filePath) continue; + const dedupKey = `${source.filePath}->${dest.filePath}`; graph.addRelationship({ id: generateId('IMPORTS', dedupKey), sourceId: generateId('File', source.filePath), - targetId: generateId('File', target.filePath), + targetId: generateId('File', dest.filePath), type: 'IMPORTS', confidence: 1.0, reason: 'swift-scope: implicit module visibility', diff --git a/gitnexus/src/core/ingestion/languages/swift/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/swift/import-decomposer.ts index b0cdfdd9c..a1fc03d2c 100644 --- a/gitnexus/src/core/ingestion/languages/swift/import-decomposer.ts +++ b/gitnexus/src/core/ingestion/languages/swift/import-decomposer.ts @@ -4,32 +4,30 @@ * `@import.name` / `@import.testable` that `interpretSwiftImport` * consumes. * - * Swift imports are whole-module (no named members), so this is 1:1 — - * one `import` produces exactly one import. The split layer exposes the - * module name and the `@testable` flag without pushing raw-text parsing - * into `interpret.ts`. + * import Foundation → kind=namespace, source=Foundation + * import Foo.Bar → kind=namespace, source=Foo + * import struct Foo.Bar → kind=named, source=Foo, name=Bar + * @testable import MyApp → kind=namespace, source=MyApp, testable=1 + * @_exported import Foo → kind=reexport, source=Foo, name=Foo + * @_exported import struct Foo.Bar → kind=reexport, source=Foo, name=Bar * - * import Foundation → kind=namespace, source=Foundation - * import Foo.Bar → kind=namespace, source=Foo (SPM target), - * name=Foo.Bar (full path, for reference) - * @testable import MyApp → kind=namespace, source=MyApp, testable=1 - * - * Verified against tree-sitter-swift 0.7.1: - * (import_declaration - * (modifiers (attribute (user_type (type_identifier))))? ; @testable / @_exported - * (identifier (simple_identifier)+)) ; one per dotted segment + * Import-kind (`struct`/`class`/…) is not a named tree-sitter child + * (hidden `_import_kind` in 0.7.1). Read it from the statement text. + * `@_exported` / `@testable` live on `modifiers`. */ import type { Capture, CaptureMatch } from 'gitnexus-shared'; import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; +const IMPORT_KIND_TOKEN_RE = /^(struct|class|enum|protocol|func|let|var|typealias)\b/; + interface SwiftImportSpec { - /** SPM target name — the first dotted segment (`Foo` in `import Foo.Bar`). */ readonly source: string; - /** Full dotted module path (`Foo.Bar`). */ + readonly memberName: string; readonly fullPath: string; - /** True for `@testable import` (test-scope visibility; resolves identically). */ readonly testable: boolean; + readonly exported: boolean; + readonly importKind: string | null; readonly atNode: SyntaxNode; } @@ -42,14 +40,15 @@ export function splitSwiftImport(stmtNode: SyntaxNode): CaptureMatch | null { function parseSwiftImport(node: SyntaxNode): SwiftImportSpec | null { let testable = false; + let exported = false; let identifierNode: SyntaxNode | null = null; for (let i = 0; i < node.namedChildCount; i++) { const child = node.namedChild(i); if (child === null) continue; if (child.type === 'modifiers') { - // Any attribute whose text mentions `testable` flips the flag. - if (/\btestable\b/.test(child.text)) testable = true; + if (swiftModifiersHaveAttribute(child.text, 'testable')) testable = true; + if (swiftModifiersHaveAttribute(child.text, '_exported')) exported = true; } else if (child.type === 'identifier') { identifierNode = child; } @@ -57,36 +56,219 @@ function parseSwiftImport(node: SyntaxNode): SwiftImportSpec | null { if (identifierNode === null) return null; - // The module path is one or more simple_identifier children, one per - // dotted segment. The SPM target is the FIRST segment. + const importKind = importKindFromClause(node, identifierNode); + const segments: string[] = []; for (let i = 0; i < identifierNode.namedChildCount; i++) { const seg = identifierNode.namedChild(i); if (seg !== null && seg.type === 'simple_identifier') segments.push(seg.text); } if (segments.length === 0) { - // Fall back to the raw identifier text (e.g. a grammar shape we didn't - // anticipate). Split on `.` to recover the target segment. const raw = identifierNode.text.trim(); if (raw === '') return null; - const parts = raw.split('.'); - return { source: parts[0], fullPath: raw, testable, atNode: node }; + segments.push(...raw.split('.')); } return { source: segments[0], + memberName: segments.length > 1 ? segments[segments.length - 1] : segments[0], fullPath: segments.join('.'), testable, + exported, + importKind, atNode: node, }; } +function isSwiftIdentCont(ch: string | undefined): boolean { + return ch !== undefined && /[A-Za-z0-9_]/.test(ch); +} + +/** First `word` outside comments and strings. Linear scan. */ +function indexOfBareWord(text: string, word: string): number { + let inString: '"' | "'" | null = null; + let escape = false; + let inLineComment = false; + let blockCommentDepth = 0; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + const next = text[i + 1]; + if (inLineComment) { + if (ch === '\n') inLineComment = false; + continue; + } + if (blockCommentDepth > 0) { + if (ch === '*' && next === '/') { + blockCommentDepth--; + i++; + } else if (ch === '/' && next === '*') { + blockCommentDepth++; + i++; + } + continue; + } + if (inString !== null) { + if (escape) { + escape = false; + continue; + } + if (ch === '\\') { + escape = true; + continue; + } + if (ch === inString) inString = null; + continue; + } + if (ch === '"' || ch === "'") { + inString = ch; + continue; + } + if (ch === '/' && next === '/') { + inLineComment = true; + i++; + continue; + } + if (ch === '/' && next === '*') { + blockCommentDepth = 1; + i++; + continue; + } + if ( + text.startsWith(word, i) && + !isSwiftIdentCont(text[i + word.length]) && + (i === 0 || !isSwiftIdentCont(text[i - 1])) + ) { + return i; + } + } + return -1; +} + +/** `@name` token in modifier text — not `_exported` / `testable` inside a string. */ +function swiftModifiersHaveAttribute(text: string, name: 'testable' | '_exported'): boolean { + let inString: '"' | "'" | null = null; + let escape = false; + let inLineComment = false; + let blockCommentDepth = 0; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + const next = text[i + 1]; + if (inLineComment) { + if (ch === '\n') inLineComment = false; + continue; + } + if (blockCommentDepth > 0) { + if (ch === '*' && next === '/') { + blockCommentDepth--; + i++; + } else if (ch === '/' && next === '*') { + blockCommentDepth++; + i++; + } + continue; + } + if (inString !== null) { + if (escape) { + escape = false; + continue; + } + if (ch === '\\') { + escape = true; + continue; + } + if (ch === inString) inString = null; + continue; + } + if (ch === '"' || ch === "'") { + inString = ch; + continue; + } + if (ch === '/' && next === '/') { + inLineComment = true; + i++; + continue; + } + if (ch === '/' && next === '*') { + blockCommentDepth = 1; + i++; + continue; + } + if (ch !== '@') continue; + let j = i + 1; + while (j < text.length && /\s/.test(text[j])) j++; + if (text.startsWith(name, j) && !isSwiftIdentCont(text[j + name.length])) return true; + } + return false; +} + +/** Kind token from the import clause only — skip `@available(..., message: "import struct")`. */ +function importKindFromClause(node: SyntaxNode, identifierNode: SyntaxNode): string | null { + const identRel = identifierNode.startIndex - node.startIndex; + const before = identRel >= 0 ? node.text.slice(0, identRel) : node.text; + const importAt = indexOfBareWord(before, 'import'); + const clause = importAt === -1 ? before : before.slice(importAt); + return kindAfterImportKeyword(clause); +} + +/** After `import`, skip whitespace and comments, then read a kind token. Linear: no nested-quantifier backtracking. */ +function kindAfterImportKeyword(clause: string): string | null { + const start = indexOfBareWord(clause, 'import'); + if (start === -1) return null; + let i = start + 'import'.length; + let skipped = false; + while (i < clause.length) { + const ch = clause[i]; + const next = clause[i + 1]; + if (/\s/.test(ch)) { + skipped = true; + i += 1; + continue; + } + if (ch === '/' && next === '/') { + const nl = clause.indexOf('\n', i + 2); + if (nl === -1) return null; + skipped = true; + i = nl + 1; + continue; + } + if (ch === '/' && next === '*') { + let depth = 1; + i += 2; + while (i < clause.length && depth > 0) { + if (clause[i] === '/' && clause[i + 1] === '*') { + depth++; + i += 2; + } else if (clause[i] === '*' && clause[i + 1] === '/') { + depth--; + i += 2; + } else { + i++; + } + } + if (depth !== 0) return null; + skipped = true; + continue; + } + break; + } + if (!skipped) return null; + return IMPORT_KIND_TOKEN_RE.exec(clause.slice(i))?.[1] ?? null; +} + +function bindingKind(spec: SwiftImportSpec): 'namespace' | 'named' | 'reexport' { + if (spec.exported) return 'reexport'; + if (spec.importKind !== null && spec.fullPath.includes('.')) return 'named'; + return 'namespace'; +} + function buildImportMatch(stmtNode: SyntaxNode, spec: SwiftImportSpec): CaptureMatch { + const kind = bindingKind(spec); + const nameText = kind === 'namespace' ? spec.fullPath : spec.memberName; const m: Record = { '@import.statement': nodeToCapture('@import.statement', stmtNode), - '@import.kind': syntheticCapture('@import.kind', spec.atNode, 'namespace'), + '@import.kind': syntheticCapture('@import.kind', spec.atNode, kind), '@import.source': syntheticCapture('@import.source', spec.atNode, spec.source), - '@import.name': syntheticCapture('@import.name', spec.atNode, spec.fullPath), + '@import.name': syntheticCapture('@import.name', spec.atNode, nameText), }; if (spec.testable) { m['@import.testable'] = syntheticCapture('@import.testable', spec.atNode, '1'); diff --git a/gitnexus/src/core/ingestion/languages/swift/import-target.ts b/gitnexus/src/core/ingestion/languages/swift/import-target.ts index e0d5e8219..603f2e6f7 100644 --- a/gitnexus/src/core/ingestion/languages/swift/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/swift/import-target.ts @@ -1,37 +1,27 @@ /** * `resolveImportTarget` adapter for the Swift `ScopeResolver`. * - * Swift's `import ModuleName` brings in a whole SPM target / framework - * module. The scope-resolution contract passes only `allFilePaths` (no - * `SwiftPackageConfig`), so we resolve a module name to the `.swift` - * files under a directory segment named after the module — the SPM - * convention `Sources//*.swift` (and the common - * `/*.swift` layout). This needs no manifest parsing. + * A Package.swift declaration map (`origin: 'package.swift'`) resolves + * only declared target names. Otherwise refuse well-known SDK module + * names and fall back to the memoized directory-segment index so local + * folder modules still resolve without a manifest. * - * Same-module (intra-target) visibility — the bulk of Swift cross-file - * resolution, which needs NO `import` statement — is handled separately - * by `populateSwiftTargetSiblings` (see `target-siblings.ts`). This - * adapter only resolves EXPLICIT `import` statements (cross-module). - * - * Returns all matching files (one ImportEdge per file, like Go's - * package resolver) so every exported symbol in the module materializes - * a binding. Returns `null` for external frameworks (Foundation, UIKit, - * …) that have no in-repo directory. - * - * Performance: the directory→files grouping is memoized on the stable - * `allFilePaths` Set identity (the same Set is threaded to every import - * in a run), so it is built once per run — NOT once per import. Mirrors - * Python's `getPythonFileIndex` WeakMap pattern (PR #1918). + * Same-module visibility without `import` is `populateSwiftTargetSiblings`. + * This adapter only resolves EXPLICIT cross-module `import`s. */ -import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; +import type { ParsedFile, ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; import { perFileSet } from '../../import-resolvers/per-file-set.js'; +import { coerceDeclaredSwiftTargets, swiftDeclaredTargetPrefix } from '../../language-config.js'; +import { isSwiftSdkModule } from './sdk-modules.js'; export interface SwiftResolveContext { readonly fromFile: string; /** `ReadonlySet` so the orchestrator's stable run-level set flows * straight through to the memoized index key. */ readonly allFilePaths: ReadonlySet; + readonly resolutionConfig?: unknown; + readonly parsedFiles?: readonly ParsedFile[]; } interface SwiftModuleIndex { @@ -40,16 +30,17 @@ interface SwiftModuleIndex { readonly byModule: Map; } +interface SwiftDeclaredFileIndex { + readonly declared: ReadonlyMap; + readonly byName: ReadonlyMap; +} + const getSwiftModuleIndex = perFileSet((allFilePaths: ReadonlySet): SwiftModuleIndex => { const byModule = new Map(); for (const raw of allFilePaths) { const norm = raw.replace(/\\/g, '/'); if (!norm.endsWith('.swift')) continue; - // Each interior directory segment is a candidate module name. A file - // `Sources/Models/User.swift` is attributed to module `Sources` and - // module `Models`; an `import Models` then resolves to it. const segments = norm.split('/'); - // Drop the filename (last segment); the rest are directory segments. for (let i = 0; i < segments.length - 1; i++) { const seg = segments[i]; if (seg === '') continue; @@ -65,12 +56,70 @@ const getSwiftModuleIndex = perFileSet((allFilePaths: ReadonlySet): Swif return { byModule }; }); -export function resolveSwiftImportTarget( - parsedImport: ParsedImport, - workspaceIndex: WorkspaceIndex, -): string | readonly string[] | null { +const SWIFT_DECLARED_INDEX = new WeakMap, SwiftDeclaredFileIndex>(); + +function getDeclaredFilesByName( + allFilePaths: ReadonlySet, + declared: ReadonlyMap, +): ReadonlyMap { + const hit = SWIFT_DECLARED_INDEX.get(allFilePaths); + if (hit !== undefined && hit.declared === declared) return hit.byName; + + const dirs = [...declared.entries()].map(([name, dir]) => ({ + name, + prefix: swiftDeclaredTargetPrefix(dir), + })); + const byName = new Map(); + for (const { name } of dirs) byName.set(name, []); + + for (const raw of allFilePaths) { + const norm = raw.replace(/\\/g, '/'); + if (!norm.endsWith('.swift')) continue; + for (const { name, prefix } of dirs) { + if (!norm.startsWith(prefix) && !norm.includes(`/${prefix}`)) continue; + const bucket = byName.get(name); + if (bucket !== undefined) bucket.push(raw); + } + } + + const index = { declared, byName }; + SWIFT_DECLARED_INDEX.set(allFilePaths, index); + return byName; +} + +const getSwiftReexportFlag = perFileSet( + (parsedFiles: readonly ParsedFile[]): { hasReexport: boolean } => { + for (const parsed of parsedFiles) { + for (const imp of parsed.parsedImports) { + if (imp.kind === 'reexport') return { hasReexport: true }; + } + } + return { hasReexport: false }; + }, +); + +const getSwiftParsedByPath = perFileSet( + (parsedFiles: readonly ParsedFile[]): ReadonlyMap => { + const byPath = new Map(); + for (const parsed of parsedFiles) { + byPath.set(parsed.filePath, parsed); + } + return byPath; + }, +); + +function excludeImporter(files: readonly string[], fromFile: string): string[] { + return files.filter((f) => f !== fromFile); +} + +function firstSwiftModuleSegment(targetRaw: string): string | null { + if (targetRaw === '') return null; + const moduleName = targetRaw.split('.')[0]; + return moduleName === '' ? null : moduleName; +} + +function narrowContext(workspaceIndex: WorkspaceIndex): SwiftResolveContext | null { const ctx = workspaceIndex as SwiftResolveContext | undefined; - // Duck-type the set (PR #1918 P2: don't `instanceof Set`). const allFilePaths = (ctx as { allFilePaths?: unknown } | undefined)?.allFilePaths; if ( ctx === undefined || @@ -80,18 +129,84 @@ export function resolveSwiftImportTarget( ) { return null; } + return ctx; +} - // Swift import target is the SPM module name (first dotted segment). - const targetRaw = parsedImport.targetRaw; - if (targetRaw === null || targetRaw === '') return null; - const moduleName = targetRaw.split('.')[0]; +/** Module files only — no @_exported closure. Null means external / unknown. */ +function resolveSwiftModuleFiles(moduleName: string, ctx: SwiftResolveContext): string[] | null { if (moduleName === '') return null; + const declared = coerceDeclaredSwiftTargets(ctx.resolutionConfig); + if (declared !== null) { + if (!declared.has(moduleName)) return null; + const files = getDeclaredFilesByName(ctx.allFilePaths, declared).get(moduleName); + if (files === undefined) return null; + const out = excludeImporter(files, ctx.fromFile); + return out.length > 0 ? out : null; + } + + if (isSwiftSdkModule(moduleName)) return null; + const index = getSwiftModuleIndex(ctx.allFilePaths); const files = index.byModule.get(moduleName); - if (files === undefined || files.length === 0) return null; // external framework - - // Exclude the importer itself (a file under `Foo/` importing `Foo`). - const out = files.filter((f) => f !== ctx.fromFile); + if (files === undefined || files.length === 0) return null; + const out = excludeImporter(files, ctx.fromFile); return out.length > 0 ? out : null; } + +function expandSwiftReexportFiles(seed: readonly string[], ctx: SwiftResolveContext): string[] { + const parsedFiles = ctx.parsedFiles; + if (parsedFiles === undefined || parsedFiles.length === 0) return [...seed]; + if (!getSwiftReexportFlag(parsedFiles).hasReexport) return [...seed]; + const byPath = getSwiftParsedByPath(parsedFiles); + + const seenModules = new Set(); + const out = new Set(seed); + const queue = [...seed]; + let head = 0; + + while (head < queue.length) { + const file = queue[head++]; + const parsed = byPath.get(file); + if (parsed === undefined) continue; + for (const imp of parsed.parsedImports) { + if (imp.kind !== 'reexport') continue; + const targetRaw = imp.targetRaw; + if (targetRaw === null) continue; + const moduleName = firstSwiftModuleSegment(targetRaw); + if (moduleName === null || seenModules.has(moduleName)) continue; + // `@_exported import struct Models.User` re-exports User, not Models. + // File-level resolve cannot attribute a member to a file, so skip + // the whole-module enqueue rather than painting every Models file. + if (imp.importedName !== moduleName) continue; + seenModules.add(moduleName); + const more = resolveSwiftModuleFiles(moduleName, ctx); + if (more === null) continue; + for (const next of more) { + if (out.has(next)) continue; + out.add(next); + queue.push(next); + } + } + } + + return [...out]; +} + +export function resolveSwiftImportTarget( + parsedImport: ParsedImport, + workspaceIndex: WorkspaceIndex, +): string | readonly string[] | null { + const ctx = narrowContext(workspaceIndex); + if (ctx === null) return null; + + const targetRaw = parsedImport.targetRaw; + if (targetRaw === null) return null; + const moduleName = firstSwiftModuleSegment(targetRaw); + if (moduleName === null) return null; + + const files = resolveSwiftModuleFiles(moduleName, ctx); + if (files === null) return null; + const expanded = expandSwiftReexportFiles(files, ctx); + return expanded.length > 0 ? expanded : null; +} diff --git a/gitnexus/src/core/ingestion/languages/swift/index.ts b/gitnexus/src/core/ingestion/languages/swift/index.ts index 4e4e7ef52..f0bcf5ad6 100644 --- a/gitnexus/src/core/ingestion/languages/swift/index.ts +++ b/gitnexus/src/core/ingestion/languages/swift/index.ts @@ -27,7 +27,12 @@ export { emitSwiftScopeCaptures } from './captures.js'; export { getSwiftCaptureCacheStats, resetSwiftCaptureCacheStats } from './cache-stats.js'; -export { interpretSwiftImport, interpretSwiftTypeBinding } from './interpret.js'; +export { + interpretSwiftImport, + interpretSwiftTypeBinding, + normalizeSwiftTypeName, + stripSwiftTypePreservingDecoration, +} from './interpret.js'; export { swiftMergeBindings } from './merge-bindings.js'; export { swiftArityCompatibility } from './arity.js'; export { resolveSwiftImportTarget, type SwiftResolveContext } from './import-target.js'; diff --git a/gitnexus/src/core/ingestion/languages/swift/interpret.ts b/gitnexus/src/core/ingestion/languages/swift/interpret.ts index 5536c4a1b..c7accca32 100644 --- a/gitnexus/src/core/ingestion/languages/swift/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/swift/interpret.ts @@ -19,17 +19,23 @@ export function interpretSwiftImport(captures: CaptureMatch): ParsedImport | nul const sourceCap = captures['@import.source']; if (sourceCap === undefined) return null; - // Swift imports are whole-module (wildcard semantics): `import Foundation` - // brings the entire module into scope, no named members. The SPM target - // (first dotted segment) is the resolution target; the full path is kept - // as importedName for reference. `@testable` resolves identically to a - // plain import (same module is visible in test scope). const source = sourceCap.text; - const fullPath = captures['@import.name']?.text ?? source; + const name = captures['@import.name']?.text ?? source; + const kindText = captures['@import.kind']?.text; + + if (kindText === 'reexport' || kindText === 'named') { + return { + kind: kindText, + localName: name, + importedName: name, + targetRaw: source, + }; + } + return { kind: 'namespace', localName: source, - importedName: fullPath, + importedName: name, targetRaw: source, }; } @@ -46,7 +52,7 @@ export function interpretSwiftTypeBinding(captures: CaptureMatch): ParsedTypeBin // `[User]` → User (array sugar) // `Array` / `Optional` → User (single-arg generic) // `Foundation.URL` → URL (qualifier) - const rawType = stripQualifier(stripGeneric(stripArraySugar(stripOptional(typeCap.text.trim())))); + const rawType = normalizeSwiftTypeName(typeCap.text); let source: TypeRef['source'] = 'parameter-annotation'; if (captures['@type-binding.self'] !== undefined) source = 'self'; @@ -58,6 +64,22 @@ export function interpretSwiftTypeBinding(captures: CaptureMatch): ParsedTypeBin return { boundName: nameCap.text, rawTypeName: rawType, source }; } +export function normalizeSwiftTypeName(text: string): string { + return stripQualifier(stripGeneric(stripArraySugar(stripOptional(text.trim())))); +} + +/** + * Type-preserving decoration only — used by `stripTypePreservingDecoration` + * for class lookup and declared-return replay. `User?` / `User!` → `User`. + * Arrays, generics, and nested `Foo.Bar` are left intact so a binding used + * for member lookup does not follow the element type or the trailing ident. + */ +export function stripSwiftTypePreservingDecoration(typeName: string): string | undefined { + const trimmed = typeName.trim(); + if (trimmed.endsWith('?') || trimmed.endsWith('!')) return trimmed.slice(0, -1).trim(); + return undefined; +} + /** `User?` / `User!` → `User`. */ function stripOptional(text: string): string { if (text.endsWith('?') || text.endsWith('!')) return text.slice(0, -1).trim(); diff --git a/gitnexus/src/core/ingestion/languages/swift/query.ts b/gitnexus/src/core/ingestion/languages/swift/query.ts index 1d50be2df..805329903 100644 --- a/gitnexus/src/core/ingestion/languages/swift/query.ts +++ b/gitnexus/src/core/ingestion/languages/swift/query.ts @@ -188,6 +188,32 @@ const SWIFT_SCOPE_QUERY = ` (call_expression (simple_identifier) @reference.name) @reference.call.free +;; Exact identity for \`let lhs = callee()\`. The call-expression anchor is +;; byte-identical to @reference.call.free / member for downstream position join. +(property_declaration + name: (pattern + bound_identifier: (simple_identifier) @call-result-assignment.lhs) + value: (call_expression) @call-result-assignment.call) + +(property_declaration + name: (pattern + bound_identifier: (simple_identifier) @call-result-assignment.lhs) + value: (await_expression + (call_expression) @call-result-assignment.call)) + +(property_declaration + name: (pattern + bound_identifier: (simple_identifier) @call-result-assignment.lhs) + value: (try_expression + (call_expression) @call-result-assignment.call)) + +(property_declaration + name: (pattern + bound_identifier: (simple_identifier) @call-result-assignment.lhs) + value: (try_expression + (await_expression + (call_expression) @call-result-assignment.call))) + ;; ── References — member / method calls: \`obj.method(...)\` ─────────── ;; navigation_expression carries the receiver (target:) and the member ;; (suffix > navigation_suffix > simple_identifier). \`self\` is a diff --git a/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts index 83a8e6ce7..f0475f1cb 100644 --- a/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/swift/scope-resolver.ts @@ -14,7 +14,8 @@ * re-keys an `extension Foo { … }` to a `class_declaration`-style def * named `Foo`, so its members land on `Foo`'s scope and the shared * `populateClassOwnedMembers` stamps them with `Foo`'s ownerId — the - * same mechanism C# uses for `partial class`. No separate hoist pass. + * same mechanism C# uses for `partial class`. Cross-file extensions + * that mint no type def are reconciled by `populateWorkspaceOwners`. * - **Labeled arguments** narrow by ARITY only (count-primary, labels * soft) — see `arity.ts`. Label-precise dispatch is deferred to the * type-binding layer. @@ -36,17 +37,18 @@ * Array where Element: Equatable`) are not narrowed — the `Self` * type of a protocol method resolves to the protocol, not the * conforming type. - * 2. **Cross-module `import` resolution** is still directory-segment - * based (`import Foo` → files under a `Foo/` dir); explicit imports do - * not yet consult the SPM target map (follow-up, tracked under #1935). - * Same-target visibility (the common case) IS SPM-target-subtree - * accurate — handled by sibling augmentation grouped via - * `groupSwiftFilesBySpmTarget`, not by explicit imports. + * 2. **Cross-module `import` resolution** uses a Package.swift + * declaration map when one is present, otherwise the directory-segment + * index minus well-known SDK module names (#2964). Same-target + * visibility (the common case) is SPM-target-subtree grouping via + * `groupSwiftFilesBySpmTarget`, not explicit imports. * 3. **Operator / subscript overloads** dispatch by name only. - * 4. **`@_exported import` re-exports** are treated as plain imports. + * 4. **`@_exported import`** is `ParsedImport` `kind: 'reexport'` and + * in-repo modules are closed transitively at resolve time. `public + * import` is not a re-export. */ -import type { ParsedFile } from 'gitnexus-shared'; +import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared'; import { SupportedLanguages } from 'gitnexus-shared'; import { loadSwiftPackageConfig } from '../../language-config.js'; import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; @@ -66,6 +68,8 @@ import { mirrorSwiftSiblingTypeBindings, type SwiftResolveContext, } from './index.js'; +import { stripSwiftTypePreservingDecoration } from './interpret.js'; +import { coerceSwiftTargets, groupSwiftFilesBySpmTarget } from './target-grouping.js'; import { swiftIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js'; const ZERO_RANGE = { startLine: 0, startCol: 0, endLine: 0, endCol: 0 } as const; @@ -84,12 +88,18 @@ const swiftScopeResolver: ScopeResolver = { // `goScopeResolver`'s `loadGoModulePath`. loadResolutionConfig: (repoPath: string) => loadSwiftPackageConfig(repoPath), - resolveImportTarget: (targetRaw, fromFile, allFilePaths) => { - const ws: SwiftResolveContext = { fromFile, allFilePaths }; + resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig, context) => { + const ws: SwiftResolveContext = { + fromFile, + allFilePaths, + resolutionConfig, + parsedFiles: context?.parsedFiles, + }; return resolveSwiftImportTarget( - interpretSwiftImport({ - '@import.source': { name: '@import.source', text: targetRaw, range: ZERO_RANGE }, - }) ?? { kind: 'namespace', localName: targetRaw, importedName: targetRaw, targetRaw }, + context?.parsedImport ?? + interpretSwiftImport({ + '@import.source': { name: '@import.source', text: targetRaw, range: ZERO_RANGE }, + }) ?? { kind: 'namespace', localName: targetRaw, importedName: targetRaw, targetRaw }, ws, ); }, @@ -101,6 +111,8 @@ const swiftScopeResolver: ScopeResolver = { arityCompatibility: (callsite, def) => swiftArityCompatibility(def, callsite), buildMro: (graph, parsedFiles, nodeLookup) => buildSwiftMro(graph, parsedFiles, nodeLookup), + implicitThisWalksMro: true, + stripTypePreservingDecoration: stripSwiftTypePreservingDecoration, // Methods/properties/init are owned by their enclosing class/struct/ // extension(→extended type)/protocol. Extension members hoist for free @@ -108,6 +120,13 @@ const swiftScopeResolver: ScopeResolver = { // the extended type. populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed), + // An extension has its own Class scope but deliberately does not mint a + // second type def. Its members therefore leave the per-file owner walk with + // a qualified name (`ExtendedType.member`) but no ownerId. Reconcile those + // members after all files are available so extensions declared in sibling + // files work as well as extensions beside the original type. + populateWorkspaceOwners: populateSwiftExtensionOwners, + // `super.method()` dispatches through the superclass chain. isSuperReceiver: (text) => text.trim() === 'super', @@ -209,6 +228,91 @@ function buildSwiftMro( return mro; } +function populateSwiftExtensionOwners( + parsedFiles: readonly ParsedFile[], + ctx?: { readonly fileContents: ReadonlyMap; readonly resolutionConfig?: unknown }, +): void { + const filesByTarget = groupSwiftFilesBySpmTarget( + parsedFiles, + (parsed) => parsed.filePath, + coerceSwiftTargets(ctx?.resolutionConfig), + ); + for (const files of filesByTarget.values()) { + stampSwiftExtensionOwnersInTarget(files); + } +} + +function stampSwiftExtensionOwnersInTarget(parsedFiles: readonly ParsedFile[]): void { + const ownersByName = new Map(); + for (const parsed of parsedFiles) { + for (const def of parsed.localDefs) { + if (!isClassLike(def.type) || def.qualifiedName === undefined) continue; + const bucket = ownersByName.get(def.qualifiedName); + if (bucket === undefined) ownersByName.set(def.qualifiedName, [def]); + else bucket.push(def); + } + } + + // Extension members are Function-owned defs whose parent Class minted no + // type def. Nested locals are Function-owned defs whose parent is another + // Function — leave those ownerless so they cannot enter implicit-self CALLS. + for (const parsed of parsedFiles) { + const byId = new Map(parsed.scopes.map((scope) => [scope.id, scope])); + for (const scope of parsed.scopes) { + if (scope.kind !== 'Function') continue; + const parent = scope.parent === null ? undefined : byId.get(scope.parent); + if (parent?.kind !== 'Class') continue; + // A type-decl Class owns the type that opened it (same start line). + // Nested types inside an `extension` are also class-like and live on + // that Class scope — they are not the extended type, so they must + // not suppress stamping `func added` onto `Foo`. + if (parent.ownedDefs.some((d) => isClassLike(d.type) && defDeclaresThisClassScope(parent, d))) + continue; + for (const def of scope.ownedDefs) { + if (def.ownerId !== undefined || def.qualifiedName === undefined) continue; + const dot = def.qualifiedName.lastIndexOf('.'); + if (dot <= 0) continue; + const owner = uniqueOwnerForExtensionPrefix(ownersByName, def.qualifiedName.slice(0, dot)); + if (owner !== undefined) { + (def as { ownerId?: string }).ownerId = owner.nodeId; + } + } + } + } +} + +function defDeclaresThisClassScope( + parent: { readonly range: { readonly startLine: number } }, + def: SymbolDefinition, +): boolean { + const line = /#(\d+):/.exec(def.nodeId); + if (line === null) return true; + return Number(line[1]) === parent.range.startLine; +} + +function uniqueOwnerForExtensionPrefix( + ownersByName: ReadonlyMap, + prefix: string, +): SymbolDefinition | undefined { + const seen = new Set(); + const matches: SymbolDefinition[] = []; + const consider = (defs: readonly SymbolDefinition[] | undefined): void => { + if (defs === undefined) return; + for (const def of defs) { + if (seen.has(def.nodeId)) continue; + seen.add(def.nodeId); + matches.push(def); + } + }; + consider(ownersByName.get(prefix)); + if (!prefix.includes('.')) { + for (const [qn, defs] of ownersByName) { + if (qn !== prefix && qn.endsWith('.' + prefix)) consider(defs); + } + } + return matches.length === 1 ? matches[0] : undefined; +} + function closeProtocols( seeds: readonly string[], directImpls: ReadonlyMap, diff --git a/gitnexus/src/core/ingestion/languages/swift/sdk-modules.ts b/gitnexus/src/core/ingestion/languages/swift/sdk-modules.ts new file mode 100644 index 000000000..8d7f052e5 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/swift/sdk-modules.ts @@ -0,0 +1,29 @@ +/** + * Well-known Apple / Swift SDK module names that must not bind to a + * same-named in-repo folder on the no-manifest path (#2964). + * + * Modeled on `CSHARP_EXTERNAL_ROOTS`. A Package.swift target that uses + * one of these names still wins — this set is only the inferred/null path. + */ +export const SWIFT_SDK_MODULES: ReadonlySet = new Set([ + 'Foundation', + 'UIKit', + 'SwiftUI', + 'AppKit', + 'Combine', + 'Dispatch', + 'Darwin', + 'ObjectiveC', + 'Swift', + 'CoreFoundation', + 'CoreData', + 'CoreGraphics', + 'XCTest', + 'Testing', + 'Observation', + 'os', +]); + +export function isSwiftSdkModule(name: string): boolean { + return SWIFT_SDK_MODULES.has(name); +} diff --git a/gitnexus/src/core/ingestion/languages/swift/target-grouping.ts b/gitnexus/src/core/ingestion/languages/swift/target-grouping.ts index 5f26d3595..a0572202c 100644 --- a/gitnexus/src/core/ingestion/languages/swift/target-grouping.ts +++ b/gitnexus/src/core/ingestion/languages/swift/target-grouping.ts @@ -9,36 +9,31 @@ * drops cross-directory same-module edges and can mis-resolve a * constructor call to a wrong same-simple-named type in another target. * - * This module duplicates the legacy `groupSwiftFilesByTarget` - * (`languages/swift.ts`) semantics **verbatim** so the registry-primary - * path matches legacy SPM-subtree grouping without touching the legacy - * pipeline (hard constraint: legacy stays byte-identical). The SPM target - * map is threaded in via the `resolutionConfig` channel + * The SPM target map is threaded in via the `resolutionConfig` channel * (`loadSwiftPackageConfig` → `resolutionConfig` → these hooks); see * `scope-resolver.ts` and `scope-resolution/pipeline/run.ts`. * - * NOTE: This intentionally differs from the import-config module's - * leading-`startsWith` (`import-resolvers/configs/swift.ts`): that module - * fans a file out to EVERY matching target (a nested file can belong to - * multiple configured target dirs there), whereas legacy module grouping - * assigns each file to the FIRST matching target only (legacy `break`s) — - * one bucket per file. Do not copy the import-config behavior here. + * Path matching is the same segment-boundary rule as import-config + * (`fileMatchesSwiftTargetDir`). Grouping assigns each file to the FIRST + * matching target; import-config fans a file out to every matching target. */ -import type { SwiftPackageConfig } from '../../language-config.js'; +import { swiftDeclaredTargetPrefix, type SwiftPackageConfig } from '../../language-config.js'; +export { coerceDeclaredSwiftTargets } from '../../language-config.js'; const DEFAULT_TARGET = '__default__'; /** - * Group `items` by SPM target subtree, replicating legacy - * `groupSwiftFilesByTarget` semantics exactly: + * Group `items` by SPM target subtree: * * - `targets` null/empty (no scanned source dir found) → ALL items go to * a single `__default__` bucket (single-Xcode-project assumption). * - Otherwise: a file matches a target when its normalized path either - * starts with `/` (`indexOf === 0`) OR contains it at a `/` - * boundary (`norm[idx - 1] === '/'`). Each file is assigned to the - * FIRST matching target only (one bucket per file, no fan-out). + * starts with `/` or contains `//` at a segment + * boundary. Using a segment-aware suffix search matters when an earlier, + * non-boundary occurrence of the same text appears in the path (#2931). + * - Each file is assigned to the FIRST matching target only (one bucket per + * file, no fan-out). * - Files matching no target fall into the `__default__` bucket. * * `targets` is `name → directory` (the `SwiftPackageConfig.targets` map). @@ -53,10 +48,9 @@ export function groupSwiftFilesBySpmTarget( return new Map([[DEFAULT_TARGET, [...items]]]); } - // Pre-convert target dirs to normalized prefix format once. const targetPrefixes = [...targets.entries()].map(([name, dir]) => ({ name, - prefix: dir.replace(/\\/g, '/') + '/', + prefix: swiftDeclaredTargetPrefix(dir), })); const groups = new Map(); @@ -67,8 +61,7 @@ export function groupSwiftFilesBySpmTarget( const normalized = rawPath.includes('\\') ? rawPath.replace(/\\/g, '/') : rawPath; let assigned = false; for (const { name, prefix } of targetPrefixes) { - const idx = normalized.indexOf(prefix); - if (idx === 0 || (idx > 0 && normalized[idx - 1] === '/')) { + if (pathMatchesTargetPrefix(normalized, prefix)) { let group = groups.get(name); if (group === undefined) { group = []; @@ -102,3 +95,13 @@ export function coerceSwiftTargets(resolutionConfig: unknown): ReadonlyMap/…`). Module membership * is threaded in via the SPM target map (`ctx.resolutionConfig` → - * `coerceSwiftTargets`) and grouped by `groupSwiftFilesBySpmTarget`, - * replicating legacy `wireSwiftImplicitImports`'s `groupSwiftFilesByTarget`: - * files are grouped by SPM target subtree when a package config is present, - * else ALL Swift files form one module (`__default__`, + * `coerceSwiftTargets`) and grouped by `groupSwiftFilesBySpmTarget`. + * The helper preserves the legacy `wireSwiftImplicitImports` bucketing + * contract for ordinary layouts (first target wins and unmatched files use + * `__default__`) but intentionally fixes #2931's repeated-prefix edge case. + * When a target map is present (declared Package.swift or inferred + * `Sources/*` folders), files are grouped by SPM target subtree; + * otherwise ALL Swift files form one module (`__default__`, * single-Xcode-project assumption). Every `.swift` file in the same target * sees its siblings' top-level defs. * @@ -23,8 +26,9 @@ * finalize and must not be mutated. */ -import type { BindingRef, ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import type { BindingRef, ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared'; import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { isClassLike } from '../../scope-resolution/scope/walkers.js'; import { coerceSwiftTargets, groupSwiftFilesBySpmTarget } from './target-grouping.js'; export function populateSwiftTargetSiblings( @@ -35,8 +39,6 @@ export function populateSwiftTargetSiblings( readonly resolutionConfig?: unknown; }, ): void { - // Group files by SPM target subtree (the module). No-source-dir → all - // files in one `__default__` bucket. const targets = coerceSwiftTargets(ctx.resolutionConfig); const filesByTarget = groupSwiftFilesBySpmTarget( parsedFiles, @@ -47,7 +49,8 @@ export function populateSwiftTargetSiblings( const augmentations = indexes.bindingAugmentations as Map>; for (const [, group] of filesByTarget) { - if (group.length < 2) continue; // no siblings to share + populateNestedTypeFragments(group, indexes, augmentations, ctx.fileContents); + if (group.length < 2) continue; // no file siblings to share const siblings = group.map((parsed) => ({ filePath: parsed.filePath, defs: [...parsed.localDefs] as SymbolDefinition[], @@ -59,17 +62,328 @@ export function populateSwiftTargetSiblings( if (receiverModule === undefined) continue; for (const def of target.defs) { - const name = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; - if (name === '') continue; - const bucket = getAugmentationBucket(augmentations, receiverModule, name); - if (bucket.some((b) => b.def.nodeId === def.nodeId)) continue; - bucket.push({ def, origin: 'namespace' }); + addNamespaceBinding(augmentations, receiverModule, def); } } } } } +/** + * A Swift extension is a second lexical fragment of its extended type. Make + * nested types declared by the primary fragment visible from every same-target + * fragment with the same logical owner. Keeping this on class scopes preserves + * lexical precedence when an unrelated top-level type has the same simple name. + */ +function populateNestedTypeFragments( + group: readonly ParsedFile[], + indexes: ScopeResolutionIndexes, + augmentations: Map>, + fileContents: ReadonlyMap, +): void { + const scopesByOwner = new Map(); + const lineStartsByFile = new Map(); + for (const parsed of group) { + const source = fileContents.get(parsed.filePath); + let lineStarts: readonly number[] | undefined; + if (source !== undefined) { + lineStarts = lineStartsByFile.get(parsed.filePath); + if (lineStarts === undefined) { + lineStarts = lineStartsOf(source); + lineStartsByFile.set(parsed.filePath, lineStarts); + } + } + for (const scope of parsed.scopes) { + if (scope.kind !== 'Class') continue; + const key = scopeOwnerKey(scope, source, lineStarts); + if (key === undefined) continue; + let scopes = scopesByOwner.get(key); + if (scopes === undefined) { + scopes = []; + scopesByOwner.set(key, scopes); + } + scopes.push(scope.id); + } + } + + for (const parsed of group) { + for (const def of parsed.localDefs) { + if (!isClassLike(def.type) || def.ownerId === undefined) continue; + const owner = indexes.defs.byId.get(def.ownerId); + if (owner === undefined) continue; + const targetScopes = scopesByOwner.get(logicalOwnerKey(owner)); + if (targetScopes === undefined) continue; + for (const scopeId of targetScopes) { + addNamespaceBinding(augmentations, scopeId, def); + } + } + } +} + +function scopeOwnerKey( + scope: Scope, + source: string | undefined, + lineStarts?: readonly number[], +): string | undefined { + const owner = scope.ownedDefs.find((def) => isClassLike(def.type)); + if (owner !== undefined) return logicalOwnerKey(owner); + + // Extension scopes carry no synthetic class def. Capture generation keeps + // only the trailing owner on members (`Inner.f` for `extension Outer.Inner`), + // so recover the full owner from this scope's declaration text first. + if (source !== undefined) { + const sourceOwner = swiftExtensionOwner(source, scope, lineStarts); + // Do not last-dot-guess: member qualified names are trailing-only, so + // `Inner.make` would key `Inner` instead of `Outer.Inner`. + if (sourceOwner === undefined) return undefined; + const representative = firstBoundDefinition(scope); + return logicalOwnerKey({ + ...(representative ?? { + nodeId: sourceOwner, + filePath: scope.filePath, + type: 'Class', + qualifiedName: sourceOwner, + }), + qualifiedName: sourceOwner, + }); + } + + // Hand-built fixtures and old cached shapes may have no source text. Keep + // the conservative member-prefix fallback, rejecting inconsistent owners. + let inferredOwner: string | undefined; + for (const refs of scope.bindings.values()) { + for (const { def } of refs) { + const qualifiedName = def.qualifiedName; + if (qualifiedName === undefined) continue; + const separator = qualifiedName.lastIndexOf('.'); + if (separator <= 0) continue; + const candidate = logicalOwnerKey({ + ...def, + qualifiedName: qualifiedName.slice(0, separator), + }); + if (inferredOwner !== undefined && inferredOwner !== candidate) return undefined; + inferredOwner = candidate; + } + } + return inferredOwner; +} + +function firstBoundDefinition(scope: Scope): SymbolDefinition | undefined { + for (const refs of scope.bindings.values()) { + const first = refs[0]?.def; + if (first !== undefined) return first; + } + return undefined; +} + +/** + * Read `extension Outer.Inner` from the class-scope source range. + * Access modifiers and attributes (`public`, `@MainActor`, `@available`) + * may precede the keyword, so the match is not start-anchored. Attribute + * message strings and comments must not supply a false `extension Type`. + * Owner segments use Unicode identifier characters so `Café.Container` + * is not truncated to `Caf`. + */ +const SWIFT_TYPE_IDENT = String.raw`[\p{ID_Start}_][\p{ID_Continue}]*`; +const EXTENSION_OWNER = new RegExp( + String.raw`\bextension\s+(${SWIFT_TYPE_IDENT}(?:\s*\.\s*${SWIFT_TYPE_IDENT})*)`, + 'u', +); + +function swiftExtensionOwner( + source: string, + scope: Scope, + lineStarts?: readonly number[], +): string | undefined { + const declaration = sliceScopeRange(source, scope.range, lineStarts ?? lineStartsOf(source)); + if (declaration === undefined) return undefined; + const cleaned = cleanExtensionHeader(declaration); + return EXTENSION_OWNER.exec(cleaned)?.[1]?.replace(/\s+/g, ''); +} + +/** `Scope.range` is 1-based on lines. Columns are Tree-sitter UTF-8 bytes. */ +function lineStartsOf(source: string): number[] { + const starts = [0, 0]; + for (let index = 0; index < source.length; index += 1) { + if (source[index] === '\n') starts.push(index + 1); + } + return starts; +} + +function sliceScopeRange( + source: string, + range: Scope['range'], + starts: readonly number[], +): string | undefined { + const start = starts[range.startLine]; + const end = starts[range.endLine]; + if (start === undefined || end === undefined) return undefined; + return source.slice( + start + jsOffsetForUtf8Column(source, start, range.startCol), + end + jsOffsetForUtf8Column(source, end, range.endCol), + ); +} + +/** Convert a Tree-sitter UTF-8 column into a JS string offset on that line. */ +function jsOffsetForUtf8Column(source: string, lineStart: number, utf8Column: number): number { + let bytes = 0; + let index = lineStart; + while (index < source.length && bytes < utf8Column) { + if (source[index] === '\n') break; + const codePoint = source.codePointAt(index); + if (codePoint === undefined) break; + bytes += utf8ByteLength(codePoint); + index += codePoint > 0xffff ? 2 : 1; + } + return index - lineStart; +} + +function utf8ByteLength(codePoint: number): number { + if (codePoint <= 0x7f) return 1; + if (codePoint <= 0x7ff) return 2; + if (codePoint <= 0xffff) return 3; + return 4; +} + +/** Header through the first unquoted `{`, with strings and comments blanked. */ +function cleanExtensionHeader(declaration: string): string { + let index = 0; + let cleaned = ''; + const blank = (from: number, to: number): void => { + for (let cursor = from; cursor < to; cursor += 1) { + cleaned += declaration[cursor] === '\n' ? '\n' : ' '; + } + }; + while (index < declaration.length) { + const current = declaration[index]; + if (current === '/' && declaration[index + 1] === '/') { + const end = skipLineComment(declaration, index); + blank(index, end); + index = end; + continue; + } + if (current === '/' && declaration[index + 1] === '*') { + const end = skipBlockComment(declaration, index); + blank(index, end); + index = end; + continue; + } + const pounds = current === '#' ? leadingPounds(declaration, index) : 0; + const quoteAt = index + pounds; + if (declaration.startsWith('"""', quoteAt) || declaration[quoteAt] === '"') { + const end = skipSwiftString(declaration, index); + blank(index, end); + index = end; + continue; + } + if (current === "'") { + const end = skipQuoted(declaration, index, current); + blank(index, end); + index = end; + continue; + } + if (current === '{') break; + cleaned += current; + index += 1; + } + return cleaned; +} + +function skipLineComment(text: string, start: number): number { + const newline = text.indexOf('\n', start); + return newline === -1 ? text.length : newline + 1; +} + +function skipBlockComment(text: string, start: number): number { + let index = start + 2; + let depth = 1; + while (index < text.length && depth > 0) { + if (text.startsWith('/*', index)) { + depth += 1; + index += 2; + continue; + } + if (text.startsWith('*/', index)) { + depth -= 1; + index += 2; + continue; + } + index += 1; + } + return index; +} + +function leadingPounds(text: string, start: number): number { + let count = 0; + while (text[start + count] === '#') count += 1; + return count; +} + +function skipSwiftString(text: string, start: number): number { + const pounds = leadingPounds(text, start); + const quoteAt = start + pounds; + if (text.startsWith('"""', quoteAt)) { + return skipDelimitedString(text, quoteAt + 3, `"""${'#'.repeat(pounds)}`, pounds === 0); + } + if (text[quoteAt] === '"') { + return skipDelimitedString(text, quoteAt + 1, `"${'#'.repeat(pounds)}`, pounds === 0); + } + return start + Math.max(pounds, 1); +} + +function skipDelimitedString( + text: string, + bodyStart: number, + closer: string, + escapes: boolean, +): number { + let index = bodyStart; + while (index < text.length) { + if (escapes && text[index] === '\\') { + index += 2; + continue; + } + if (text.startsWith(closer, index)) return index + closer.length; + index += 1; + } + return text.length; +} + +function skipQuoted(text: string, start: number, quote: string): number { + let index = start + 1; + while (index < text.length) { + if (text[index] === '\\') { + index += 2; + continue; + } + if (text[index] === quote) return index + 1; + index += 1; + } + return text.length; +} + +function logicalOwnerKey(def: SymbolDefinition): string { + const qualifiedName = def.qualifiedName ?? def.nodeId; + const namespacePrefix = def.namespacePrefix ?? ''; + return `${namespacePrefix.length}:${namespacePrefix}:${qualifiedName}`; +} + +function simpleName(def: SymbolDefinition): string { + return def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; +} + +function addNamespaceBinding( + augmentations: Map>, + scopeId: ScopeId, + def: SymbolDefinition, +): void { + const name = simpleName(def); + if (name === '') return; + const bucket = getAugmentationBucket(augmentations, scopeId, name); + if (bucket.some((binding) => binding.def.nodeId === def.nodeId)) return; + bucket.push({ def, origin: 'namespace' }); +} + function getAugmentationBucket( augmentations: Map>, scopeId: ScopeId, diff --git a/gitnexus/src/core/ingestion/pipeline-phases/processes.ts b/gitnexus/src/core/ingestion/pipeline-phases/processes.ts index e34e79d35..6be8839c3 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/processes.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/processes.ts @@ -19,6 +19,12 @@ import type { ToolsOutput } from './tools.js'; import type { StructureOutput } from './structure.js'; import type { ParseOutput } from './parse.js'; import { processProcesses, type ProcessDetectionResult } from '../process-processor.js'; +import { + buildProcessDetectionPhaseConfig, + formatWholeFlowsMissingRemedies, + processDetectionEffectiveLimits, + resolveProcessDetectionBudget, +} from '../process-detection-budget.js'; import { generateId } from '../../../lib/utils.js'; import { routeNodeKey } from '../route-extractors/route-path.js'; import { isDev } from '../utils/env.js'; @@ -79,11 +85,29 @@ export const processesPhase: PipelinePhase = { stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: ctx.graph.nodeCount }, }); + const resolvedBudget = resolveProcessDetectionBudget( + { + maxProcesses: ctx.options?.maxProcesses, + maxProcessBranching: ctx.options?.maxProcessBranching, + maxProcessTraceDepth: ctx.options?.maxProcessTraceDepth, + maxEntryPointCandidates: ctx.options?.maxEntryPointCandidates, + }, + // Env is resolved in `runFullAnalysis` and threaded on PipelineOptions. + // The phase reads only those fields so unit tests stay isolated from + // the host environment. + {}, + ); let symbolCount = 0; - ctx.graph.forEachNode((n) => { - if (n.label !== 'File') symbolCount++; - }); - const dynamicMaxProcesses = computeDynamicMaxProcesses(symbolCount); + if (resolvedBudget.maxProcesses === undefined) { + ctx.graph.forEachNode((n) => { + if (n.label !== 'File') symbolCount++; + }); + } + const detectionConfig = buildProcessDetectionPhaseConfig( + resolvedBudget, + symbolCount, + computeDynamicMaxProcesses, + ); // R3-6: where the program reaches outward. Already collected by the parse // phase for FILE-level FETCHES/QUERIES edges; reused here at function @@ -123,7 +147,7 @@ export const processesPhase: PipelinePhase = { stats: { filesProcessed: totalFiles, totalFiles, nodesCreated: ctx.graph.nodeCount }, }); }, - { maxProcesses: dynamicMaxProcesses, minSteps: 3 }, + detectionConfig, outwardActionSites, ); @@ -143,8 +167,8 @@ export const processesPhase: PipelinePhase = { // "unexplored entry points mean whole flows are missing, while a // depth-capped trace means a flow is present but shorter than it really is" // — and it is what keeps the line worth reading. Warning on every counter - // meant warning on every run: this phase overrides only `maxProcesses`, so - // at the shipped defaults (`maxBranching: 4`, `maxTraceDepth: 10`, + // meant warning on every run: at the shipped defaults (`maxBranching: 4`, + // `maxTraceDepth: 10`, // per-entry trace budget 12) `calleesDropped` fires for any function with // five callees, `tracesDepthCapped` for any chain deeper than ten, and // `walksCutByBudget` for any entry point with twelve paths under it. All @@ -171,6 +195,15 @@ export const processesPhase: PipelinePhase = { truncation.entryPointCandidatesDropped > 0 || truncation.entryPointsUnexplored > 0 || truncation.processesDropped > 0; + const effectiveLimits = processDetectionEffectiveLimits( + detectionConfig.maxProcesses, + resolvedBudget, + ); + const remedies = formatWholeFlowsMissingRemedies( + truncation, + effectiveLimits, + entryPointCandidates, + ); const shape = `${truncation.entryPointCandidatesDropped} of ${entryPointCandidates} candidate entry point(s) never ranked in, ` + `${truncation.entryPointsUnexplored} ranked entry point(s) never traced, ` + @@ -180,13 +213,13 @@ export const processesPhase: PipelinePhase = { `${truncation.walksCutByBudget} walk(s) cut by the per-entry trace budget.`; if (flowsMissing) { logger.warn( - { truncation }, + { truncation, effectiveLimits }, `[processes] ${processResult.stats.totalProcesses} flows reported, but whole flows are MISSING: ` + - `${shape} An absent flow does NOT mean the code path does not exist.`, + `${shape}${remedies} An absent flow does NOT mean the code path does not exist.`, ); } else if (truncation.truncated) { logger.debug( - { truncation }, + { truncation, effectiveLimits }, `[processes] ${processResult.stats.totalProcesses} flows reported; every flow found is present, ` + `but some are shorter than the code path they describe: ${shape}`, ); diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 9b8eb1897..e36345f69 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -251,6 +251,15 @@ export interface PipelineOptions { * analyze invocations. */ workerPoolSize?: number; + /** + * Process-detection budget (#3313). Explicit `maxProcesses` replaces the + * dynamic `symbols / 10` formula; the other three replace compiled defaults. + * Unset fields keep shipped behavior. `0` is rejected upstream — not unlimited. + */ + maxProcesses?: number; + maxProcessBranching?: number; + maxProcessTraceDepth?: number; + maxEntryPointCandidates?: number; /** * Number of chunks whose file contents may be read into memory in * parallel while the worker pool is busy dispatching the current @@ -445,8 +454,8 @@ export const runPipelineFromRepo = async ( const propertyInference = scopeResolutionOutput.propertyInference; // Presence check, not `!skipGraphPhases`: phases can now be filtered out by - // any `enabledWhen` predicate (streamGraphEmit disables communities/processes - // too), and `getPhaseOutput` THROWS on a phase that was never resolved. Keying + // any `enabledWhen` predicate (`skipGraphPhases` drops communities/processes), + // and `getPhaseOutput` THROWS on a phase that was never resolved. Keying // off the options flag alone made every filtered-out combination crash here // rather than return undefined results. if (results.has('communities') && results.has('processes')) { diff --git a/gitnexus/src/core/ingestion/process-detection-budget.ts b/gitnexus/src/core/ingestion/process-detection-budget.ts new file mode 100644 index 000000000..12ce1c0d3 --- /dev/null +++ b/gitnexus/src/core/ingestion/process-detection-budget.ts @@ -0,0 +1,304 @@ +/** + * Analyze-time process-detection budget (#3313). + * + * Four knobs control how many execution flows `processProcesses` keeps: + * process count, per-node branching, trace depth, and the ranked entry-point + * pool. Precedence is CLI / explicit `AnalyzeOptions` > `.gitnexusrc` (already + * merged into those fields) > `GITNEXUS_*` env > built-in defaults. Unset + * `maxProcesses` keeps the dynamic `symbols / 10` formula. `0` is invalid, not + * unlimited. + */ + +import type { ProcessDetectionConfig, ProcessTruncationStats } from './process-processor.js'; +import { parsePositiveIntEnv } from './utils/env.js'; + +export const PROCESS_DETECTION_BUDGET_DEFAULTS = { + maxProcessBranching: 4, + maxProcessTraceDepth: 10, + maxEntryPointCandidates: 200, + minSteps: 3, +} as const; + +export const PROCESS_DETECTION_ENV = { + maxProcesses: 'GITNEXUS_MAX_PROCESSES', + maxProcessBranching: 'GITNEXUS_MAX_PROCESS_BRANCHING', + maxProcessTraceDepth: 'GITNEXUS_MAX_PROCESS_TRACE_DEPTH', + maxEntryPointCandidates: 'GITNEXUS_MAX_ENTRY_POINT_CANDIDATES', +} as const; + +export const PROCESS_DETECTION_CLI_FLAGS = { + maxProcesses: '--max-processes', + maxProcessBranching: '--max-process-branching', + maxProcessTraceDepth: '--max-process-trace-depth', + maxEntryPointCandidates: '--max-entry-point-candidates', +} as const; + +const BUDGET_KEYS = [ + 'maxProcesses', + 'maxProcessBranching', + 'maxProcessTraceDepth', + 'maxEntryPointCandidates', +] as const; + +export type ProcessDetectionBudgetKey = (typeof BUDGET_KEYS)[number]; + +export type ProcessDetectionBudgetFields = { + maxProcesses?: number; + maxProcessBranching?: number; + maxProcessTraceDepth?: number; + maxEntryPointCandidates?: number; +}; + +export type ProcessDetectionBudgetStrings = { + maxProcesses?: string; + maxProcessBranching?: string; + maxProcessTraceDepth?: string; + maxEntryPointCandidates?: string; +}; + +export type ProcessDetectionStamp = { + /** Explicit override, or `null` when this run used the dynamic formula. */ + maxProcesses: number | null; + maxProcessBranching: number; + maxProcessTraceDepth: number; + maxEntryPointCandidates: number; + /** + * In-place FTS park after a derived-layer rewrite (#3322). Missing stamp + + * defaults is a match, so recovery must persist a complete stamp that still + * mismatches until a successful analyze certifies the live Community/Process + * rows. Success writes omit this flag. + */ + uncertified?: true; +}; + +export type ResolvedProcessDetectionBudget = { + /** Set only when an explicit override won. */ + maxProcesses?: number; + maxProcessBranching: number; + maxProcessTraceDepth: number; + maxEntryPointCandidates: number; + overridden: { + maxProcesses: boolean; + maxProcessBranching: boolean; + maxProcessTraceDepth: boolean; + maxEntryPointCandidates: boolean; + }; +}; + +export type ProcessDetectionEffectiveLimits = { + maxProcesses: number; + maxProcessBranching: number; + maxProcessTraceDepth: number; + maxEntryPointCandidates: number; + /** + * Pre-entry gate: `processProcesses` does not start the next entry once + * collected traces already reach `maxProcesses * 2`. One started entry can + * still append every trace `traceFromEntryPoint` returns. + */ + maxProcessTraces: number; +}; + +export type InvalidBudgetHandler = (knob: string, raw: string) => void; + +/** Operator copy when a CLI/rc/env token is rejected. Next precedence still applies. */ +export const formatInvalidProcessDetectionOverride = (knob: string, raw: string): string => { + const next = knob.startsWith('GITNEXUS_') + ? 'the built-in default' + : 'the next source (env, then the built-in default)'; + return `${knob} must be a positive integer (got ${JSON.stringify(raw)}); ignoring it so ${next} applies.`; +}; + +export const parsePositiveIntegerOverride = ( + raw: string | number | undefined | null, + onInvalid?: (raw: string) => void, +): number | undefined => { + if (raw === undefined || raw === null) return undefined; + const text = typeof raw === 'number' ? String(raw) : raw; + const parsed = parsePositiveIntEnv(text); + if (parsed === undefined) onInvalid?.(typeof raw === 'number' ? text : text.trim()); + return parsed; +}; + +export const parseProcessDetectionBudgetStrings = ( + raw: ProcessDetectionBudgetStrings, + onInvalid?: InvalidBudgetHandler, +): ProcessDetectionBudgetFields => { + const out: ProcessDetectionBudgetFields = {}; + for (const key of BUDGET_KEYS) { + if (raw[key] === undefined) continue; + const parsed = parsePositiveIntegerOverride(raw[key], (invalid) => + onInvalid?.(PROCESS_DETECTION_CLI_FLAGS[key], invalid), + ); + if (parsed !== undefined) out[key] = parsed; + } + return out; +}; + +const readOverride = ( + options: ProcessDetectionBudgetFields, + env: NodeJS.ProcessEnv, + key: ProcessDetectionBudgetKey, + onInvalid?: InvalidBudgetHandler, +): number | undefined => { + const fromOptions = options[key]; + if (fromOptions !== undefined) { + const parsed = parsePositiveIntegerOverride(fromOptions, (invalid) => + onInvalid?.(PROCESS_DETECTION_CLI_FLAGS[key], invalid), + ); + if (parsed !== undefined) return parsed; + } + const envName = PROCESS_DETECTION_ENV[key]; + const fromEnv = env[envName]; + if (fromEnv === undefined) return undefined; + return parsePositiveIntegerOverride(fromEnv, (invalid) => onInvalid?.(envName, invalid)); +}; + +export const resolveProcessDetectionBudget = ( + options: ProcessDetectionBudgetFields = {}, + env: NodeJS.ProcessEnv = process.env, + onInvalid?: InvalidBudgetHandler, +): ResolvedProcessDetectionBudget => { + const maxProcesses = readOverride(options, env, 'maxProcesses', onInvalid); + const branching = readOverride(options, env, 'maxProcessBranching', onInvalid); + const depth = readOverride(options, env, 'maxProcessTraceDepth', onInvalid); + const entryPoints = readOverride(options, env, 'maxEntryPointCandidates', onInvalid); + return { + ...(maxProcesses === undefined ? {} : { maxProcesses }), + maxProcessBranching: branching ?? PROCESS_DETECTION_BUDGET_DEFAULTS.maxProcessBranching, + maxProcessTraceDepth: depth ?? PROCESS_DETECTION_BUDGET_DEFAULTS.maxProcessTraceDepth, + maxEntryPointCandidates: + entryPoints ?? PROCESS_DETECTION_BUDGET_DEFAULTS.maxEntryPointCandidates, + overridden: { + maxProcesses: maxProcesses !== undefined, + maxProcessBranching: branching !== undefined, + maxProcessTraceDepth: depth !== undefined, + maxEntryPointCandidates: entryPoints !== undefined, + }, + }; +}; + +export const hasProcessDetectionOverride = (resolved: ResolvedProcessDetectionBudget): boolean => + resolved.overridden.maxProcesses || + resolved.overridden.maxProcessBranching || + resolved.overridden.maxProcessTraceDepth || + resolved.overridden.maxEntryPointCandidates; + +export const toProcessDetectionStamp = ( + resolved: ResolvedProcessDetectionBudget, +): ProcessDetectionStamp => ({ + maxProcesses: resolved.maxProcesses ?? null, + maxProcessBranching: resolved.maxProcessBranching, + maxProcessTraceDepth: resolved.maxProcessTraceDepth, + maxEntryPointCandidates: resolved.maxEntryPointCandidates, +}); + +/** Complete stamp that always mismatches until the next successful analyze. */ +export const uncertifyProcessDetectionStamp = ( + recorded: ProcessDetectionStamp | undefined, +): ProcessDetectionStamp => ({ + maxProcesses: recorded?.maxProcesses ?? null, + maxProcessBranching: + recorded?.maxProcessBranching ?? PROCESS_DETECTION_BUDGET_DEFAULTS.maxProcessBranching, + maxProcessTraceDepth: + recorded?.maxProcessTraceDepth ?? PROCESS_DETECTION_BUDGET_DEFAULTS.maxProcessTraceDepth, + maxEntryPointCandidates: + recorded?.maxEntryPointCandidates ?? PROCESS_DETECTION_BUDGET_DEFAULTS.maxEntryPointCandidates, + uncertified: true, +}); + +const isCompleteStamp = ( + recorded: ProcessDetectionStamp | undefined, +): recorded is ProcessDetectionStamp => + recorded !== undefined && + (recorded.maxProcesses === null || + (typeof recorded.maxProcesses === 'number' && Number.isInteger(recorded.maxProcesses))) && + Number.isInteger(recorded.maxProcessBranching) && + Number.isInteger(recorded.maxProcessTraceDepth) && + Number.isInteger(recorded.maxEntryPointCandidates); + +export const processDetectionBudgetMismatch = ( + recorded: ProcessDetectionStamp | undefined, + resolved: ResolvedProcessDetectionBudget, +): boolean => { + if (recorded?.uncertified === true) return true; + if (!isCompleteStamp(recorded)) { + // Legacy meta: same defaults as today's shipped behavior stay a match so + // an upgrade backfills the stamp instead of re-detecting. Any explicit + // override is a mismatch — otherwise a budget-only raise on a pre-#3313 + // index would preserve the sampled Community/Process layer. + return hasProcessDetectionOverride(resolved); + } + const stamp = toProcessDetectionStamp(resolved); + return ( + recorded.maxProcesses !== stamp.maxProcesses || + recorded.maxProcessBranching !== stamp.maxProcessBranching || + recorded.maxProcessTraceDepth !== stamp.maxProcessTraceDepth || + recorded.maxEntryPointCandidates !== stamp.maxEntryPointCandidates + ); +}; + +export const buildProcessDetectionPhaseConfig = ( + resolved: ResolvedProcessDetectionBudget, + symbolCount: number, + computeDynamicMaxProcesses: (n: number) => number, +): Pick< + ProcessDetectionConfig, + 'maxProcesses' | 'maxBranching' | 'maxTraceDepth' | 'maxEntryPointCandidates' | 'minSteps' +> => ({ + maxProcesses: resolved.maxProcesses ?? computeDynamicMaxProcesses(symbolCount), + maxBranching: resolved.maxProcessBranching, + maxTraceDepth: resolved.maxProcessTraceDepth, + maxEntryPointCandidates: resolved.maxEntryPointCandidates, + minSteps: PROCESS_DETECTION_BUDGET_DEFAULTS.minSteps, +}); + +export const processDetectionEffectiveLimits = ( + maxProcesses: number, + resolved: ResolvedProcessDetectionBudget, +): ProcessDetectionEffectiveLimits => ({ + maxProcesses, + maxProcessBranching: resolved.maxProcessBranching, + maxProcessTraceDepth: resolved.maxProcessTraceDepth, + maxEntryPointCandidates: resolved.maxEntryPointCandidates, + maxProcessTraces: maxProcesses * 2, +}); + +export const formatWholeFlowsMissingRemedies = ( + truncation: Pick< + ProcessTruncationStats, + 'entryPointCandidatesDropped' | 'entryPointsUnexplored' | 'processesDropped' + >, + limits: ProcessDetectionEffectiveLimits, + observedEntryPointCandidates: number, +): string => { + const parts: string[] = []; + if (truncation.entryPointCandidatesDropped > 0) { + parts.push( + `${PROCESS_DETECTION_CLI_FLAGS.maxEntryPointCandidates} ` + + `(this run ranked ${observedEntryPointCandidates} candidate(s))`, + ); + } + if (truncation.entryPointsUnexplored > 0 || truncation.processesDropped > 0) { + parts.push( + `${PROCESS_DETECTION_CLI_FLAGS.maxProcesses} ` + + `(this run used ${limits.maxProcesses}; next entry is skipped once ` + + `collected traces reach ${limits.maxProcessTraces})`, + ); + } + return parts.length === 0 ? '' : ` Raise ${parts.join('; ')}.`; +}; + +export const formatProcessDetectionBudgetBanner = ( + resolved: ResolvedProcessDetectionBudget, +): string | null => { + if (!hasProcessDetectionOverride(resolved)) return null; + const maxProcesses = resolved.overridden.maxProcesses + ? String(resolved.maxProcesses) + : 'dynamic (max(20, round(symbols/10)))'; + return ( + ` Process-detection budget: maxProcesses=${maxProcesses}, ` + + `maxProcessBranching=${resolved.maxProcessBranching}, ` + + `maxProcessTraceDepth=${resolved.maxProcessTraceDepth}, ` + + `maxEntryPointCandidates=${resolved.maxEntryPointCandidates}` + ); +}; diff --git a/gitnexus/src/core/ingestion/process-processor.ts b/gitnexus/src/core/ingestion/process-processor.ts index 6a7cc4a10..fd75652c7 100644 --- a/gitnexus/src/core/ingestion/process-processor.ts +++ b/gitnexus/src/core/ingestion/process-processor.ts @@ -17,6 +17,7 @@ import { CommunityMembership } from './community-processor.js'; import { calculateEntryPointScore, isTestFile } from './entry-point-scoring.js'; import { SupportedLanguages } from 'gitnexus-shared'; import { isDev } from './utils/env.js'; +import { PROCESS_DETECTION_BUDGET_DEFAULTS } from './process-detection-budget.js'; import { logger } from '../logger.js'; // ============================================================================ @@ -25,16 +26,18 @@ import { logger } from '../logger.js'; export interface ProcessDetectionConfig { maxTraceDepth: number; // Maximum steps to trace (default: 10) - maxBranching: number; // Max branches to follow per node (default: 3) - maxProcesses: number; // Maximum processes to detect (default: 50) - minSteps: number; // Minimum steps for a valid process (default: 2) + maxBranching: number; // Max branches to follow per node (default: 4) + maxProcesses: number; // Maximum processes to detect (default: 75) + minSteps: number; // Minimum steps for a valid process (default: 3) + maxEntryPointCandidates: number; // Ranked entry-point pool (default: 200) } -const DEFAULT_CONFIG: ProcessDetectionConfig = { - maxTraceDepth: 10, - maxBranching: 4, +export const DEFAULT_CONFIG: ProcessDetectionConfig = { + maxTraceDepth: PROCESS_DETECTION_BUDGET_DEFAULTS.maxProcessTraceDepth, + maxBranching: PROCESS_DETECTION_BUDGET_DEFAULTS.maxProcessBranching, maxProcesses: 75, - minSteps: 3, // 3+ steps = genuine multi-hop flow (2-step is just "A calls B") + minSteps: PROCESS_DETECTION_BUDGET_DEFAULTS.minSteps, // 3+ steps = genuine multi-hop flow (2-step is just "A calls B") + maxEntryPointCandidates: PROCESS_DETECTION_BUDGET_DEFAULTS.maxEntryPointCandidates, }; // ============================================================================ @@ -89,7 +92,7 @@ export interface ProcessTruncationStats { truncated: boolean; /** * Scoring candidates that never reached the trace loop because - * `findEntryPoints` keeps only the top `ENTRY_POINT_CANDIDATE_LIMIT`. + * `findEntryPoints` keeps only the top `maxEntryPointCandidates`. * Counted BEFORE the slice, so it sees what `entryPointsFound` cannot. */ entryPointCandidatesDropped: number; @@ -166,11 +169,17 @@ export const processProcesses = async ( for (const n of knowledgeGraph.iterNodes()) nodeMap.set(n.id, n); // Declared before Step 1 because `findEntryPoints` has a ceiling of its own - // (see `ENTRY_POINT_CANDIDATE_LIMIT`) and reports it through the same record. + // (`cfg.maxEntryPointCandidates`, default 200) and reports it through the same record. const truncation = emptyTruncation(); // Step 1: Find entry points (functions that call others but have few callers) - const entryPoints = findEntryPoints(knowledgeGraph, reverseCallsEdges, callsEdges, truncation); + const entryPoints = findEntryPoints( + knowledgeGraph, + reverseCallsEdges, + callsEdges, + truncation, + cfg.maxEntryPointCandidates, + ); onProgress?.(`Found ${entryPoints.length} entry points, tracing flows...`, 20); @@ -199,7 +208,7 @@ export const processProcesses = async ( // the remainder are not "no flows found" — they were never looked at. // // Counted over the list `findEntryPoints` RETURNS, which is already capped at - // `ENTRY_POINT_CANDIDATE_LIMIT`; candidates beyond that cap are invisible here + // `maxEntryPointCandidates`; candidates beyond that cap are invisible here // by construction and are reported separately as // `entryPointCandidatesDropped`. truncation.entryPointsUnexplored = entryPoints.length - tracedEntryPoints; @@ -480,13 +489,6 @@ const buildReverseCallsGraph = (graph: KnowledgeGraph): AdjacencyList => { return adj; }; -/** - * How many ranked candidates survive to be traced. Everything below this line - * is discarded — see `ProcessTruncationStats.entryPointCandidatesDropped`, the - * counter that exists because this cap spent a release being invisible. - */ -const ENTRY_POINT_CANDIDATE_LIMIT = 200; - /** * Find functions/methods that are good entry points for tracing. * @@ -495,7 +497,9 @@ const ENTRY_POINT_CANDIDATE_LIMIT = 200; * 2. Export status (exported/public functions rank higher) * 3. Name patterns (handle*, on*, *Controller, etc.) * - * Test files are excluded entirely. + * Test files are excluded entirely. How many ranked candidates survive to be + * traced is `maxEntryPointCandidates` (default 200) — see + * `ProcessTruncationStats.entryPointCandidatesDropped`. */ const findEntryPoints = ( graph: KnowledgeGraph, @@ -509,6 +513,7 @@ const findEntryPoints = ( * to unwrap a counter to ask for entry points. */ truncation?: ProcessTruncationStats, + maxEntryPointCandidates: number = DEFAULT_CONFIG.maxEntryPointCandidates, ): string[] => { const symbolTypes = new Set(['Function', 'Method']); const entryPointCandidates: { @@ -576,15 +581,15 @@ const findEntryPoints = ( // Limit to prevent explosion — and SAY SO. This is the ceiling that decides // how much of a repository process detection ever looks at: on anything with - // more than 200 scoring candidates the reported flows are a sample of the - // top-ranked ones, and every downstream count (`entryPointsFound`, - // `entryPointsUnexplored`) is computed over the survivors, so none of them can - // see what was cut here. - if (truncation !== undefined && sorted.length > ENTRY_POINT_CANDIDATE_LIMIT) { - truncation.entryPointCandidatesDropped = sorted.length - ENTRY_POINT_CANDIDATE_LIMIT; + // more scoring candidates than `maxEntryPointCandidates` the reported flows + // are a sample of the top-ranked ones, and every downstream count + // (`entryPointsFound`, `entryPointsUnexplored`) is computed over the + // survivors, so none of them can see what was cut here. + if (truncation !== undefined && sorted.length > maxEntryPointCandidates) { + truncation.entryPointCandidatesDropped = sorted.length - maxEntryPointCandidates; } - return sorted.slice(0, ENTRY_POINT_CANDIDATE_LIMIT).map((c) => c.id); + return sorted.slice(0, maxEntryPointCandidates).map((c) => c.id); }; // ============================================================================ @@ -822,7 +827,7 @@ const compareOrderKeys = (a: string, b: string): number => (a < b ? -1 : a > b ? * joined strings, so an O(n log n) sort performs O(n log n) joins of * O(depth x id-length) characters each. * - * `n` is bounded here (`ENTRY_POINT_CANDIDATE_LIMIT` entry points x the + * `n` is bounded here (`maxEntryPointCandidates` entry points x the * per-entry trace budget), so the cost is small and once-per-analyze: measured * at the ceiling, 23,851 comparisons performed 70,524 joins, and end-to-end * `processProcesses` at 80,000 functions / 640k CALLS went 456 -> 555 ms. It is diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index 898282b5e..22d1f2572 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -4,7 +4,7 @@ * (RFC §5.3 + §3.2 Phase 1; Ring 2 PKG #919). * * Exactly one entry point: `extract(matches, filePath, provider) → ParsedFile`. - * Runs a five-pass pipeline over the matches. Each pass is internal; the + * Runs a seven-pass pipeline over the matches. Each pass is internal; the * public contract is the output `ParsedFile`. * * ## Design principles @@ -22,7 +22,7 @@ * don't overlap) are enforced by `buildScopeTree` from Ring 2 SHARED * (#912). Malformed inputs throw `ScopeTreeInvariantError`. * - * ## The five passes + * ## The seven passes * * 1. **Build scope tree.** Walk `@scope.*` matches. For each, consult * `provider.resolveScopeKind` (default: suffix of the capture name). @@ -60,6 +60,10 @@ * one `ReferenceSite` per match. Classify call form via * `provider.classifyCallForm` (default: the capture's sub-tag if * present; else `'free'`). + * 6. **Collect callable-value-flow facts.** Independent of Pass 5 so + * existing reference-site extraction stays byte-identical. + * 7. **Preserve call-result assignment identity.** Untyped + * `let lhs = call()` facts used by exact-callee return-type replay. * * ## What gets attached where * @@ -80,6 +84,7 @@ import type { CallableFlowOperand, CallableFlowPassingMode, CallableFlowSite, + CallResultAssignmentSite, Capture, CaptureMatch, ImportEdge, @@ -134,7 +139,7 @@ export type ScopeExtractorHooks = Pick< // ─── Public entry point ───────────────────────────────────────────────────── /** - * Drive the five extraction passes and return a `ParsedFile`. + * Drive the seven extraction passes and return a `ParsedFile`. * * Throws `ScopeTreeInvariantError` (from #912) when the provider emits * captures that violate structural scope invariants (e.g., overlapping @@ -240,6 +245,15 @@ export function extract( const callableFlowSites: CallableFlowSite[] = []; pass6CollectCallableFlows(partitioned.callableFlow, positionIndex, filePath, callableFlowSites); + // ── Pass 7: preserve call-result assignment identity ─────────────── + const callResultAssignmentSites: CallResultAssignmentSite[] = []; + pass7CollectCallResultAssignments( + partitioned.callResultAssignment, + positionIndex, + filePath, + callResultAssignmentSites, + ); + // Freeze Scope drafts into final shape and return. const frozenScopes = scopeDrafts.map(draftToScope); return Object.freeze({ @@ -252,6 +266,9 @@ export function extract( ...(callableFlowSites.length > 0 ? { callableFlowSites: Object.freeze(callableFlowSites.slice()) } : {}), + ...(callResultAssignmentSites.length > 0 + ? { callResultAssignmentSites: Object.freeze(callResultAssignmentSites.slice()) } + : {}), }); } @@ -264,6 +281,7 @@ interface Partitioned { readonly typeBinding: readonly CaptureMatch[]; readonly reference: readonly CaptureMatch[]; readonly callableFlow: readonly CaptureMatch[]; + readonly callResultAssignment: readonly CaptureMatch[]; } /** @@ -283,6 +301,7 @@ function partitionByTopic(matches: readonly CaptureMatch[]): Partitioned { const typeBinding: CaptureMatch[] = []; const reference: CaptureMatch[] = []; const callableFlow: CaptureMatch[] = []; + const callResultAssignment: CaptureMatch[] = []; for (const match of matches) { for (const topic of topicsOf(match)) { @@ -305,14 +324,32 @@ function partitionByTopic(matches: readonly CaptureMatch[]): Partitioned { case 'callable-flow': callableFlow.push(match); break; + case 'call-result-assignment': + callResultAssignment.push(match); + break; } } } - return { scope, declaration, import_, typeBinding, reference, callableFlow }; + return { + scope, + declaration, + import_, + typeBinding, + reference, + callableFlow, + callResultAssignment, + }; } -type Topic = 'scope' | 'declaration' | 'import' | 'type-binding' | 'reference' | 'callable-flow'; +type Topic = + | 'scope' + | 'declaration' + | 'import' + | 'type-binding' + | 'reference' + | 'callable-flow' + | 'call-result-assignment'; function topicsOf(match: CaptureMatch): ReadonlySet { const topics = new Set(); @@ -323,6 +360,7 @@ function topicsOf(match: CaptureMatch): ReadonlySet { else if (name.startsWith('@type-binding.')) topics.add('type-binding'); else if (name.startsWith('@reference.')) topics.add('reference'); else if (name.startsWith('@callable-flow.')) topics.add('callable-flow'); + else if (name.startsWith('@call-result-assignment.')) topics.add('call-result-assignment'); } return topics; } @@ -598,17 +636,25 @@ function pass2AttachDeclarations( // fact is the failure mode this subsystem rejects everywhere else. // // Copying the field onto BOTH twins makes the outcome identical whichever - // one wins. Deliberately narrow — only `typeParameters`, the one field with - // an asymmetric twin today. Widening this to "merge all metadata" would - // change what every existing duplicate resolves to, which is a different - // change with a different blast radius and no evidence behind it yet. + // one wins. Deliberately narrow — only metadata whose asymmetric twins have + // executable regressions (`typeParameters` and `returnType`). Widening this + // to "merge all metadata" would change what every existing duplicate + // resolves to, which is a different change with a different blast radius + // and no evidence behind it yet. const first = firstDefByNodeId.get(def.nodeId); if (first === undefined) { firstDefByNodeId.set(def.nodeId, def); - } else if (first.typeParameters === undefined && def.typeParameters !== undefined) { - first.typeParameters = def.typeParameters; - } else if (def.typeParameters === undefined && first.typeParameters !== undefined) { - def.typeParameters = first.typeParameters; + } else { + if (first.typeParameters === undefined && def.typeParameters !== undefined) { + first.typeParameters = def.typeParameters; + } else if (def.typeParameters === undefined && first.typeParameters !== undefined) { + def.typeParameters = first.typeParameters; + } + if (first.returnType === undefined && def.returnType !== undefined) { + first.returnType = def.returnType; + } else if (def.returnType === undefined && first.returnType !== undefined) { + def.returnType = first.returnType; + } } // Find the innermost scope that contains the declaration's anchor range. @@ -1652,6 +1698,24 @@ function pass6CollectCallableFlows( } } +// ─── Pass 7: collect call-result assignment identity ────────────────────── + +function pass7CollectCallResultAssignments( + matches: readonly CaptureMatch[], + positionIndex: ReturnType, + filePath: string, + out: CallResultAssignmentSite[], +): void { + for (const match of matches) { + const call = match['@call-result-assignment.call']; + const lhs = match['@call-result-assignment.lhs']; + if (call === undefined || lhs === undefined || !nonEmpty(lhs.text)) continue; + const inScope = positionIndex.atPosition(filePath, call.range.startLine, call.range.startCol); + if (inScope === undefined) continue; + out.push({ callSite: call.range, inScope, lhs: lhs.text }); + } +} + function callableFlowKind(match: CaptureMatch): CallableFlowKind | undefined { return CALLABLE_FLOW_KINDS.find((kind) => match[`@callable-flow.${kind}`] !== undefined); } diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index 71385a41f..5fed0ea15 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -692,7 +692,10 @@ export interface ScopeResolver { */ readonly populateWorkspaceOwners?: ( parsedFiles: readonly ParsedFile[], - ctx: { readonly fileContents: ReadonlyMap }, + ctx: { + readonly fileContents: ReadonlyMap; + readonly resolutionConfig?: unknown; + }, ) => void; /** @@ -965,6 +968,11 @@ export interface ScopeResolver { */ readonly freeCallsRequireInstanceOwnership?: boolean; + /** Whether an unqualified call inside a type may dispatch to an inherited + * instance method. Languages such as Swift allow implicit-self lookup, + * while Python/JavaScript/PHP require an explicit receiver. */ + readonly implicitThisWalksMro?: boolean; + /** * When true, a constructor-form call `Type(...)` links to the Class def * itself rather than its explicit Constructor def. Default diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts index 3e7c3013c..6693a2bc6 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/free-call-fallback.ts @@ -121,6 +121,7 @@ export function emitFreeCallFallback( * contains the method's owner. See * `ScopeResolver.freeCallsRequireInstanceOwnership`. */ readonly freeCallsRequireInstanceOwnership?: boolean; + readonly implicitThisWalksMro?: boolean; readonly recordResolutionOutcome?: ResolutionOutcomeRecorder; /** Call sites owned by a later precise pass (for example callable-value-flow). */ readonly skipSites?: ReadonlySet; @@ -314,6 +315,7 @@ export function emitFreeCallFallback( conversionRankFn: options.conversionRankFn, conversionOnlyArgTypePrefixes: options.conversionOnlyArgTypePrefixes, constraintCompatibility: options.constraintCompatibility, + implicitThisWalksMro: options.implicitThisWalksMro, }); fnDefFromImplicitThis = fnDef !== undefined; } @@ -1167,11 +1169,14 @@ export function pickUniqueGlobalClass( * pick a method member by name with overload narrowing on arity + * argument types. Returns undefined if there's no enclosing class, * no matching method, OR narrowing leaves multiple compatible - * candidates — in the multi-candidate case, picking - * `candidates[0]` would emit a high-confidence CALLS edge whose - * target depends on registration order rather than a defensible - * resolution. Mirrors `pickUniqueGlobalCallable`'s uniqueness check - * in the same file (Codex PR #1497 review, finding 2). + * candidates — except when those survivors are a protocol/interface + * requirement plus exactly one extension witness, in which case the + * witness (the default body) is returned. An inherited class, struct, + * or enum member still wins over a protocol-extension default. + * Picking `candidates[0]` would emit a high-confidence CALLS edge + * whose target depends on registration order rather than a + * defensible resolution. Mirrors `pickUniqueGlobalCallable`'s + * uniqueness check in the same file (Codex PR #1497 review, finding 2). * * Exported for unit testing — language-agnostic logic, exercised * via synthetic stubs in `pick-implicit-this-overload.test.ts`. The @@ -1191,6 +1196,7 @@ export function pickImplicitThisOverload( readonly conversionRankFn?: ConversionRankFn; readonly conversionOnlyArgTypePrefixes?: readonly string[]; readonly constraintCompatibility?: ScopeResolver['constraintCompatibility']; + readonly implicitThisWalksMro?: boolean; }, ): SymbolDefinition | undefined { // Find the enclosing Class scope by walking parents. @@ -1211,22 +1217,118 @@ export function pickImplicitThisOverload( const classDefId = workspaceIndex.classScopeIdToDefId.get(classScopeId); if (classDefId === undefined) return undefined; - const overloads = model.methods.lookupAllByOwner(classDefId, site.name); - if (overloads.length === 0) return undefined; - if (overloads.length === 1) return overloads[0]; + // Bare calls in an instance method use the same implicit receiver as + // `self.member()`. Prefer declarations on the enclosing type; when the + // language opts into MRO implicit-this, walk inherited owners + // nearest-first and arity-narrow per owner. Compatible-but-ambiguous + // on a nearer ancestor fail-closes — do not fall through to a farther + // override. Falling through to the global name lookup makes inherited + // defaults depend on file order. + const own = model.methods.lookupAllByOwner(classDefId, site.name); + const ownPicked = pickUniqueImplicitThisCandidate(own, site, hookCtx, workspaceIndex); + if (ownPicked !== undefined) return ownPicked; + if (own.length > 0) { + const ownCompatible = narrowOverloadCandidates(own, site.arity, site.argumentTypes, { + argumentTypeClasses: site.argumentTypeClasses, + conversionRankFn: hookCtx?.conversionRankFn, + conversionOnlyArgTypePrefixes: hookCtx?.conversionOnlyArgTypePrefixes, + constraintCompatibility: hookCtx?.constraintCompatibility, + }); + if (ownCompatible.length > 0) return undefined; + } + if (hookCtx?.implicitThisWalksMro !== true) return undefined; - // Narrow on arity + argument types. Require a UNIQUE survivor — - // ambiguous narrowing (multiple compatible candidates with no - // disambiguating signal) leaves the call unresolved rather than - // routing to an arbitrary first overload by registration order. + for (const ownerId of scopes.methodDispatch?.mroFor(classDefId) ?? []) { + const inherited = model.methods.lookupAllByOwner(ownerId, site.name); + const inheritedPicked = pickUniqueImplicitThisCandidate( + inherited, + site, + hookCtx, + workspaceIndex, + ); + if (inheritedPicked !== undefined) return inheritedPicked; + if (inherited.length > 0) { + const inheritedCompatible = narrowOverloadCandidates( + inherited, + site.arity, + site.argumentTypes, + { + argumentTypeClasses: site.argumentTypeClasses, + conversionRankFn: hookCtx?.conversionRankFn, + conversionOnlyArgTypePrefixes: hookCtx?.conversionOnlyArgTypePrefixes, + constraintCompatibility: hookCtx?.constraintCompatibility, + }, + ); + if (inheritedCompatible.length > 0) return undefined; + } + } + return undefined; +} + +function pickUniqueImplicitThisCandidate( + overloads: readonly SymbolDefinition[], + site: { + readonly arity?: number; + readonly argumentTypes?: readonly string[]; + readonly argumentTypeClasses?: readonly import('gitnexus-shared').ParameterTypeClass[]; + }, + hookCtx: + | { + readonly conversionRankFn?: ConversionRankFn; + readonly conversionOnlyArgTypePrefixes?: readonly string[]; + readonly constraintCompatibility?: ScopeResolver['constraintCompatibility']; + } + | undefined, + workspaceIndex: WorkspaceResolutionIndex, +): SymbolDefinition | undefined { + if (overloads.length === 0) return undefined; const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes, { argumentTypeClasses: site.argumentTypeClasses, conversionRankFn: hookCtx?.conversionRankFn, conversionOnlyArgTypePrefixes: hookCtx?.conversionOnlyArgTypePrefixes, constraintCompatibility: hookCtx?.constraintCompatibility, }); - if (candidates.length !== 1) return undefined; - return candidates[0]; + if (candidates.length === 0) return undefined; + if (candidates.length === 1) return candidates[0]; + const witnesses = preferExtensionWitnesses(candidates, workspaceIndex); + return witnesses.length === 1 ? witnesses[0] : undefined; +} + +function preferExtensionWitnesses( + candidates: readonly SymbolDefinition[], + workspaceIndex: WorkspaceResolutionIndex, +): readonly SymbolDefinition[] { + const onOwnerType: SymbolDefinition[] = []; + const extensionWitnesses: SymbolDefinition[] = []; + for (const def of candidates) { + const ownerId = def.ownerId; + if (ownerId === undefined) { + extensionWitnesses.push(def); + continue; + } + const ownerScope = workspaceIndex.classScopeByDefId?.get(ownerId); + const livesOnOwner = + ownerScope?.ownedDefs.some((owned) => owned.nodeId === def.nodeId) === true; + if (livesOnOwner) onOwnerType.push(def); + else extensionWitnesses.push(def); + } + if (extensionWitnesses.length === 0 || onOwnerType.length === 0) return candidates; + const concrete = onOwnerType.filter((def) => !isProtocolLikeOwner(def.ownerId, workspaceIndex)); + if (concrete.length > 0) return concrete; + return extensionWitnesses; +} + +function isProtocolLikeOwner( + ownerId: string | undefined, + workspaceIndex: WorkspaceResolutionIndex, +): boolean { + if (ownerId === undefined) return false; + const ownerScope = workspaceIndex.classScopeByDefId?.get(ownerId); + if (ownerScope === undefined) return false; + return ownerScope.ownedDefs.some( + (owned) => + owned.nodeId === ownerId && (owned.type === 'Protocol' || owned.type === 'Interface'), + ); } /** diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 58dd19248..27a96d599 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -24,6 +24,7 @@ */ import type { ParsedFile, RegistryProviders } from 'gitnexus-shared'; +import type { TypeRef } from 'gitnexus-shared'; import type { KnowledgeGraph } from '../../../graph/types.js'; import { generateId } from '../../../../lib/utils.js'; import { lookupOwnedMembersByOwner } from '../../model/owned-members-lookup.js'; @@ -84,6 +85,7 @@ import { import { emitReturnShapeMemberAccesses } from '../passes/return-shape-members.js'; import { emitImportedValueReferences } from '../passes/imported-value-refs.js'; import { + calleeIdPosKey, createCalleeIdAccumulator, type CalleeIdAccumulator, } from '../graph-bridge/callee-id-sink.js'; @@ -103,6 +105,58 @@ import { buildWorkspaceResolutionIndex } from '../workspace-index.js'; import type { ResolutionOutcome, ResolutionOutcomeRecorder } from '../resolution-outcome.js'; import { logHeapProbe } from '../../utils/heap-probe.js'; import { parseTruthyEnv } from '../../utils/env.js'; + +/** + * Join extraction-time assignment identity to Phase-4's exact resolved callee. + * Ambiguous dispatch and missing return annotations deliberately produce no + * binding. Extraction emits these facts only for untyped declarations, so an + * existing entry here is necessarily inference/mirroring and may be corrected; + * explicit annotations never enter this join. + */ +export function applyPreciseCallResultBindings( + parsedFiles: readonly ParsedFile[], + indexes: ReturnType, + workspaceIndex: ReturnType, + calleeIds: CalleeIdAccumulator, + nodeLookup: ReturnType, +): number { + let updated = 0; + const returnTypeByGraphId = new Map(); + for (const parsed of parsedFiles) { + for (const def of parsed.localDefs) { + const returnType = workspaceIndex.declaredReturnTypeByCallableId.get(def.nodeId); + if (returnType === undefined) continue; + const graphId = resolveDefGraphId(def.filePath, def, nodeLookup); + if (graphId !== undefined) returnTypeByGraphId.set(graphId, returnType); + } + } + for (const parsed of parsedFiles) { + const resolvedByPosition = calleeIds.get(parsed.filePath); + if (resolvedByPosition === undefined) continue; + for (const assignment of parsed.callResultAssignmentSites ?? []) { + const targets = resolvedByPosition.get( + calleeIdPosKey(assignment.callSite.startLine, assignment.callSite.startCol), + ); + if (targets === undefined || targets.size !== 1) continue; + const targetId = targets.values().next().value as string | undefined; + if (targetId === undefined) continue; + const returnType = returnTypeByGraphId.get(targetId); + if (returnType === undefined) continue; + const scope = indexes.scopeTree.getScope(assignment.inScope); + if (scope === undefined) continue; + (scope.typeBindings as Map).set(assignment.lhs, { + rawName: returnType.rawName, + ...(returnType.declaredSpelling !== undefined + ? { declaredSpelling: returnType.declaredSpelling } + : {}), + declaredAtScope: assignment.inScope, + source: 'assignment-inferred', + }); + updated++; + } + } + return updated; +} import { isValueDefinitionLabel } from '../../utils/ast-helpers.js'; import { TransitionalScopeTree } from '../../../../storage/scope-index-store.js'; import { forceGc } from '../../../../storage/parsedfile-store.js'; @@ -551,7 +605,6 @@ export function runScopeResolution( const undecidedSatisfaction: UndecidedSatisfaction[] = []; const recordResolutionOutcome: ResolutionOutcomeRecorder = (outcome) => { resolutionOutcomes.push(outcome); - input.recordResolutionOutcome?.(outcome); }; const PROF = process.env.PROF_SCOPE_RESOLUTION === '1'; const tStart = PROF ? process.hrtime.bigint() : 0n; @@ -638,7 +691,10 @@ export function runScopeResolution( 'sr-extract-end', `lang=${provider.language} parsedFiles=${parsedFiles.length} preExtractedHits=${preExtractedHits} skipped=${filesSkipped}`, ); - provider.populateWorkspaceOwners?.(parsedFiles, { fileContents: getFileContents() }); + provider.populateWorkspaceOwners?.(parsedFiles, { + fileContents: getFileContents(), + resolutionConfig: input.resolutionConfig, + }); provider.populateWorkspaceReferences?.(parsedFiles, { fileContents: getFileContents(), treeCache, @@ -844,7 +900,9 @@ export function runScopeResolution( // views that delegate to it (out-of-core scope index) — the index pins no Scope objects, so the // disk seal can reclaim them. Byte-identical: the view returns the same Scope // the resident tree holds (or a value-identical revived one in disk mode). - const workspaceIndex = buildWorkspaceResolutionIndex(parsedFiles, indexes.scopeTree); + const workspaceIndex = buildWorkspaceResolutionIndex(parsedFiles, indexes.scopeTree, { + stripTypePreservingDecoration: provider.stripTypePreservingDecoration, + }); logHeapProbe('sr-post-workspaceIndex', `lang=${provider.language}`); // Cross-file implicit-namespace visibility (C#). Must run before @@ -1011,6 +1069,12 @@ export function runScopeResolution( const deferredIndirectCollection = collectDeferredIndirectCollection(emitParsedFiles, indexes); const deferredIndirectSites = deferredIndirectCollection.sites; const callableArgumentSites = new Set(); + const callResultAssignmentSites = new Set(); + for (const parsed of emitParsedFiles) { + for (const site of parsed.callResultAssignmentSites ?? []) { + callResultAssignmentSites.add(callableFlowSiteKey(parsed.filePath, site.callSite)); + } + } if (input.pdg !== true && deferredIndirectSites.size > 0) { for (const parsed of emitParsedFiles) { for (const site of parsed.callableFlowSites ?? []) { @@ -1025,11 +1089,14 @@ export function runScopeResolution( // propagation. Populated below at every CALLS emit path before dedup; the CFG // join still consumes it only inside the `input.pdg` block. const calleeIdAccumulator: CalleeIdAccumulator | undefined = - input.pdg === true || deferredIndirectSites.size > 0 + input.pdg === true || deferredIndirectSites.size > 0 || callResultAssignmentSites.size > 0 ? createCalleeIdAccumulator( input.pdg === true ? undefined - : (filePath, line, col) => callableArgumentSites.has(`${filePath}:${line}:${col}`), + : (filePath, line, col) => { + const key = `${filePath}:${line}:${col}`; + return callableArgumentSites.has(key) || callResultAssignmentSites.has(key); + }, ) : undefined; const receiverBound = callableFlowOnly @@ -1060,7 +1127,7 @@ export function runScopeResolution( heritageTypeArguments, }, ); - const receiverExtras = receiverBound.emitted; + let receiverExtras = receiverBound.emitted; if (receiverBound.dispatchFanoutSkipped > 0) { // Never drop dispatch coverage silently (#2829) — same contract as the // property-dispatch cap below. An interface member over the cap loses real @@ -1112,6 +1179,7 @@ export function runScopeResolution( isFileLocalDef: provider.isFileLocalDef, isBuiltInName: provider.languageProvider.isBuiltInName, freeCallsRequireInstanceOwnership: provider.freeCallsRequireInstanceOwnership === true, + implicitThisWalksMro: provider.implicitThisWalksMro === true, isCallableVisibleFromCaller: provider.isCallableVisibleFromCaller, resolveAdlCandidates: provider.resolveAdlCandidates, resolveQualifiedFreeCall: provider.resolveQualifiedFreeCall, @@ -1123,6 +1191,63 @@ export function runScopeResolution( skipSites: deferredIndirectSites, }, ); + const replayedCallResultBindings = + callableFlowOnly || calleeIdAccumulator === undefined + ? 0 + : applyPreciseCallResultBindings( + emitParsedFiles, + indexes, + workspaceIndex, + calleeIdAccumulator, + postHeritageNodeLookup, + ); + if (replayedCallResultBindings > 0) { + const handledBeforeReplay = new Set(handledSites); + const replayedReceiverBound = emitReceiverBoundCalls( + graph, + indexes, + emitParsedFiles, + postHeritageNodeLookup, + handledSites, + provider, + workspaceIndex, + readonlyModel, + { + calleeIdSink: calleeIdAccumulator, + isBuiltInName: provider.languageProvider.isBuiltInName, + heritageTypeArguments, + }, + ); + receiverExtras += replayedReceiverBound.emitted; + if (replayedReceiverBound.dispatchFanoutSkipped > 0) { + logger.warn( + { + lang: provider.language, + dispatchFanoutSkipped: replayedReceiverBound.dispatchFanoutSkipped, + dispatchFanoutSkippedNames: replayedReceiverBound.dispatchFanoutSkippedNames, + fanoutCap: MAX_INTERFACE_DISPATCH_FANOUT, + replay: true, + }, + 'interface-dispatch: members over the fan-out cap dropped implementors (their CALLS edges were not emitted)', + ); + } + const resolvedOnReplay = new Set(); + for (const key of handledSites) { + if (!handledBeforeReplay.has(key)) resolvedOnReplay.add(key); + } + if (resolvedOnReplay.size > 0) { + for (let i = resolutionOutcomes.length - 1; i >= 0; i--) { + const outcome = resolutionOutcomes[i]; + if ( + outcome.kind === 'suppressed' && + outcome.reason === 'receiver-unresolved' && + resolvedOnReplay.has(callableFlowSiteKey(outcome.filePath, outcome.range)) + ) { + resolutionOutcomes.splice(i, 1); + } + } + } + } const referenceSkipSites = new Set(handledSites); for (const key of deferredIndirectSites) referenceSkipSites.add(key); const { emitted, skipped } = callableFlowOnly @@ -1688,6 +1813,8 @@ export function runScopeResolution( logHeapProbe('sr-end', `lang=${provider.language} parsedFiles=${parsedFiles.length}`); + for (const outcome of resolutionOutcomes) input.recordResolutionOutcome?.(outcome); + return { filesProcessed: parsedFiles.length, filesSkipped, diff --git a/gitnexus/src/core/ingestion/scope-resolution/workspace-index-types.ts b/gitnexus/src/core/ingestion/scope-resolution/workspace-index-types.ts index 408e5ca42..012562ce7 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/workspace-index-types.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/workspace-index-types.ts @@ -13,7 +13,7 @@ * on `SemanticModel`, and for the builder itself. */ -import type { Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared'; +import type { Scope, ScopeId, SymbolDefinition, TypeRef } from 'gitnexus-shared'; export interface WorkspaceResolutionIndex { /** Class def `nodeId` → that class's `Scope`. */ @@ -36,4 +36,10 @@ export interface WorkspaceResolutionIndex { * killer). "First module-local callable in `moduleScopeByFile` order" is the * exact semantics the old scan returned, so it is byte-identical. */ readonly exportedCallableByName: ReadonlyMap; + + /** Exact callable def `nodeId` → its declared return type binding. + * Built from definitions owned by each callable's Function scope, so methods + * declared in extension/partial scopes remain addressable even when those + * scopes deliberately own no separate class-like definition. */ + readonly declaredReturnTypeByCallableId: ReadonlyMap; } diff --git a/gitnexus/src/core/ingestion/scope-resolution/workspace-index.ts b/gitnexus/src/core/ingestion/scope-resolution/workspace-index.ts index 597819f58..683ed0972 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/workspace-index.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/workspace-index.ts @@ -39,7 +39,14 @@ * Build cost is O(totalScopes). Read-only after construction. */ -import type { ParsedFile, Scope, ScopeId, ScopeTree, SymbolDefinition } from 'gitnexus-shared'; +import type { + ParsedFile, + Scope, + ScopeId, + ScopeTree, + SymbolDefinition, + TypeRef, +} from 'gitnexus-shared'; import type { WorkspaceResolutionIndex } from './workspace-index-types.js'; import { isClassLike } from './scope/walkers.js'; @@ -107,11 +114,15 @@ class ScopeByKeyView implements ReadonlyMap { export function buildWorkspaceResolutionIndex( parsedFiles: readonly ParsedFile[], scopeTree?: ScopeTree, + options?: { + readonly stripTypePreservingDecoration?: (typeName: string) => string | undefined; + }, ): WorkspaceResolutionIndex { const classScopeIdByDefId = new Map(); const classScopeIdToDefId = new Map(); const moduleScopeIdByFile = new Map(); const exportedCallableByName = new Map(); + const declaredReturnTypeByCallableId = new Map(); // Back-compat (no scopeTree): keep the direct Scope-object maps. const classScopeByDefIdDirect = scopeTree === undefined ? new Map() : undefined; const moduleScopeByFileDirect = scopeTree === undefined ? new Map() : undefined; @@ -138,6 +149,24 @@ export function buildWorkspaceResolutionIndex( } for (const scope of parsed.scopes) { + if (scope.kind === 'Function' && scope.parent !== null) { + for (const def of scope.ownedDefs) { + if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') { + continue; + } + if (def.returnType !== undefined) { + const stripped = options?.stripTypePreservingDecoration?.(def.returnType); + const rawName = stripped ?? def.returnType; + declaredReturnTypeByCallableId.set(def.nodeId, { + rawName, + ...(rawName !== def.returnType ? { declaredSpelling: def.returnType } : {}), + declaredAtScope: scope.id, + source: 'return-annotation', + }); + continue; + } + } + } if (scope.kind !== 'Class') continue; const cd = scope.ownedDefs.find((d) => isClassLike(d.type)); if (cd !== undefined) { @@ -157,5 +186,11 @@ export function buildWorkspaceResolutionIndex( ? moduleScopeByFileDirect! : new ScopeByKeyView(moduleScopeIdByFile, scopeTree); - return { classScopeByDefId, classScopeIdToDefId, moduleScopeByFile, exportedCallableByName }; + return { + classScopeByDefId, + classScopeIdToDefId, + moduleScopeByFile, + exportedCallableByName, + declaredReturnTypeByCallableId, + }; } diff --git a/gitnexus/src/core/lbug/lbug-adapter.ts b/gitnexus/src/core/lbug/lbug-adapter.ts index 621d26da6..5f5a076d4 100644 --- a/gitnexus/src/core/lbug/lbug-adapter.ts +++ b/gitnexus/src/core/lbug/lbug-adapter.ts @@ -34,7 +34,16 @@ import type { GraphEmitManifest } from './graph-emit-sink.js'; import type { PdgEmitManifest } from './pdg-emit-sink.js'; import { PDG_EDGE_TYPES } from './pdg-emit-sink.js'; import { getNodeLabel as deriveNodeLabel, type WriteStreamFactory } from './rel-pair-routing.js'; -import { EMBEDDABLE_LABELS, type CachedEmbedding } from '../embeddings/types.js'; +import { EMBEDDABLE_LABELS } from '../embeddings/types.js'; +import { + abortCachedEmbeddingsBuilder, + createCachedEmbeddingsBuilder, + emptyCachedEmbeddingsSnapshot, + finalizeCachedEmbeddingsSnapshot, + ingestCachedEmbeddingRow, + type CachedEmbeddingsSnapshot, + type LoadCachedEmbeddingsOptions, +} from '../embeddings/embedding-restore-spill.js'; import { extensionManager, getFtsCapability, @@ -2069,28 +2078,31 @@ export const getLbugStats = async (): Promise<{ /** * Load cached embeddings from LadybugDB before a rebuild. - * Returns all embedding vectors so they can be re-inserted after the graph is reloaded, - * avoiding expensive re-embedding of unchanged nodes. + * + * Streams `CodeEmbedding` rows with `hasNext`/`getNext` under `withConnLock` + * (#2264, #3306). Vectors are spilled to a temp Float32 file once the table + * exceeds the in-memory row limit so incremental analyze cannot OOM the V8 + * heap by materializing every `number[]` up front. Small tables still return + * in-RAM `embeddings` for existing callers/tests. * * Detects old schema (no chunkIndex column) and returns empty cache to trigger rebuild. */ -export const loadCachedEmbeddings = async (): Promise<{ - embeddingNodeIds: Set; - embeddings: CachedEmbedding[]; -}> => { +export const loadCachedEmbeddings = async ( + options?: LoadCachedEmbeddingsOptions, +): Promise => { const c = conn; if (!c) { - return { embeddingNodeIds: new Set(), embeddings: [] }; + return emptyCachedEmbeddingsSnapshot(); } // The whole read runs inside the connection lock (#2264 review P2). It's safe // today only by call-ordering (loadCachedEmbeddings runs before the WAL driver // starts), but the lock makes it robust to future reordering — a concurrent // CHECKPOINT on the singleton connection is the documented corruption trigger. - // Leaf read: no nested withConnLock-wrapped helpers inside. + // Leaf read: no nested withConnLock-wrapped helpers inside. Do NOT call + // `streamQuery` here — that path is unlocked and would race a CHECKPOINT. return withConnLock(async () => { - const embeddingNodeIds = new Set(); - const embeddings: CachedEmbedding[] = []; + const builder = createCachedEmbeddingsBuilder(options); try { // Schema migration detection: query with new columns to verify schema version. // Old schema only had (nodeId, embedding); new schema adds (id, chunkIndex, startLine, endLine, contentHash). @@ -2104,51 +2116,46 @@ export const loadCachedEmbeddings = async (): Promise<{ ); await readQueryRows(check); } catch { - return { embeddingNodeIds: new Set(), embeddings: [] }; + abortCachedEmbeddingsBuilder(builder); + return emptyCachedEmbeddingsSnapshot(); } - // Try to read contentHash alongside chunk columns - let rows: any; + let queryResult: lbug.QueryResult | lbug.QueryResult[] | undefined; let hasContentHash = true; try { - rows = await c.query( - `MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN e.nodeId AS nodeId, e.chunkIndex AS chunkIndex, e.startLine AS startLine, e.endLine AS endLine, e.embedding AS embedding, e.contentHash AS contentHash`, - ); - } catch (err: any) { - // Fallback for legacy DBs without contentHash column - const msg = err?.message ?? ''; - if (isMissingColumnOrTableError(msg)) { - hasContentHash = false; - rows = await c.query( - `MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN e.nodeId AS nodeId, e.chunkIndex AS chunkIndex, e.startLine AS startLine, e.endLine AS endLine, e.embedding AS embedding`, + try { + queryResult = await c.query( + `MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN e.nodeId AS nodeId, e.chunkIndex AS chunkIndex, e.startLine AS startLine, e.endLine AS endLine, e.embedding AS embedding, e.contentHash AS contentHash`, ); - } else { - throw err; + } catch (err: any) { + // Fallback for legacy DBs without contentHash column + const msg = err?.message ?? ''; + if (isMissingColumnOrTableError(msg)) { + hasContentHash = false; + queryResult = await c.query( + `MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN e.nodeId AS nodeId, e.chunkIndex AS chunkIndex, e.startLine AS startLine, e.endLine AS endLine, e.embedding AS embedding`, + ); + } else { + throw err; + } } - } - for (const row of await readQueryRows(rows)) { - const nodeId = String(row.nodeId ?? row[0] ?? ''); - if (!nodeId) continue; - embeddingNodeIds.add(nodeId); - const embedding = row.embedding ?? row[4]; - if (embedding) { - embeddings.push({ - nodeId, - chunkIndex: Number(row.chunkIndex ?? row[1] ?? 0), - startLine: Number(row.startLine ?? row[2] ?? 0), - endLine: Number(row.endLine ?? row[3] ?? 0), - embedding: Array.isArray(embedding) - ? embedding.map(Number) - : Array.from(embedding as any).map(Number), - contentHash: hasContentHash ? (row.contentHash ?? row[5] ?? undefined) : undefined, - }); + const results = Array.isArray(queryResult) ? queryResult : [queryResult]; + const result = results[0]; + while (await result.hasNext()) { + const row = await result.getNext(); + ingestCachedEmbeddingRow(builder, row, hasContentHash); } + return finalizeCachedEmbeddingsSnapshot(builder); + } catch (err) { + abortCachedEmbeddingsBuilder(builder); + throw err; + } finally { + if (queryResult) await closeQueryResults(queryResult); } - } catch { - /* embedding table may not exist */ + } catch (err) { + abortCachedEmbeddingsBuilder(builder); + throw err; } - - return { embeddingNodeIds, embeddings }; }); }; diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 567a156be..3d241f2b1 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -198,6 +198,13 @@ import { nodeTablesForIncrementalDelete, shouldPreservePersistedDerivedGraph, } from './incremental/derived-writeback.js'; +import { + formatInvalidProcessDetectionOverride, + processDetectionBudgetMismatch, + resolveProcessDetectionBudget, + toProcessDetectionStamp, + uncertifyProcessDetectionStamp, +} from './ingestion/process-detection-budget.js'; import { NODE_TABLES } from './lbug/schema.js'; import { loadParseCache, @@ -221,9 +228,20 @@ import { } from '../storage/git.js'; import { isGitNexusManagedPath } from '../storage/gitnexus-managed-paths.js'; import { getMaxFileSizeBytes } from './ingestion/utils/max-file-size.js'; -import type { CachedEmbedding } from './embeddings/types.js'; +import { + cacheRowCount, + discardScopedEmbeddingSpills, + disposeEmbeddingSpill, + withEmbeddingSpillScope, + emptyCachedEmbeddingsSnapshot, + EmbeddingSpillReader, + materializeCachedEmbeddings, + normalizeCachedEmbeddings, + snapshotEmbeddingDims, + type CachedEmbeddingsSnapshot, +} from './embeddings/embedding-restore-spill.js'; import { generateAIContextFiles } from '../cli/ai-context.js'; -import { sanitizeDetectedBranch } from '../cli/analyze-config.js'; +import { formatRejectedBranchForLog, sanitizeDetectedBranch } from './git-ref.js'; import { EMBEDDING_TABLE_NAME, EMBEDDING_DIMS, @@ -474,8 +492,9 @@ export interface AnalyzeOptions { pdgEmitChunkSize?: number; /** Streamed structural graph emit (#2680). Honored only on a full rebuild * (`force === true`). May also be enabled via `GITNEXUS_STREAM_GRAPH_EMIT`. - * Trades community detection, process extraction and PDG taint summaries for - * a ~2.9x reduction of in-memory graph heap. */ + * The sink answers a complete relationship read, so community detection, + * process extraction, and PDG taint summaries still run; streaming reduces + * in-memory graph heap (~2.9x) by keeping those edges on disk. */ streamGraphEmit?: boolean; /** * Default branch threaded into generated AGENTS.md / CLAUDE.md so the @@ -517,6 +536,16 @@ export interface AnalyzeOptions { * removed); `undefined` defers to the env / auto-formula fallback. */ workerPoolSize?: number; + /** + * Process-detection budget overrides (#3313). Threaded to + * `PipelineOptions` without mutating `process.env`. Unset fields fall + * back to `GITNEXUS_*` env, then shipped defaults / the dynamic + * `maxProcesses` formula. + */ + maxProcesses?: number; + maxProcessBranching?: number; + maxProcessTraceDepth?: number; + maxEntryPointCandidates?: number; /** * Extra fetch-wrapper function names to treat as HTTP consumers, forwarded to * `PipelineOptions.fetchWrappers` (#1589/#1852 residual). Sourced from the CLI @@ -1041,12 +1070,16 @@ export const pdgModeMismatch = (recorded: RepoMeta['pdg'], options: PdgOptions): * directory (#2658). `metaDir` — not `getStoragePaths(repoPath, options.branch)` * — is the lock scope: a `--branch X` that owns the flat slot resolves to the * flat `.gitnexus`, so scoping off the raw option would lock the wrong dir. + * `rejectedDetectedBranch` is log-only (the detect-reject warning after lock + * settle); it does not change placement. */ interface WriteTarget { storagePath: string; repoHasGit: boolean; currentCommit: string; checkedOutBranch: string | null; + /** Raw checkout name when git returned one the branch-name rules reject. */ + rejectedDetectedBranch: string | null; branchLabel: string | null; placement: { branch?: string }; lbugPath: string; @@ -1078,9 +1111,12 @@ async function resolveWriteTarget(repoPath: string, options: AnalyzeOptions): Pr // validated (#2106 R1): a git ref the branch-name rules forbid becomes `null` // → the flat slot, matching that a later `--branch ` query would // also be rejected. A normal ref round-trips index-time/query-time labels. - const checkedOutBranch = repoHasGit - ? (sanitizeDetectedBranch(getCurrentBranch(repoPath)) ?? null) - : null; + // Keep the raw rejected name so `runFullAnalysis` can warn once after the + // lock settles. Detached / non-git / empty detect stay `null` here and silent. + const rawDetectedBranch = repoHasGit ? getCurrentBranch(repoPath) : null; + const checkedOutBranch = sanitizeDetectedBranch(rawDetectedBranch) ?? null; + const rejectedDetectedBranch = + rawDetectedBranch != null && checkedOutBranch === null ? rawDetectedBranch : null; // Analyze indexes the working tree, not an arbitrary ref. An explicit // `--branch X` while a DIFFERENT branch Y is checked out would write Y's // content into X's slot, corrupting X (#2106). Refuse the mismatch. Detached @@ -1101,6 +1137,7 @@ async function resolveWriteTarget(repoPath: string, options: AnalyzeOptions): Pr repoHasGit, currentCommit, checkedOutBranch, + rejectedDetectedBranch, branchLabel, placement, lbugPath, @@ -1166,59 +1203,69 @@ export async function runFullAnalysis( let writeTarget = await resolveWriteTarget(repoPath, options); let lock = await acquireIndexLock(writeTarget.metaDir, acquireOpts); - try { - requireExclusiveIndexLock( - lock, - `Cannot acquire the index lock at ${writeTarget.metaDir}; refusing an unlocked analysis.`, - ); - // #2658 review H2: acquireIndexLock can wait up to the timeout ceiling, - // during which git HEAD/branch — and thus the resolved write slot — may - // change (a commit lands, a branch is switched, or another writer adopts the - // flat slot). The pre-wait snapshot must NOT be reused: re-resolve UNDER the - // lock so the freshness check (`existingMeta.lastCommit === currentCommit`) - // and the meta stamps see current git state, honoring the module's "re-check - // freshness after acquiring" contract. If the slot itself moved we hold the - // WRONG lock — release and re-acquire the correct one. Bounded so a - // pathologically churning checkout can't loop forever; after the cap we - // proceed on the current lock. The loop is INSIDE the try so a re-resolve - // that throws (e.g. a `--branch` that stopped matching the now-switched - // checkout) still releases the held lock via `finally` (no leak). - const MAX_RELOCK = 3; - for (let attempt = 0; attempt < MAX_RELOCK; attempt++) { - // Never pass the pre-lock storagePath as already-validated: requireStoragePath - // must run again under the lock so a now-foreign slot aborts (and finally - // still releases the lock). - const fresh = await resolveWriteTarget(repoPath, options); - if (fresh.metaDir === writeTarget.metaDir) { - writeTarget = fresh; // same slot — adopt the freshly-read commit/branch/placement - break; - } - log( - `Index write target moved while waiting for the lock ` + - `(${writeTarget.metaDir} → ${fresh.metaDir}); re-acquiring the correct slot.`, - ); - lock.release(); - writeTarget = fresh; - lock = await acquireIndexLock(fresh.metaDir, acquireOpts); + return withEmbeddingSpillScope(async () => { + try { requireExclusiveIndexLock( lock, - `Cannot acquire the index lock at ${fresh.metaDir}; refusing an unlocked analysis.`, + `Cannot acquire the index lock at ${writeTarget.metaDir}; refusing an unlocked analysis.`, ); - if (attempt === MAX_RELOCK - 1) { - log('Index write target still moving after repeated re-acquire; proceeding on this lock.'); + // #2658 review H2: acquireIndexLock can wait up to the timeout ceiling, + // during which git HEAD/branch — and thus the resolved write slot — may + // change (a commit lands, a branch is switched, or another writer adopts the + // flat slot). The pre-wait snapshot must NOT be reused: re-resolve UNDER the + // lock so the freshness check (`existingMeta.lastCommit === currentCommit`) + // and the meta stamps see current git state, honoring the module's "re-check + // freshness after acquiring" contract. If the slot itself moved we hold the + // WRONG lock — release and re-acquire the correct one. Bounded so a + // pathologically churning checkout can't loop forever; after the cap we + // proceed on the current lock. The loop is INSIDE the try so a re-resolve + // that throws (e.g. a `--branch` that stopped matching the now-switched + // checkout) still releases the held lock via `finally` (no leak). + const MAX_RELOCK = 3; + for (let attempt = 0; attempt < MAX_RELOCK; attempt++) { + // Never pass the pre-lock storagePath as already-validated: requireStoragePath + // must run again under the lock so a now-foreign slot aborts (and finally + // still releases the lock). + const fresh = await resolveWriteTarget(repoPath, options); + if (fresh.metaDir === writeTarget.metaDir) { + writeTarget = fresh; // same slot — adopt the freshly-read commit/branch/placement + break; + } + log( + `Index write target moved while waiting for the lock ` + + `(${writeTarget.metaDir} → ${fresh.metaDir}); re-acquiring the correct slot.`, + ); + lock.release(); + writeTarget = fresh; + lock = await acquireIndexLock(fresh.metaDir, acquireOpts); + requireExclusiveIndexLock( + lock, + `Cannot acquire the index lock at ${fresh.metaDir}; refusing an unlocked analysis.`, + ); + if (attempt === MAX_RELOCK - 1) { + log( + 'Index write target still moving after repeated re-acquire; proceeding on this lock.', + ); + } } + if (writeTarget.rejectedDetectedBranch) { + log( + `Warning: checkout "${formatRejectedBranchForLog(writeTarget.rejectedDetectedBranch)}" is not a usable index label; continuing.`, + ); + } + return await runFullAnalysisInner( + repoPath, + options, + callbacks, + writeTarget, + contentRetention, + runnerIdentityAtBootstrap, + ); + } finally { + discardScopedEmbeddingSpills(); + lock.release(); } - return await runFullAnalysisInner( - repoPath, - options, - callbacks, - writeTarget, - contentRetention, - runnerIdentityAtBootstrap, - ); - } finally { - lock.release(); - } + }); } async function runFullAnalysisInner( @@ -2037,13 +2084,37 @@ async function runFullAnalysisInner( options = { ...options, force: true }; } + // Process-detection budget (#3313). Resolve CLI/options then env here so + // MCP/server jobs honor GITNEXUS_* without a CLI merge. Compare against + // the persisted stamp BEFORE the already-up-to-date fast path: a clean + // same-commit raise must re-detect flows rather than return the sampled + // index. Does NOT set force — incremental empty-diff + skip derived + // preserve is enough. + const processDetectionBudget = resolveProcessDetectionBudget( + { + maxProcesses: options.maxProcesses, + maxProcessBranching: options.maxProcessBranching, + maxProcessTraceDepth: options.maxProcessTraceDepth, + maxEntryPointCandidates: options.maxEntryPointCandidates, + }, + process.env, + (knob, raw) => { + log(formatInvalidProcessDetectionOverride(knob, raw)); + }, + ); + const processDetectionMismatch = processDetectionBudgetMismatch( + existingMeta?.processDetection, + processDetectionBudget, + ); + // ── Early-return: already up to date ────────────────────────────── if ( existingMeta && !existingMeta.embeddingCheckpoint && !options.force && existingMeta.lastCommit === currentCommit && - !ftsModeChanged + !ftsModeChanged && + !processDetectionMismatch ) { // Non-git folders have currentCommit = '' — always rebuild since we can't detect changes if (currentCommit !== '') { @@ -2103,6 +2174,8 @@ async function runFullAnalysisInner( // later read on a host where it loads — which is a legitimate, common // state, and the invariant `analyzer-identity-cli.test.ts` pins. if (!dirty && !indexedContentChanged && !healUnregistered) { + const processDetectionStamp = + existingMeta.processDetection ?? toProcessDetectionStamp(processDetectionBudget); if (options.registryName) { await registerRepo(repoPath, existingMeta, { name: options.registryName, @@ -2154,7 +2227,11 @@ async function runFullAnalysisInner( // documented Docker :ro workflow (#1549) — degrades to a warning. try { await adoptFlatBranchLabel(repoPath, branchLabel, storagePath); - await saveMeta(metaDir, { ...existingMeta, branch: branchLabel }); + await saveMeta(metaDir, { + ...existingMeta, + branch: branchLabel, + processDetection: processDetectionStamp, + }); } catch (err) { // EACCES/EPERM also arise from ownership problems and transient // Windows locks, so keep the real error visible alongside the @@ -2167,12 +2244,26 @@ async function runFullAnalysisInner( // Discriminator-only restamp (flag↔env). `existingMeta` already // carries the folded skipReason; persist it without a write plan. try { - await saveMeta(metaDir, existingMeta); + await saveMeta(metaDir, { + ...existingMeta, + processDetection: processDetectionStamp, + }); } catch (err) { log( `Warning: could not restamp the FTS skip reason (${formatMetaWriteFailureReason(err)}); will retry on the next run.`, ); } + } else if (!existingMeta.processDetection) { + try { + await saveMeta(metaDir, { + ...existingMeta, + processDetection: processDetectionStamp, + }); + } catch (err) { + log( + `Warning: could not backfill the process-detection stamp (${formatMetaWriteFailureReason(err)}); will retry on the next run.`, + ); + } } await ensureGitNexusIgnored(repoPath, storagePath); return { @@ -2214,8 +2305,18 @@ async function runFullAnalysisInner( // The default-preserve branch is what makes a routine `analyze` (e.g. a // post-commit hook) safe: a multi-minute embedding pass is no longer // silently dropped just because the caller omitted `--embeddings`. - let cachedEmbeddingNodeIds = new Set(); - let cachedEmbeddings: CachedEmbedding[] = []; + let cachedSnapshot: CachedEmbeddingsSnapshot = emptyCachedEmbeddingsSnapshot(); + const adoptCachedEmbeddings = (raw: CachedEmbeddingsSnapshot): void => { + cachedSnapshot = normalizeCachedEmbeddings(raw); + }; + const discardCachedEmbeddings = (): void => { + disposeEmbeddingSpill(cachedSnapshot.spill); + cachedSnapshot = emptyCachedEmbeddingsSnapshot(); + }; + const discardCachedEmbeddingSpill = (): void => { + disposeEmbeddingSpill(cachedSnapshot.spill); + cachedSnapshot = { ...cachedSnapshot, spill: undefined }; + }; const existingEmbeddingCount = existingMeta?.stats?.embeddings ?? 0; const { @@ -2250,7 +2351,7 @@ async function runFullAnalysisInner( // of the predicted `willTryIncremental`). The post-pipeline branch may // disagree with the prediction (e.g. when the pipeline produces zero // File nodes, `isIncremental` flips false and the full-rebuild path - // wipes the DB) — loading unconditionally is cheap insurance against + // wipes the DB) — loading unconditionally is insurance against // silently dropping embeddings on a mispredicted run. The re-insert // step gates itself on the actual `isIncremental` value to avoid // PK-conflicts when the incremental writeback path keeps the rows. @@ -2264,9 +2365,7 @@ async function runFullAnalysisInner( try { progress('embeddings', 0, 'Caching embeddings...'); await initAnalysisLbug(lbugPath); - const cached = await loadCachedEmbeddings(); - cachedEmbeddingNodeIds = cached.embeddingNodeIds; - cachedEmbeddings = cached.embeddings; + adoptCachedEmbeddings(await loadCachedEmbeddings()); await closeLbug(); } catch (err: any) { // Surface cache-load failures explicitly: silently swallowing here would @@ -2277,8 +2376,7 @@ async function runFullAnalysisInner( `(${err?.message ?? String(err)}). ` + `Embeddings will not be preserved on this run.`, ); - cachedEmbeddingNodeIds = new Set(); - cachedEmbeddings = []; + discardCachedEmbeddings(); try { await closeLbug(); } catch { @@ -2352,6 +2450,16 @@ async function runFullAnalysisInner( { parseCache, workerPoolSize: options.workerPoolSize, + maxProcesses: processDetectionBudget.maxProcesses, + maxProcessBranching: processDetectionBudget.overridden.maxProcessBranching + ? processDetectionBudget.maxProcessBranching + : undefined, + maxProcessTraceDepth: processDetectionBudget.overridden.maxProcessTraceDepth + ? processDetectionBudget.maxProcessTraceDepth + : undefined, + maxEntryPointCandidates: processDetectionBudget.overridden.maxEntryPointCandidates + ? processDetectionBudget.maxEntryPointCandidates + : undefined, // CFG/PDG opt-in (#2081 M1). PipelineOptions.pdg fans out to the worker // build gate (workerData.pdg) and the scope-resolution emit gate. pdg: options.pdg === true, @@ -2391,6 +2499,7 @@ async function runFullAnalysisInner( }, ); } catch (err) { + discardCachedEmbeddingSpill(); await removeColdParseRebuildDir(coldParseRebuildDir, true); throw err; } @@ -2482,7 +2591,8 @@ async function runFullAnalysisInner( skipDerivedGraphPhases && isIncremental && !!hashDiff && - shouldPreservePersistedDerivedGraph(hashDiff); + shouldPreservePersistedDerivedGraph(hashDiff) && + !processDetectionMismatch; if (skipDerivedGraphPhases && !preserveDerivedLayer) { progress('communities', 58, 'Detecting code communities and flows...'); await pipelineResult.runDeferredDerivedPhases?.(); @@ -2565,17 +2675,21 @@ async function runFullAnalysisInner( ); // Set the dirty flag BEFORE any destructive DB mutation. Cleared on // success at the meta-save step. Scoped to this branch's meta.json. - const now = Date.now(); - await saveMeta(metaDir, { - ...existingMeta!, - incrementalInProgress: { - startedAt: now, - updatedAt: now, - phase: 'pre-write', - toWriteCount: hashDiff.toWrite.length, - directWriteCount: hashDiff.toWrite.length, - }, - }); + // POSIX atomic incremental mutates the copy, so a live dirty stamp would + // force-rebuild a healthy index after a crash before swap. + if (!atomicIncremental) { + const now = Date.now(); + await saveMeta(metaDir, { + ...existingMeta!, + incrementalInProgress: { + startedAt: now, + updatedAt: now, + phase: 'pre-write', + toWriteCount: hashDiff.toWrite.length, + directWriteCount: hashDiff.toWrite.length, + }, + }); + } if (atomicIncremental) { // Stage the live index into the temp so the in-place delete/writeback // below mutates the COPY, and the end-of-run swap publishes it atomically. @@ -2627,6 +2741,7 @@ async function runFullAnalysisInner( try { await wipeLbugDbFiles(buildPath); } catch (error) { + discardCachedEmbeddingSpill(); if (liveIndexMutationStarted) recordLiveIndexMutationRisk(error); throw error; } @@ -2655,6 +2770,7 @@ async function runFullAnalysisInner( try { await initAnalysisLbug(buildPath); } catch (error) { + discardCachedEmbeddingSpill(); if (liveIndexMutationStarted) recordLiveIndexMutationRisk(error); throw error; } @@ -2976,7 +3092,7 @@ async function runFullAnalysisInner( const extensionForcedRebuild = !embeddingRowDmlSafe || !ftsRowDmlSafe; // `!options.dropEmbeddings` (H1): this rescue reads the rows back OUT of // the DB, so it must never fire on the one path whose entire purpose is to - // destroy them. `--drop-embeddings` deliberately leaves `cachedEmbeddings` + // destroy them. `--drop-embeddings` deliberately leaves `cachedSnapshot` // empty (`deriveEmbeddingMode` returns `shouldLoadCache: false` for it by // construction — see the four-mode comment at the cache-load site), and its // `options.force = true` conversion sits INSIDE @@ -2991,12 +3107,16 @@ async function runFullAnalysisInner( // while rows survive ⇒ `hasExisting` false ⇒ `shouldLoadCache` false), i.e. // it would fix the wipe by deleting the safeguard. Covers // `--drop-embeddings --embeddings` too — the rescue repopulates - // `cachedEmbeddingNodeIds`, which Phase 4 hands `runEmbeddingPipeline` as + // `cachedSnapshot.embeddingNodeIds`, which Phase 4 hands `runEmbeddingPipeline` as // the already-embedded set, so the very nodes the user asked to REGENERATE // would be skipped. - if (extensionForcedRebuild && !options.dropEmbeddings && cachedEmbeddings.length === 0) { + if ( + extensionForcedRebuild && + !options.dropEmbeddings && + cacheRowCount(cachedSnapshot) === 0 + ) { // The escalation below WIPES the DB files, and Phase 3.5 restores - // embedding rows from `cachedEmbeddings` — which is only populated when + // embedding rows from `cachedSnapshot` — which is only populated when // `deriveEmbeddingMode` saw `meta.stats.embeddings > 0`. A DB whose meta // under-reports its embeddings (meta restored from an older run, or a // count that never got stamped) would therefore have every vector @@ -3004,14 +3124,21 @@ async function runFullAnalysisInner( // while the DB is still intact — a plain MATCH, which needs no VECTOR // extension. Rows whose owning node is gone are dropped by Phase 3.5's // live-graph filter, exactly as on any other wiped path. - const rescued = await loadCachedEmbeddings(); - if (rescued.embeddings.length > 0) { - cachedEmbeddings = rescued.embeddings; - cachedEmbeddingNodeIds = rescued.embeddingNodeIds; + try { + adoptCachedEmbeddings(await loadCachedEmbeddings()); + if (cacheRowCount(cachedSnapshot) > 0) { + log( + `Preserving ${cacheRowCount(cachedSnapshot)} embedding row(s) across the forced rebuild ` + + `(the index metadata did not account for them).`, + ); + } + } catch (err: any) { log( - `Preserving ${rescued.embeddings.length} embedding row(s) across the forced rebuild ` + - `(the index metadata did not account for them).`, + `Warning: could not load cached embeddings ` + + `(${err?.message ?? String(err)}). ` + + `Embeddings will not be preserved on this run.`, ); + discardCachedEmbeddings(); } } // Hoisted out of the `||` below (§5.D): the size verdict has to be KNOWN @@ -3492,6 +3619,11 @@ async function runFullAnalysisInner( lastCommit: '', indexedAt: new Date().toISOString(), }; + // #3322: persist uncertified *before* CREATE_FTS_INDEX. Park keeps this + // stamp; it must not invent one on every FTS-only crash. Missing stamp + + // shipped defaults is a match, so a budget-mismatch derived rewrite that + // dies in FTS would otherwise recertify the rewritten Community/Process + // rows on a flagless retry. await saveMeta(metaDir, { ...base, incrementalInProgress: buildFtsDirtyStamp({ @@ -3499,6 +3631,11 @@ async function runFullAnalysisInner( writePlan: 'in-place', checkpointSucceeded: boundaryCheckpointSucceeded, }), + ...(processDetectionMismatch + ? { + processDetection: uncertifyProcessDetectionStamp(base.processDetection), + } + : {}), }); } @@ -3650,26 +3787,27 @@ async function runFullAnalysisInner( // propagates errors (a completed writeback means a deterministic // delete outcome) and this process holds the exclusive DB lock (no // concurrent writer). - // The per-batch try/catch stays as a last-resort guard only — it no - // longer fires on the happy path. + // Materialize runs outside the insert catch so a spill I/O failure is not + // treated as a benign PK conflict. Any node with a failed restore batch is + // marked stale in the Phase 4 map so leftover chunks are deleted and rembedded. let restoredEmbeddingCount = 0; - if (cachedEmbeddings.length > 0) { - const cachedDims = cachedEmbeddings[0].embedding.length; + const restoreFailedNodeIds = new Set(); + if (cacheRowCount(cachedSnapshot) > 0) { + const cachedDims = snapshotEmbeddingDims(cachedSnapshot); const { EMBEDDING_DIMS } = await import('./lbug/schema.js'); - if (cachedDims !== EMBEDDING_DIMS) { + if (cachedDims !== undefined && cachedDims !== EMBEDDING_DIMS) { // Dimensions changed (e.g. switched embedding model) — discard cache and re-embed all log( `Embedding dimensions changed (${cachedDims}d -> ${EMBEDDING_DIMS}d), discarding cache`, ); - cachedEmbeddings = []; - cachedEmbeddingNodeIds = new Set(); + discardCachedEmbeddings(); } else { const { batchInsertEmbeddings: batchInsert } = await import('./embeddings/embedding-pipeline.js'); // (1) Live-graph filter — the FULL pipeline graph (always produced), // NOT the incremental subgraph, or unchanged files' rows would be // dropped from the restore set. - const liveEmbeddings = cachedEmbeddings.filter( + const liveEmbeddings = cachedSnapshot.rows.filter( (e) => pipelineResult.graph.getNode(e.nodeId) !== undefined, ); // (2) Restore-scope filter (see the discipline note above). @@ -3682,13 +3820,36 @@ async function runFullAnalysisInner( }); progress('embeddings', 88, `Restoring ${rowsToRestore.length} cached embeddings...`); const EMBED_BATCH = 200; - for (const batch of chunk(rowsToRestore, EMBED_BATCH)) { - try { - await batchInsert(executeWithReusedStatement, batch); - restoredEmbeddingCount += batch.length; - } catch { - /* last-resort guard — conflict-free by construction above */ + let spillReader: EmbeddingSpillReader | undefined; + try { + for (const batch of chunk(rowsToRestore, EMBED_BATCH)) { + let materialized; + try { + if (!spillReader && cachedSnapshot.spill && cachedSnapshot.embeddings.length === 0) { + spillReader = new EmbeddingSpillReader(cachedSnapshot.spill); + } + materialized = materializeCachedEmbeddings(cachedSnapshot, batch, spillReader); + } catch (err) { + for (const row of batch) restoreFailedNodeIds.add(row.nodeId); + log( + `Warning: could not materialize ${batch.length} cached embedding(s) for restore ` + + `(${(err as Error).message}); those nodes will be re-embedded if this run generates embeddings.`, + ); + continue; + } + try { + await batchInsert(executeWithReusedStatement, materialized); + restoredEmbeddingCount += batch.length; + } catch (err) { + for (const row of batch) restoreFailedNodeIds.add(row.nodeId); + log( + `Warning: could not restore ${batch.length} cached embedding(s) ` + + `(${(err as Error).message}); those nodes will be re-embedded if this run generates embeddings.`, + ); + } } + } finally { + spillReader?.close(); } // Legacy-orphan sweep (FIX 3, finder B): the live-graph filter's @@ -3705,7 +3866,7 @@ async function runFullAnalysisInner( // sweep failure must never fail a completed writeback, so the whole // sweep warns-and-continues. if (deletedFilePathsForRestore !== null) { - const orphanRowIds = cachedEmbeddings + const orphanRowIds = cachedSnapshot.rows .filter((e) => pipelineResult.graph.getNode(e.nodeId) === undefined) .map((e) => `${e.nodeId}:${e.chunkIndex}`); if (orphanRowIds.length > 0) { @@ -3734,6 +3895,9 @@ async function runFullAnalysisInner( } } } + // Vectors are on disk only to survive the wipe/delete. After restore, + // drop the spill so Phase 4 does not keep a multi-GB temp file open. + discardCachedEmbeddingSpill(); // ── Phase 4: Embeddings (90–98%) ────────────────────────────────── const stats = await getLbugStats(); @@ -3983,9 +4147,16 @@ async function runFullAnalysisInner( const embeddingIdentity = embeddingIdentityForRun; // Build a Map from cached embeddings for incremental mode let existingEmbeddings: Map | undefined; - if (cachedEmbeddingNodeIds.size > 0) { + if (cachedSnapshot.embeddingNodeIds.size > 0) { existingEmbeddings = new Map(); - for (const e of cachedEmbeddings) { + for (const e of cachedSnapshot.rows) { + if (restoreFailedNodeIds.has(e.nodeId)) { + // Any failed batch for this node: mark stale so Phase 4 DELETEs + // leftover chunks and re-embeds. Omitting the id would treat the + // node as new and PK-conflict on rows that already restored. + existingEmbeddings.set(e.nodeId, STALE_HASH_SENTINEL); + continue; + } existingEmbeddings.set(e.nodeId, e.contentHash ?? STALE_HASH_SENTINEL); } } @@ -4062,7 +4233,7 @@ async function runFullAnalysisInner( progress('embeddings', scaled, label); }, {}, - cachedEmbeddingNodeIds.size > 0 ? cachedEmbeddingNodeIds : undefined, + cachedSnapshot.embeddingNodeIds.size > 0 ? cachedSnapshot.embeddingNodeIds : undefined, existingEmbeddings, { forceReembedNodeIds: pendingEmbeddingNodeIds, @@ -4471,6 +4642,7 @@ async function runFullAnalysisInner( // stamp after an on→off flip; the next pdgModeMismatch then compares // off==off and incremental eligibility is restored. pdg: resolvePdgConfig(options), + processDetection: toProcessDetectionStamp(processDetectionBudget), }; // Re-resolve at the commit boundary. Long analyses can overlap an npm // upgrade, rebuilt dist tree, or native dependency replacement; stamping @@ -4744,6 +4916,7 @@ async function runFullAnalysisInner( } } await removeColdParseRebuildDir(coldParseRebuildDir, true); + discardCachedEmbeddingSpill(); if (liveIndexMutationStarted) { // Preserve the original error identity/prototype: callers distinguish // IndexLockTimeoutError and other domain failures with `instanceof`. diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index e49044b7e..a29b17273 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -769,7 +769,9 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid // v101 (#3294 review): Rust bare-keyword glob imports retain crate/self/super // instead of an empty target path; restricted pub(...) imports are no longer // captured as unrestricted reexports. Re-extract both facts on warm indexes. -const SCHEMA_BUMP = 101; +// v102: ParsedFile gained callResultAssignmentSites; old durable shards do +// not carry the exact assignment identity required by return-type replay. +const SCHEMA_BUMP = 102; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/src/storage/parsedfile-store.ts b/gitnexus/src/storage/parsedfile-store.ts index 58c336c95..4233850d8 100644 --- a/gitnexus/src/storage/parsedfile-store.ts +++ b/gitnexus/src/storage/parsedfile-store.ts @@ -53,6 +53,7 @@ import path from 'node:path'; import v8 from 'node:v8'; import vm from 'node:vm'; import type { + CallResultAssignmentSite, CallableFlowSite, ParsedFile, ReferenceSite, @@ -352,20 +353,26 @@ export const loadParsedFilesForPaths = async ( rejectedFiles++; continue; } + const assignments = sanitizeCallResultAssignmentSites(pf.callResultAssignmentSites); + if (assignments === undefined) { + rejectedFiles++; + continue; + } const chains = sanitizeReceiverChains(pf.referenceSites); if (chains === undefined) { rejectedFiles++; continue; } - if (flow.dropped === 0 && chains.dropped === 0) { + if (flow.dropped === 0 && assignments.dropped === 0 && chains.dropped === 0) { out.set(pf.filePath, pf); } else { - droppedSites += flow.dropped; + droppedSites += flow.dropped + assignments.dropped; droppedChains += chains.dropped; filesWithDroppedSites++; out.set(pf.filePath, { ...pf, ...(flow.dropped === 0 ? {} : { callableFlowSites: flow.sites }), + ...(assignments.dropped === 0 ? {} : { callResultAssignmentSites: assignments.sites }), ...(chains.dropped === 0 ? {} : { referenceSites: chains.sites }), }); } @@ -401,6 +408,19 @@ function sanitizeCallableFlowSites( return { sites, dropped: value.length - sites.length }; } +function sanitizeCallResultAssignmentSites( + value: unknown, +): { sites: readonly CallResultAssignmentSite[] | undefined; dropped: number } | undefined { + if (value === undefined) return { sites: undefined, dropped: 0 }; + if (!Array.isArray(value)) return undefined; + const bounded = + value.length > MAX_CALLABLE_FLOW_SITES_PER_FILE + ? value.slice(0, MAX_CALLABLE_FLOW_SITES_PER_FILE) + : value; + const sites = bounded.filter(isValidCallResultAssignmentSite); + return { sites, dropped: value.length - sites.length }; +} + /** * Untrusted-boundary handling for the compact `receiverChain` on a reference site. * @@ -503,6 +523,15 @@ function isValidCallableFlowSite(value: unknown): value is CallableFlowSite { } } +function isValidCallResultAssignmentSite(value: unknown): value is CallResultAssignmentSite { + return ( + isRecord(value) && + isValidRange(value.callSite) && + isBoundedString(value.inScope) && + isBoundedString(value.lhs) + ); +} + function isValidOperand(value: unknown): boolean { if (!isRecord(value)) return false; return ( diff --git a/gitnexus/src/storage/repo-meta.ts b/gitnexus/src/storage/repo-meta.ts index 159e6a4a8..db7b2a7a7 100644 --- a/gitnexus/src/storage/repo-meta.ts +++ b/gitnexus/src/storage/repo-meta.ts @@ -617,6 +617,22 @@ export interface RepoMeta { */ hasCallSummary?: boolean; }; + /** + * The process-detection budget this index's Community/Process rows were + * built under (#3313). Compared on the next analyze so a budget-only + * config change re-detects flows without `--force`. `maxProcesses: null` + * means the dynamic `symbols / 10` formula. Absent on pre-#3313 metas: + * that absence matches default/dynamic knobs (backfill) and mismatches + * any explicit override. + */ + processDetection?: { + maxProcesses: number | null; + maxProcessBranching: number; + maxProcessTraceDepth: number; + maxEntryPointCandidates: number; + /** Live Community/Process rows are not certified under this stamp (#3322). */ + uncertified?: true; + }; } /** diff --git a/gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Builder.swift b/gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Builder.swift new file mode 100644 index 000000000..5b15f19cb --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Builder.swift @@ -0,0 +1,5 @@ +public extension Outer.Container { + static func makeEntry() -> Entry { + Entry(id: 1, text: "sample") + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Standalone.swift b/gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Standalone.swift new file mode 100644 index 000000000..7f0d964f0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Standalone.swift @@ -0,0 +1,3 @@ +struct Entry { + let enabled: Bool +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Types.swift b/gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Types.swift new file mode 100644 index 000000000..bd7f0806d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-nested-constructor-extension/Types.swift @@ -0,0 +1,8 @@ +public enum Outer { + public struct Container { + public struct Entry { + let id: Int + let text: String + } + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/AUnrelated.swift b/gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/AUnrelated.swift new file mode 100644 index 000000000..da849ee4d --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/AUnrelated.swift @@ -0,0 +1,13 @@ +struct OtherStore { let value: Int } + +struct OtherScenario { + private func makeStore(currentValue: Int) -> OtherStore { OtherStore(value: currentValue) } +} + +enum OtherValue { + private static func makeValue(year: Int, month: Int, day: Int) -> Int { -1 } +} + +enum OtherItems { + private static func insertItem(into store: Store) -> String { "decoy" } +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/Scenario.swift b/gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/Scenario.swift new file mode 100644 index 000000000..d6d8fedd0 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/Scenario.swift @@ -0,0 +1,8 @@ +struct Scenario: ScenarioSupport { + func run() -> String { + let store = makeStore() + let value = makeValue(year: 2, month: 3, day: 4) + let item = insertItem(into: store, count: 1) + return "\(store.worker.execute()):\(value):\(item)" + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/Support.swift b/gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/Support.swift new file mode 100644 index 000000000..596ba1472 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/swift-protocol-extension-implicit-self/Support.swift @@ -0,0 +1,13 @@ +protocol ScenarioSupport {} + +struct Worker { + func execute() -> String { "worker" } +} + +struct Store { let worker: Worker } + +extension ScenarioSupport { + func makeStore(observer: Int? = nil) -> Store { Store(worker: Worker()) } + func makeValue(year: Int, month: Int, day: Int) -> Int { year + month + day } + func insertItem(into store: Store, count: Int = 1) -> String { "item" } +} diff --git a/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json b/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json index dd1540ec7..1d67db04f 100644 --- a/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/swift-captures-golden/expected-captures.json @@ -1,31 +1,31 @@ { "swift-abstract-dispatch/Sources/App.swift": { - "captureGroups": 10, - "digest": "0ec4bb27184ce68dca2ef6bef58c23353b363be9aa483d0bbea5321bccf85ae2" + "captureGroups": 12, + "digest": "29e6acb3afba30eca11eb947812e834196a01dceac7eff131710405b9f5963fa" }, "swift-abstract-dispatch/Sources/Repository.swift": { "captureGroups": 26, - "digest": "d8a8c59b7236d6b25a063b8584637d5c89a86cf022776272f80aeb4f1e4826b9" + "digest": "6978cdb9fc70bdba0b640d547e2fb0c4e21c96463303e91612ef30ba1597b3c4" }, "swift-await-try/App.swift": { - "captureGroups": 13, - "digest": "7ba2042b0cb0a6e5c5d459dc460c509ace477b0ce97bec0cc8aaf981f320e69a" + "captureGroups": 15, + "digest": "6d3bef6d2c98dfaf64c6cb728a54aad87efcc16d7a8ce5aeb1cc2e1207b4d1a8" }, "swift-await-try/Models.swift": { "captureGroups": 21, - "digest": "df2a06182275e023c6f40e978f60a0ff933fccc711d7c15f4a2434e8e282a653" + "digest": "c00efbc5b57cce03a5847b304f0956a424ccc345309c8907cd12e5c32449ced1" }, "swift-call-result-binding/App.swift": { - "captureGroups": 7, - "digest": "979534a46ee420a7a5dfd6b029c9fe5d67ca1b00f3bdc5e8977d72a1c316b75d" + "captureGroups": 8, + "digest": "ae788e37585c57c13bdb29cce48a0fbd1bcdbfa91ba415bdbc675945e500919d" }, "swift-call-result-binding/Models.swift": { "captureGroups": 15, - "digest": "d312b4003cbe7f3eec8e1b6f924b0e1afb7023ca086ec26ef0ad69078cc67925" + "digest": "f192ae885ea3de848f3b93acf7672d91b0118800cb0c6513628a6c09c26fadbf" }, "swift-child-extends-parent/Sources/App.swift": { - "captureGroups": 10, - "digest": "9532b3989cd104dd468deb8d671e727cdf944ebb858e002072875519a014e968" + "captureGroups": 11, + "digest": "4841a7a147f5cac64ed70af845534845bf34701af4bc55a6a9f02fb09ad4b716" }, "swift-child-extends-parent/Sources/Child.swift": { "captureGroups": 4, @@ -33,55 +33,55 @@ }, "swift-child-extends-parent/Sources/Parent.swift": { "captureGroups": 7, - "digest": "09d26410736d5ca5ec8dfe598212e1eefc64075bc2199fda170bb08a478392a4" + "digest": "705670271274ef3f484a4974aba743d8871c8e0ba41afd8bcf07a48225c12880" }, "swift-class-func-receiver/Service.swift": { "captureGroups": 27, "digest": "5d7e5b278bdc94cc2014ebf2f8c975afb10aa80bc2e1bd62ec5559a102fc8e41" }, "swift-constructor-fallback/App.swift": { - "captureGroups": 7, - "digest": "782cf85fcfe24b2baea6226f09226d23adc2bab591380bd33600cfe8509dbb83" + "captureGroups": 8, + "digest": "c5b2e7f8ea76636a45d46ecee6b44f0687fc6fe2f983bab0d98f8ce2a5c16828" }, "swift-constructor-fallback/Service.swift": { "captureGroups": 7, - "digest": "5744bba4c9b821b2264dea36de3e864723f8ca1be9face21ec9de55117a9e7a5" + "digest": "083dd54d6999b18bd96ee51503d5b63957e49f981199022d27112cf9478f89e0" }, "swift-constructor-type-inference/Models/Repo.swift": { "captureGroups": 15, - "digest": "e8ea96d49fc2113e29b88961686e39472b92481e929b3f887d53b65201e2e7bb" + "digest": "e5f29bb46ce39f7fcf98a53378ca00825134350afb6c81dd9fe4ff07e1201348" }, "swift-constructor-type-inference/Models/User.swift": { "captureGroups": 15, - "digest": "fe1039891719c640bfa7d362040460addcf744eed1350521cbcc7c9399395087" + "digest": "349b1dc04ef00d74058c7e8e7431cd19529c77ffeac5dac76ee7fe5a4ff39f53" }, "swift-constructor-type-inference/Services/App.swift": { - "captureGroups": 12, - "digest": "a9c40a2dcb51c6e6a9adbaa869183eed90a81302729d4d7a0c300084ea2978e1" + "captureGroups": 14, + "digest": "54cf81184c5017a96c983ee6c5e0e46efa99b1fd4eb67a77d911a5303094173a" }, "swift-enum-members/Direction.swift": { "captureGroups": 18, - "digest": "bd0bf779b08305fcd7dfadfcad45319123393aadc17044784314a7b34cb9e6ae" + "digest": "9eb1209d4e89040515e21c0efa17490c58232aa8f5f853ca210e6c1e0470d4cc" }, "swift-export-visibility/App.swift": { - "captureGroups": 10, - "digest": "6be49190e8a120ed1c3c3812afaa717f60fc12dae82d4b1b7158a358c53ea75a" + "captureGroups": 11, + "digest": "a0ef5984650c2220865999fba38d0803533f1c41d9941031d2b8c25e894ae84f" }, "swift-export-visibility/Visible.swift": { "captureGroups": 15, - "digest": "c8515702b94de2f9e3ad009daec61bad6f9328fb39796e57ec03348438446c4c" + "digest": "a0ef7c53a9cc19836afbe94c9bd5f32cabf1ce2756bee4e36267b5a64d1b66f0" }, "swift-extension-dedup/App.swift": { - "captureGroups": 7, - "digest": "f7062b53f33ec548a2ba5836ef5507b4c662d74ac4935dc654e6f95081f6fcbd" + "captureGroups": 8, + "digest": "4c9583519283afe6f7b80261cc2105781b86ea90bae69882468a71276bbe162c" }, "swift-extension-dedup/Product.swift": { "captureGroups": 15, - "digest": "c0f08c41829db294b2c89876a329cfffd7f3fb0a4b2bca315ae867a1d263a068" + "digest": "aaa046017c54533f938ec03669a80ef4b541fd32dad968ec7a339a6438f860b3" }, "swift-extension-dedup/ProductExtensions.swift": { "captureGroups": 8, - "digest": "228f2f32494a51fb371c754d9ae11a94e7181a14d875a339c631e26f82df542e" + "digest": "faa5f45d1bbad8478902814d100d776681b2e9015c7bfd519b432effdfb6f915" }, "swift-field-types/App.swift": { "captureGroups": 7, @@ -89,7 +89,7 @@ }, "swift-field-types/Models.swift": { "captureGroups": 20, - "digest": "cad215dd5ce4d0c93056ec5600663b8d17eebbefa06d09675fb64392196531ab" + "digest": "cb40d6eee9315b77bb6f1b1f82644a3636e7ef9aa30cf5edddd55d845b832486" }, "swift-for-loop-inference/App.swift": { "captureGroups": 5, @@ -105,39 +105,39 @@ }, "swift-if-let-guard-let/Models.swift": { "captureGroups": 19, - "digest": "51d6986620c07de553b2c8d848d908899a96c7174a7ae56bf68c7de930c8d5c5" + "digest": "9cf6d8e36976de137d378e2822e0883c7c33a869bc6615b632ee1b7433fde89e" }, "swift-implicit-imports/App.swift": { - "captureGroups": 7, - "digest": "5a423f851ee81956f48209e618c71668394e2a7312d812656a7f326233bc1387" + "captureGroups": 8, + "digest": "c2a0718ef2ef0f686a36c247bc7a37a64dbfc38f49c400dcd41462050a2e75f4" }, "swift-implicit-imports/Models.swift": { "captureGroups": 7, - "digest": "995e604bfd03b42eaeabff130fd1671e524832c64ee52f01cd193a88283d3764" + "digest": "aff5d14d229b15b0806531ebd6db27600b358a6a6fd57a481893e702e3839eb7" }, "swift-init-cross-file/User.swift": { "captureGroups": 18, - "digest": "13d60237fb85ddeba695f3e5f0a202c327309bf159a04e696531c419c42e8cf1" + "digest": "005c808719efd792c7ee8f73b863047db27ba57dd7c3c94e0e550186c4274c4e" }, "swift-init-cross-file/main.swift": { - "captureGroups": 8, - "digest": "3007e4849cf77cc6cb360a1dbf70abec414a408076020f4730d01b8a80c1ddd3" + "captureGroups": 9, + "digest": "c4d442b67247406de3b1158b958f6f3e294f11c0bbb360ed32489582769a1187" }, "swift-member-write-access/App.swift": { "captureGroups": 14, - "digest": "f3c28a41c270cea237598bd20c688ba6ab791e3701e85c66bfe3aff7ecfb1ec8" + "digest": "7be68b7e510fefac99c94a4ebab3eb96556a0e587155b6c82b7602705e270987" }, "swift-member-write-access/Models.swift": { "captureGroups": 26, - "digest": "376359897f8706015f702b2f3346ad4ca4e78e9f11ae6425746ae57116c2cc51" + "digest": "0eb01f35af949541bf63f781c0d23d2b890a09a858575d2d0922093657bb7661" }, "swift-method-enrichment/Sources/Animal.swift": { "captureGroups": 24, - "digest": "48bd53d1e69d4a48569985296f5aa44d4b41ff474566445f4e3d33390165c84d" + "digest": "49eec3df81637274541eb67fd2a640d22ec2166a17854d3f7be179294eee1386" }, "swift-method-enrichment/Sources/App.swift": { - "captureGroups": 10, - "digest": "906c1f026eb183306a19eff5fafd888fc8982434e2ab07bc886100b99653359b" + "captureGroups": 13, + "digest": "af60edc088ece4298751041cd8cb3fdf3c2a3c03f14f78fcead821b275260f7c" }, "swift-multi-if-let/App.swift": { "captureGroups": 17, @@ -145,19 +145,19 @@ }, "swift-multi-if-let/Models.swift": { "captureGroups": 24, - "digest": "e6add0cb3679cb2800094e41d4afc66c5d714c19ffefc38edfb5686ed9d46d51" + "digest": "b681c53b823b275dd517d29244b8f1b70094e69159d9e49193b6d9fd117385cc" }, "swift-multidir-target/Package.swift": { - "captureGroups": 5, - "digest": "7c27b1db8b34bf3e81963c29f9f51f1226c1012b7a028c6207209914412b9d5f" + "captureGroups": 6, + "digest": "b7401530c522ff647a7a1d958a35314f669d5dfad2b39578fb9866d2f2d76e58" }, "swift-multidir-target/Sources/Alpha/Core/User.swift": { "captureGroups": 7, "digest": "a67cb60787680595b43af3fdd371ad84237e17fa7de928610a8c14e8ac062043" }, "swift-multidir-target/Sources/Alpha/Entry/App.swift": { - "captureGroups": 7, - "digest": "3f5df4e88a54d05032cb00c999551cb4f41e0782616ec0cdbf353f3b80a4404d" + "captureGroups": 8, + "digest": "6670c64ae61885694d980ab798e5bab0664bf6b95727ce8a70b08749790b8052" }, "swift-multidir-target/Sources/Beta/Core/User.swift": { "captureGroups": 7, @@ -168,8 +168,20 @@ "digest": "356dcd75eb93636607d406e26ce0e62dfa1fe9c78dcd1f950adca304a14d3ad1" }, "swift-multifolder-nopackage/Services/App.swift": { + "captureGroups": 8, + "digest": "469f895fc72297a08eb0c7ddbf7fdbb812c9296076b4797cd9262e2b7ab59a94" + }, + "swift-nested-constructor-extension/Builder.swift": { "captureGroups": 7, - "digest": "7ed3ccfa724fd58d3c9e10a176101364f2a01f20a7a87f9e3d76b715e4a307d8" + "digest": "8e4e01966ad669ef2e8ea6fc032053540588507e75ecabe142ab5f0b35211cb2" + }, + "swift-nested-constructor-extension/Standalone.swift": { + "captureGroups": 5, + "digest": "4ac71fb5f08cea3ab32a23ae41dd3bf3a215880cbb826b83d1c7427fa837d3c2" + }, + "swift-nested-constructor-extension/Types.swift": { + "captureGroups": 11, + "digest": "489630892c43db4b9440e6de387d91f3b84ed82702cfffab11db2e6afcce5da1" }, "swift-nested-extension/Extension.swift": { "captureGroups": 7, @@ -180,28 +192,40 @@ "digest": "2738a4d77473a2641166b95bc1107f99afca55e9b9d4186eb102e053d378d8ab" }, "swift-overload-dispatch/App.swift": { - "captureGroups": 7, - "digest": "db8515d0d61419e827767ced61b6bb1949e672b8df11c35cc96882d60f007e07" + "captureGroups": 8, + "digest": "da644f9163f61ef3ec0a000d31dd93b5f0dd11b33def9d96ffe425ff4288b922" }, "swift-overload-dispatch/Repository.swift": { "captureGroups": 11, - "digest": "07c6509becf6ae01ae336c53ddeeff38b486bea4a7facad0edf129eb67401408" + "digest": "0dadccb34bc2cc5150bc7d2ac878c2d8f6e1526738e5b5a6301161ff2fe6ae3f" }, "swift-overload-dispatch/SqlRepository.swift": { "captureGroups": 28, - "digest": "59d11755ba6796355751608bbf74bd03b99edd6c601c51dcd3e46ff33a4af659" + "digest": "891d106fd673247c89ffbcaf7fe4f6103e2ed79cac09bcf5c3933385c77c287f" }, "swift-parent-resolution/Sources/Models/BaseModel.swift": { "captureGroups": 7, - "digest": "0d9056d5dbd480f04ad915c628c81a71e9c1b4ec8981d8027d3a4edcd0f06427" + "digest": "0dbf9f9aff651e5231d87b31d74d0c9d37b30ad6ecdf9e1913cb0da3539bbe00" }, "swift-parent-resolution/Sources/Models/Serializable.swift": { "captureGroups": 6, - "digest": "502ce9136303235cb8e1cdd459afeb4bb76c658d186c63731a3dbb3c42fadac9" + "digest": "5551bfbb93cacb230bb635880af1d87fcaffdff6c54173ff1db5aa84079cef36" }, "swift-parent-resolution/Sources/Models/User.swift": { "captureGroups": 10, - "digest": "9bc07345adb74d5e2b4711354175825ef00918dbd61785e472d091f14567f9d5" + "digest": "014f2ea08b5a45692b8fbd2dc6cc3e16004908fe6aa229535f59c59c7a4c6a93" + }, + "swift-protocol-extension-implicit-self/AUnrelated.swift": { + "captureGroups": 33, + "digest": "2df68574e940bc9d384517dc8ab845525e05a0cd8612973360e4c5c2619622dc" + }, + "swift-protocol-extension-implicit-self/Scenario.swift": { + "captureGroups": 23, + "digest": "e0713e3117f5c668b2a96fde7c1e62457e433b9072b8ed4ad9b96af501f0cf39" + }, + "swift-protocol-extension-implicit-self/Support.swift": { + "captureGroups": 41, + "digest": "60b645833f81961448817fad0c4c5431b0cb2d19e5d37aa711a65aa600e7438d" }, "swift-protocol-property/Repository.swift": { "captureGroups": 7, @@ -213,34 +237,34 @@ }, "swift-qualified-base/Sources/Outer.swift": { "captureGroups": 9, - "digest": "1ff59349dc4d45a5e1f6fdebf19caf65427d3904eddbc50efeb0879e31574a17" + "digest": "0c6af8f37f15553ed2d6199689994606ae580026e7a608d534cea5834021c89d" }, "swift-return-type-inference/App.swift": { - "captureGroups": 21, - "digest": "ec0a14090e0240bdd52a955cf7c95c2bc994085ca1482911dd882c5cdaa17c81" + "captureGroups": 23, + "digest": "6e342715fd1d2fa576a713fdf3bdd4f1367566182442b8398f6b35b861946a8d" }, "swift-return-type-inference/Models.swift": { "captureGroups": 29, - "digest": "bc539da3c97ac77543b7a1cce457c6ec0f8072c1e5861bfc027cf8fe8f4b541c" + "digest": "1616d99b6c529274c0fe33645b58c69a507162353de96694d90d7539e6c69569" }, "swift-return-type/App.swift": { - "captureGroups": 7, - "digest": "979534a46ee420a7a5dfd6b029c9fe5d67ca1b00f3bdc5e8977d72a1c316b75d" + "captureGroups": 8, + "digest": "ae788e37585c57c13bdb29cce48a0fbd1bcdbfa91ba415bdbc675945e500919d" }, "swift-return-type/Models.swift": { "captureGroups": 21, - "digest": "dc230a5f6085165fb717632f62b65fd3c99825b5fcc7a3a597a8d9143627729e" + "digest": "f3daaadcce524d8c44ef4077bb858a1124e3f9ef9525d5c84fb1288f79e97c4a" }, "swift-self-this-resolution/Sources/Models/Repo.swift": { "captureGroups": 7, - "digest": "61fe84aa9826726ee02bce54c62507cc4f27e56e9be2d57c10f55dd8043e13d9" + "digest": "44f6a8907accee7b3dfc6d35de6a70d3e8a154e1bc602f8545aec3bcd2f9af9c" }, "swift-self-this-resolution/Sources/Models/User.swift": { "captureGroups": 11, - "digest": "1841837c805e19746138dbb80ccdfccbce483d68d1e7ea08d92daec255cfbdc7" + "digest": "133a8a1186742b19b7408703262fed82ebdf2aa48b9728ae78aa3b26b5b06428" }, "synthetic:dao-20": { "captureGroups": 361, - "digest": "aa1747e2f297f5a5a71bd0361273f5505adf93db3d4a2ae3111916bc109c1c7b" + "digest": "56dde1e182164a4ad86de0254e7b683596d9b0340ec941fc09cdff598f16c6dc" } } diff --git a/gitnexus/test/helpers/detect-reject-warning.ts b/gitnexus/test/helpers/detect-reject-warning.ts new file mode 100644 index 000000000..abbea7852 --- /dev/null +++ b/gitnexus/test/helpers/detect-reject-warning.ts @@ -0,0 +1,6 @@ +/** + * Matches the detect-reject warning emitted by `run-analyze` after lock settle. + * Shared so unit and integration tests keep one regex. + */ +export const isDetectRejectWarning = (message: string): boolean => + /^Warning:.*not a usable index label.*continuing\.$/.test(message); diff --git a/gitnexus/test/helpers/ignore-set-source.ts b/gitnexus/test/helpers/ignore-set-source.ts index 04db55dd6..584faf839 100644 --- a/gitnexus/test/helpers/ignore-set-source.ts +++ b/gitnexus/test/helpers/ignore-set-source.ts @@ -4,18 +4,17 @@ * Those sets are module-private, and exporting them purely to be testable would * widen a production surface to satisfy a test — the call * `receiver-twin-list-drift.test.ts` documents. So the guards read the source - * instead, through the TypeScript parser the repo already vendors and already - * uses this way (`literal-collectors.ts`, `query-determinism-guard.test.ts`, - * `cli-index-help.test.ts`). + * instead, through `@babel/parser` (`parse-typescript-source.ts`). * - * Using the real parser is what makes the guards trustworthy. A text scanner has - * to decide whether a delimiter opens a comment or sits inside a string, and it - * gets that wrong in both directions here: the ignore-list comments quote paths - * and carry an apostrophe (`Next.js's`), while a glob string such as `'** / *'` - * contains a comment-open sequence. It also has to guess which bracket belongs - * to the declaration rather than to a type annotation. Each of those is a way to - * silently read fewer members — and a guard that quietly stops seeing members is - * the exact defect these guards exist to catch. + * Using an AST parser for TypeScript syntax is what makes the guards + * trustworthy. A text scanner has to decide whether a delimiter opens a comment + * or sits inside a string, and it gets that wrong in both directions here: the + * ignore-list comments quote paths and carry an apostrophe (`Next.js's`), while + * a glob string such as `'** / *'` contains a comment-open sequence. It also + * has to guess which bracket belongs to the declaration rather than to a type + * annotation. Each of those is a way to silently read fewer members — and a + * guard that quietly stops seeing members is the exact defect these guards + * exist to catch. * * `setEntries` therefore refuses anything that is not a plain list of string * literals, rather than skipping the members it cannot resolve. @@ -23,7 +22,8 @@ import { readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import ts from 'typescript'; +import * as t from '@babel/types'; +import { forEachChild, nodeText, parseTypeScript } from './parse-typescript-source.js'; const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); @@ -55,46 +55,44 @@ export const readSource = (file: string): string => readFileSync(file, 'utf8'); * concatenation, a computed value). */ export const setEntries = (source: string, setName: string): string[] => { - const sourceFile = ts.createSourceFile( - 'ignore-set-source.ts', - source, - ts.ScriptTarget.Latest, - true, - ); + const { ast } = parseTypeScript('ignore-set-source.ts', source); - let elements: ts.NodeArray | undefined; - const visit = (node: ts.Node): void => { + let elements: t.ArrayExpression['elements'] | undefined; + const visit = (node: t.Node): void => { if ( elements === undefined && - ts.isVariableDeclaration(node) && - ts.isIdentifier(node.name) && - node.name.text === setName && - node.initializer !== undefined && - ts.isNewExpression(node.initializer) && - node.initializer.arguments?.length === 1 && - ts.isArrayLiteralExpression(node.initializer.arguments[0]) + t.isVariableDeclarator(node) && + t.isIdentifier(node.id) && + node.id.name === setName && + node.init !== undefined && + node.init !== null && + t.isNewExpression(node.init) && + node.init.arguments.length === 1 && + t.isArrayExpression(node.init.arguments[0]) ) { - elements = node.initializer.arguments[0].elements; + elements = node.init.arguments[0].elements; return; } - ts.forEachChild(node, visit); + forEachChild(node, visit); }; - visit(sourceFile); + visit(ast); if (elements === undefined) { throw new Error(`${setName} is not declared as \`new Set([...])\` — update this test`); } - const unresolvable = elements.filter((element) => !ts.isStringLiteral(element)); + const unresolvable = elements.filter((element) => !t.isStringLiteral(element)); if (unresolvable.length > 0) { + const first = unresolvable[0]; + const excerpt = first && typeof first === 'object' ? nodeText(source, first) : String(first); throw new Error( `${setName} holds ${unresolvable.length} member(s) that are not plain string literals ` + - `(first: \`${unresolvable[0].getText(sourceFile)}\`). A source-reading guard cannot resolve ` + + `(first: \`${excerpt}\`). A source-reading guard cannot resolve ` + `those, so switch this set to a runtime assertion rather than letting the guard see fewer members.`, ); } - return elements.map((element) => (element as ts.StringLiteral).text); + return elements.map((element) => (element as t.StringLiteral).value); }; /** diff --git a/gitnexus/test/helpers/literal-collectors.ts b/gitnexus/test/helpers/literal-collectors.ts index a6bb41b29..6172b739b 100644 --- a/gitnexus/test/helpers/literal-collectors.ts +++ b/gitnexus/test/helpers/literal-collectors.ts @@ -9,7 +9,7 @@ * FOUR modes (plan KTD3): * 1. Config reflection — import `*-extractors/configs/*.ts`, read each * config-shaped export's node-type-array keys. Exact `config.language`. - * 2. AST scan (`typescript` parser, no type-checker) over the EXTRACTION + * 2. AST scan (TypeScript 7 program SourceFile; no Checker gating) over the EXTRACTION * surface — `*-extractors/**`, every `languages//captures.ts`, and * `export-detection.ts`. Collected BY CONSUMPTION SITE: `.type === '..'`, * `childForFieldName('..')` (capturing the receiver node type when an @@ -26,7 +26,7 @@ * `ingestion/` (e.g. `type-env.ts`). These files MIX SyntaxNode `.type` with * resolved-symbol `.type` (kinds like 'Class'), so a literal is collected * ONLY when its `.type` / `childForFieldName` receiver resolves to a - * tree-sitter SyntaxNode (via the TS TypeChecker). Per-`languages//` + * tree-sitter SyntaxNode (via the TypeScript 7 Checker). Per-`languages//` * files tag to that one grammar; shared (non-`languages//`) files tag * to the full gated set (valid-if-any). * @@ -35,8 +35,8 @@ * * Test-only file: allowed to name languages. */ -import ts from 'typescript'; -import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'; +import * as ts from './ts7-ast.js'; +import { readdirSync, existsSync, statSync, realpathSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { SupportedLanguages } from '../../src/config/supported-languages.js'; @@ -449,11 +449,16 @@ function receiverNodeTypeOf(call: ts.CallExpression, sf: ts.SourceFile): string return receiverMutatedIn(recvText, scope) ? undefined : found; } -function scanFile(file: string): ScanResult { +function scanFile( + file: string, + built: { program: ts.Program; checker: ts.Checker } | null, +): ScanResult { const relPath = rel(file); const langs = fileLanguages(relPath); - const src = readFileSync(file, 'utf8'); - const sf = ts.createSourceFile(file, src, ts.ScriptTarget.Latest, true); + const empty: ScanResult = { nodeTypes: [], fields: [] }; + if (!built) return empty; + const sf = programSourceFile(built.program, file); + if (!sf) return empty; const nodeTypes: CollectedNodeType[] = []; const fields: CollectedField[] = []; @@ -558,8 +563,9 @@ function scanFile(file: string): ScanResult { function collectInCodeLiterals(): ScanResult { const nodeTypes: CollectedNodeType[] = []; const fields: CollectedField[] = []; + const built = buildProgram(); for (const file of mode2Files()) { - const r = scanFile(file); + const r = scanFile(file, built); nodeTypes.push(...r.nodeTypes); fields.push(...r.fields); } @@ -571,7 +577,7 @@ function collectInCodeLiterals(): ScanResult { // binding/interpret/arity/import-decomposer/...), the production path for // migrated languages. These files mix SyntaxNode `.type` (grammar nodes) with // resolved-symbol `.type` (kinds like 'Class'); a naive scan would false- -// positive on the latter. So this mode uses the TS TypeChecker to collect a +// positive on the latter. So this mode uses the TypeScript 7 Checker to collect a // literal ONLY when its `.type` receiver / childForFieldName target resolves to // a tree-sitter SyntaxNode. Per-language dir => grammar (no cross-lang ambiguity). // --------------------------------------------------------------------------- @@ -613,25 +619,35 @@ function resolutionLayerFiles(): { file: string; langs: SupportedLanguages[] }[] } let _program: ts.Program | null = null; -let _checker: ts.TypeChecker | null = null; -function buildProgram( - rootFiles: string[], -): { program: ts.Program; checker: ts.TypeChecker } | null { +let _checker: ts.Checker | null = null; +function buildProgram(): { program: ts.Program; checker: ts.Checker } | null { if (_program && _checker) return { program: _program, checker: _checker }; try { - const cfg = ts.readConfigFile(join(REPO_ROOT, 'tsconfig.json'), ts.sys.readFile); - const parsed = ts.parseJsonConfigFileContent(cfg.config ?? {}, ts.sys, REPO_ROOT); - const options: ts.CompilerOptions = { ...parsed.options, noEmit: true, skipLibCheck: true }; - _program = ts.createProgram(rootFiles, options); - _checker = _program.getTypeChecker(); + const api = new ts.API({ cwd: REPO_ROOT }); + const snapshot = api.updateSnapshot({ + openProjects: [join(REPO_ROOT, 'tsconfig.json')], + }); + const project = snapshot.getProjects()[0]; + if (!project) return null; + _program = project.program; + _checker = project.checker; return { program: _program, checker: _checker }; } catch { return null; } } +const sourceFileByPath = new Map(); + +function programSourceFile(program: ts.Program, file: string): ts.SourceFile | undefined { + if (sourceFileByPath.has(file)) return sourceFileByPath.get(file); + const sf = program.getSourceFile(file) ?? program.getSourceFile(realpathSync(file)); + sourceFileByPath.set(file, sf); + return sf; +} + /** True when `node`'s resolved type is (or includes) a tree-sitter SyntaxNode. */ -function isSyntaxNodeReceiver(checker: ts.TypeChecker, node: ts.Node): boolean { +function isSyntaxNodeReceiver(checker: ts.Checker, node: ts.Node): boolean { try { const s = checker.typeToString(checker.getTypeAtLocation(node)); return /\bSyntaxNode\b/.test(s); @@ -647,7 +663,7 @@ function collectResolutionLayerLiterals(): ScanResult { const nodeTypes: CollectedNodeType[] = []; const fields: CollectedField[] = []; const entries = resolutionLayerFiles(); - const built = buildProgram(entries.map((e) => e.file)); + const built = buildProgram(); if (!built) { resolutionLayerProgramOk = false; return { nodeTypes, fields }; @@ -655,7 +671,7 @@ function collectResolutionLayerLiterals(): ScanResult { const { program, checker } = built; for (const { file, langs } of entries) { - const sf = program.getSourceFile(file); + const sf = programSourceFile(program, file); if (!sf) continue; const relPath = rel(file); const constMembers = new Map(); diff --git a/gitnexus/test/helpers/parse-typescript-source.ts b/gitnexus/test/helpers/parse-typescript-source.ts new file mode 100644 index 000000000..d324d84d8 --- /dev/null +++ b/gitnexus/test/helpers/parse-typescript-source.ts @@ -0,0 +1,162 @@ +/** + * Parse-only TypeScript AST walks for tests. + * + * TypeScript 7.0 does not ship the classic Compiler API (`createSourceFile`). + * These guards only need syntax, so they parse with `@babel/parser` (already a + * CLI dependency) rather than spawning the native TypeScript 7 program API. + */ +import { parse } from '@babel/parser'; +import { + VISITOR_KEYS, + isBinaryExpression, + isNode, + isStringLiteral, + isTemplateLiteral, + type Comment, + type File, + type MemberExpression, + type Node, + type OptionalMemberExpression, + type TemplateLiteral, +} from '@babel/types'; + +export type AstNode = Node & { parent?: AstNode }; + +export interface ParsedSource { + ast: File & { parent?: AstNode }; + source: string; + fileName: string; +} + +const PARSE_PLUGINS: NonNullable[1]>['plugins'] = [ + 'typescript', + 'explicitResourceManagement', + 'importAttributes', + 'decoratorAutoAccessors', + ['decorators', { decoratorsBeforeExport: true }], +]; + +export function forEachChild(node: Node, visit: (child: AstNode) => void): void { + const keys = VISITOR_KEYS[node.type] ?? []; + for (const key of keys) { + const value = (node as unknown as Record)[key]; + if (Array.isArray(value)) { + for (const item of value) { + if (isNode(item)) visit(item); + } + } else if (isNode(value)) { + visit(value); + } + } +} + +/** Direct descendants in source order (does not include `node` itself). */ +export function collectDescendants(node: Node): AstNode[] { + const out: AstNode[] = []; + const visit = (child: AstNode): void => { + out.push(child); + forEachChild(child, visit); + }; + forEachChild(node, visit); + return out; +} + +export function staticMemberName( + node: MemberExpression | OptionalMemberExpression, +): string | undefined { + return node.computed || node.property.type !== 'Identifier' ? undefined : node.property.name; +} + +function templateLiteralText(node: TemplateLiteral): string | undefined { + if (node.expressions.length > 0) return undefined; + return node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw).join(''); +} + +/** Compile-time string from a literal, template without holes, or `+` chain. */ +export function staticStringValue(node: Node | undefined | null): string | undefined { + if (!node) return undefined; + if (isStringLiteral(node)) return node.value; + if (isTemplateLiteral(node)) return templateLiteralText(node); + if (isBinaryExpression(node) && node.operator === '+') { + const left = staticStringValue(node.left); + const right = staticStringValue(node.right); + if (left !== undefined && right !== undefined) return `${left}${right}`; + } + return undefined; +} + +function attachParents(node: AstNode, parent?: AstNode): void { + node.parent = parent; + forEachChild(node, (child) => attachParents(child, node)); +} + +export function parseTypeScript(fileName: string, source: string): ParsedSource { + const ast = parse(source, { + sourceFilename: fileName, + sourceType: 'unambiguous', + plugins: PARSE_PLUGINS, + errorRecovery: true, + attachComment: true, + ranges: true, + }) as File & { parent?: AstNode }; + attachParents(ast); + return { ast, source, fileName }; +} + +export function nodeStart(node: Node): number { + return node.start ?? 0; +} + +export function nodeEnd(node: Node): number { + return node.end ?? 0; +} + +export function nodeText(source: string, node: Node): string { + return source.slice(nodeStart(node), nodeEnd(node)); +} + +let lineAtSource = ''; +let lineAtStarts: number[] = [0]; + +function lineStarts(source: string): number[] { + if (source === lineAtSource) return lineAtStarts; + const starts = [0]; + for (let i = 0; i < source.length; i++) { + if (source[i] === '\n') starts.push(i + 1); + } + lineAtSource = source; + lineAtStarts = starts; + return starts; +} + +export function lineAt(source: string, position: number): number { + if (position <= 0) return 1; + const starts = lineStarts(source); + const pos = Math.min(position, source.length); + let lo = 0; + let hi = starts.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (starts[mid] <= pos) lo = mid; + else hi = mid - 1; + } + return lo + 1; +} + +export interface CommentRange { + pos: number; + end: number; +} + +function toRange(comment: Comment): CommentRange | undefined { + if (comment.start == null || comment.end == null) return undefined; + return { pos: comment.start, end: comment.end }; +} + +export function leadingCommentRanges(node: Node): CommentRange[] { + return (node.leadingComments ?? []).map(toRange).filter((range) => range !== undefined); +} + +export function trailingCommentRanges(node: Node): CommentRange[] { + return (node.trailingComments ?? []).map(toRange).filter((range) => range !== undefined); +} diff --git a/gitnexus/test/helpers/ts7-ast.ts b/gitnexus/test/helpers/ts7-ast.ts new file mode 100644 index 000000000..c2d077042 --- /dev/null +++ b/gitnexus/test/helpers/ts7-ast.ts @@ -0,0 +1,48 @@ +/** + * TypeScript 7 AST + Checker surface for test-only walks that need types. + * + * TypeScript 7.0 dropped the classic Compiler API (`createProgram` / + * `getTypeChecker`). The native `typescript/unstable/sync` client exposes the + * same Checker methods against the Go compiler, and `typescript/unstable/ast` + * is the matching syntax tree. + */ +export { SyntaxKind } from 'typescript/unstable/ast'; +export type { + CallExpression, + Expression, + Node, + PropertyAccessExpression, + SourceFile, + StringLiteral, +} from 'typescript/unstable/ast'; +export { + isArrayLiteralExpression, + isArrowFunction, + isAsExpression, + isBinaryExpression, + isCallExpression, + isCaseClause, + isConstructorDeclaration, + isFunctionDeclaration, + isFunctionExpression, + isGetAccessorDeclaration, + isIdentifier, + isIfStatement, + isMethodDeclaration, + isNewExpression, + isPostfixUnaryExpression, + isPrefixUnaryExpression, + isPropertyAccessExpression, + isSetAccessorDeclaration, + isStringLiteral, + isStringLiteralLikeNode as isStringLiteralLike, + isSwitchStatement, + isVariableDeclaration, +} from 'typescript/unstable/ast/is'; +export { API, type Checker, type Program } from 'typescript/unstable/sync'; + +import type { Node } from 'typescript/unstable/ast'; + +export function forEachChild(node: Node, visitor: (child: Node) => void): void { + node.forEachChild(visitor); +} diff --git a/gitnexus/test/integration/cli-limit-e2e.test.ts b/gitnexus/test/integration/cli-limit-e2e.test.ts index 65652260b..ec7a0d08c 100644 --- a/gitnexus/test/integration/cli-limit-e2e.test.ts +++ b/gitnexus/test/integration/cli-limit-e2e.test.ts @@ -32,12 +32,20 @@ const FIXTURE_SRC = path.resolve(testDir, '..', 'fixtures', 'mini-repo'); let MINI_REPO: string; let tmpParent: string; let suiteGitnexusHome: string; +/** False when setup analyze fell back to `--skip-fts` after CREATE_FTS_INDEX native-aborted. */ +let ftsIndexed = false; function cliEnv(extraEnv: Record = {}) { return { ...process.env, GITNEXUS_HOME: suiteGitnexusHome, NODE_OPTIONS: `${process.env.NODE_OPTIONS || ''} --max-old-space-size=8192`.trim(), + // Cold parse-worker loads every tree-sitter grammar before the ready + // handshake. The default 5s budget classifies that as a deterministic + // crash-loop on a loaded WSL/CI host (status 1) or the 60s spawnSync + // timeout kills the child first (status null). Sibling integration + // suites pin 60s. + GITNEXUS_WORKER_READY_TIMEOUT_MS: process.env.GITNEXUS_WORKER_READY_TIMEOUT_MS || '60000', ...extraEnv, }; } @@ -52,6 +60,22 @@ function runCliRaw(extraArgs: string[], cwd: string, timeoutMs = 30000) { }); } +function isNativeAbort(result: ReturnType): boolean { + return ( + result.signal === 'SIGSEGV' || + result.signal === 'SIGABRT' || + result.signal === 'SIGBUS' || + result.status === 139 + ); +} + +function isFatalAnalyzeHarness(result: ReturnType): boolean { + return ( + result.stderr?.includes('Worker script not found') === true || + result.stderr?.includes('deterministic crash-loop') === true + ); +} + /** * Parse stdout as JSON, returning null on failure (e.g., text output). */ @@ -116,14 +140,39 @@ beforeAll(() => { }, }); - // Run analyze to populate .gitnexus/ index (required for all tool commands) - const analyzeResult = runCliRaw(['analyze', '--force'], MINI_REPO, 60000); - if (analyzeResult.status !== 0) { + // Index once so every --limit command has a registered repo. Match cli-e2e: + // a tiny fixture analyzes in seconds on a quiet machine, but spawnSync + // status null is SIGTERM from the timeout under load (not an analyze + // exit). Retry timeouts; alreadyUpToDate makes a repeat cheap. + // + // CREATE_FTS_INDEX can SIGSEGV the analyze process on some WSL/native + // hosts (status null, signal SIGSEGV) even when `doctor` reports FTS + // LOAD-able. Do not retry that path — rebuild with --skip-fts so graph + // tools still run. query --limit needs BM25 and is skipped in that case. + let analyzeResult: ReturnType | undefined; + for (let attempt = 0; attempt < 3; attempt++) { + analyzeResult = runCliRaw(['analyze', '--force'], MINI_REPO, 90_000); + if (analyzeResult.status === 0) { + ftsIndexed = true; + break; + } + if (isFatalAnalyzeHarness(analyzeResult) || isNativeAbort(analyzeResult)) break; + } + if ( + analyzeResult && + !ftsIndexed && + isNativeAbort(analyzeResult) && + !isFatalAnalyzeHarness(analyzeResult) + ) { + analyzeResult = runCliRaw(['analyze', '--force', '--skip-fts'], MINI_REPO, 90_000); + } + if (!analyzeResult || analyzeResult.status !== 0) { + const err = analyzeResult?.error; throw new Error( - `Analyze failed (status ${analyzeResult.status}):\nstdout: ${analyzeResult.stdout}\nstderr: ${analyzeResult.stderr}`, + `Analyze failed (status ${analyzeResult?.status}, signal ${analyzeResult?.signal}, error ${err?.message ?? 'none'}):\nstdout: ${analyzeResult?.stdout}\nstderr: ${analyzeResult?.stderr}`, ); } -}); +}, 300_000); afterAll(() => { if (tmpParent) cleanupTempDirSync(tmpParent); @@ -357,6 +406,18 @@ describe('CLI --limit flag E2E', () => { // ─── query ────────────────────────────────────────────────────────────── describe('query --limit', () => { + beforeEach((ctx) => { + if (ftsIndexed) return; + if (process.env.GITNEXUS_REQUIRE_FTS === '1') { + throw new Error( + 'GITNEXUS_REQUIRE_FTS=1 but setup analyze native-aborted during CREATE_FTS_INDEX; ' + + 'query --limit cannot be verified without BM25.', + ); + } + ctx.skip( + 'query --limit needs BM25; CREATE_FTS_INDEX native-aborted and analyze fell back to --skip-fts', + ); + }); it('truncates processes to --limit 1', () => { // "message" matches logMessage / createLogEntry / formatLogEntry → 4 processes const limited = runJson([ diff --git a/gitnexus/test/integration/cli/update-notice.test.ts b/gitnexus/test/integration/cli/update-notice.test.ts index 0887d1c46..7e6171549 100644 --- a/gitnexus/test/integration/cli/update-notice.test.ts +++ b/gitnexus/test/integration/cli/update-notice.test.ts @@ -139,11 +139,16 @@ describe('CLI update notice subprocess behavior', () => { preload, `import fs from 'node:fs'; Object.defineProperty(process.stderr, 'isTTY', { value: true, configurable: true }); -globalThis.fetch = async () => { +// Mark the detached child at --import time, before tsx compiles the CLI. +// Writing this from fetch() raced a 30s poll against cold boot + lock +// acquisition on a loaded default-project worker (status: poll timeout). +if (process.argv.includes('__update-check')) { fs.writeFileSync(${JSON.stringify(started)}, ''); +} +globalThis.fetch = async () => { // Bounded so an abandoned child (test failed before releasing, temp home // already deleted) still exits instead of spinning forever. - const deadline = Date.now() + 60_000; + const deadline = Date.now() + 90_000; while (!fs.existsSync(${JSON.stringify(release)}) && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 25)); } @@ -176,19 +181,21 @@ globalThis.fetch = async () => { }); }); - // The parent already exited above, so reaching a still-parked child proves - // the refresh outlived it and was never awaited. + // The parent already exited above. `started` is written by the child's + // --import hook, so this wait is "did the detached process actually + // start?" not "did tsx finish compiling the CLI?" The fetch mock still + // parks until `release` so the cache cannot appear before we unblock it. const cache = path.join(home, 'update-check.json'); await expect.poll(() => fs.existsSync(started), { timeout: 30_000, interval: 50 }).toBe(true); expect(fs.existsSync(cache)).toBe(false); fs.writeFileSync(release, ''); - await expect.poll(() => fs.existsSync(cache), { timeout: 30_000, interval: 50 }).toBe(true); + await expect.poll(() => fs.existsSync(cache), { timeout: 60_000, interval: 50 }).toBe(true); expect(JSON.parse(fs.readFileSync(cache, 'utf8'))).toMatchObject({ latestVersion: '99.0.0', registry: 'https://registry.npmjs.org', }); - }, 90_000); + }, 120_000); it('prints the localized notice on a forced-TTY stderr and keeps stdout clean', () => { const home = tempHome(); diff --git a/gitnexus/test/integration/load-cached-embeddings-spill.test.ts b/gitnexus/test/integration/load-cached-embeddings-spill.test.ts new file mode 100644 index 000000000..99adb47d0 --- /dev/null +++ b/gitnexus/test/integration/load-cached-embeddings-spill.test.ts @@ -0,0 +1,113 @@ +/** + * Real-DB coverage for #3306: loadCachedEmbeddings must stream CodeEmbedding + * rows instead of getAll()+map(Number) of the whole table, and must be able + * to spill vectors so incremental analyze does not keep every embedding in + * the V8 heap. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { createTempDir, type TestDBHandle } from '../helpers/test-db.js'; +import { EMBEDDING_DIMS } from '../../src/core/lbug/schema.js'; +import { batchInsertEmbeddings } from '../../src/core/embeddings/embedding-pipeline.js'; +import { + disposeEmbeddingSpill, + materializeCachedEmbeddings, +} from '../../src/core/embeddings/embedding-restore-spill.js'; + +describe('loadCachedEmbeddings streaming (#3306)', () => { + let tmp: TestDBHandle | undefined; + + afterEach(async () => { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + try { + await adapter.closeLbug(); + } catch { + /* already closed */ + } + await tmp?.cleanup(); + tmp = undefined; + }); + + async function seedDb(rowCount: number) { + tmp = await createTempDir('gitnexus-lbug-'); + const dbPath = path.join(tmp.dbPath, 'lbug'); + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + await adapter.initLbug(dbPath); + const rows = Array.from({ length: rowCount }, (_, i) => ({ + nodeId: `Function:src/f${i}.ts:fn${i}:1`, + chunkIndex: 0, + startLine: 1, + endLine: 3, + embedding: Array.from({ length: EMBEDDING_DIMS }, (__, d) => (d === 0 ? i + 1 : 0)), + contentHash: `hash-${i}`, + })); + await batchInsertEmbeddings(adapter.executeWithReusedStatement, rows); + return { adapter, rows }; + } + + it('materializes a small table in RAM (skip-fts / mock-compatible shape)', async () => { + const { adapter, rows } = await seedDb(3); + const cached = await adapter.loadCachedEmbeddings(); + expect(cached.spill).toBeUndefined(); + expect(cached.embeddings).toHaveLength(3); + expect(cached.rows).toHaveLength(3); + expect(cached.embeddingNodeIds.size).toBe(3); + expect(cached.embeddings.map((e) => e.nodeId).sort()).toEqual(rows.map((r) => r.nodeId).sort()); + expect(cached.embeddings.find((e) => e.nodeId === rows[1]!.nodeId)?.embedding[0]).toBe(2); + }); + + it('streams into a spill file when the in-memory limit is 0 and restores a subset', async () => { + const { adapter, rows } = await seedDb(12); + const cached = await adapter.loadCachedEmbeddings({ inMemoryRowLimit: 0 }); + try { + expect(cached.embeddings).toEqual([]); + expect(cached.spill?.rowCount).toBe(12); + expect(cached.rows).toHaveLength(12); + const wanted = new Set([rows[0]!.nodeId, rows[5]!.nodeId, rows[10]!.nodeId]); + const subset = materializeCachedEmbeddings( + cached, + cached.rows.filter((meta) => wanted.has(meta.nodeId)), + ); + expect(subset).toHaveLength(3); + const byId = new Map(subset.map((row) => [row.nodeId, row])); + expect(byId.get(rows[0]!.nodeId)?.embedding[0]).toBe(1); + expect(byId.get(rows[5]!.nodeId)?.embedding[0]).toBe(6); + expect(byId.get(rows[10]!.nodeId)?.embedding[0]).toBe(11); + expect(byId.get(rows[0]!.nodeId)?.contentHash).toBe(rows[0]!.contentHash); + } finally { + disposeEmbeddingSpill(cached.spill); + } + }); + + it('flips from RAM to spill once a non-zero in-memory limit is crossed', async () => { + const { adapter, rows } = await seedDb(8); + const cached = await adapter.loadCachedEmbeddings({ inMemoryRowLimit: 4 }); + try { + expect(cached.embeddings).toEqual([]); + expect(cached.spill?.rowCount).toBe(8); + expect(cached.rows).toHaveLength(8); + expect(fs.statSync(cached.spill!.path).size).toBe(12 + 8 * EMBEDDING_DIMS * 4); + const wanted = new Set([rows[2]!.nodeId, rows[3]!.nodeId]); + const subset = materializeCachedEmbeddings( + cached, + cached.rows.filter((meta) => wanted.has(meta.nodeId)), + ); + expect(subset).toHaveLength(2); + const byId = new Map(subset.map((row) => [row.nodeId, row])); + expect(byId.get(rows[2]!.nodeId)?.embedding[0]).toBe(3); + expect(byId.get(rows[3]!.nodeId)?.embedding[0]).toBe(4); + } finally { + disposeEmbeddingSpill(cached.spill); + } + }); + + it('surfaces a spill write failure instead of adopting an empty snapshot', async () => { + const { adapter } = await seedDb(3); + const spillDir = path.join(tmp!.dbPath, 'not-a-directory'); + fs.writeFileSync(spillDir, 'x'); + await expect(adapter.loadCachedEmbeddings({ inMemoryRowLimit: 0, spillDir })).rejects.toThrow( + /ENOTDIR|not a directory|ENOSPC|EACCES/i, + ); + }); +}); diff --git a/gitnexus/test/integration/multi-branch-analyze.test.ts b/gitnexus/test/integration/multi-branch-analyze.test.ts index bf2fabc9b..c61428059 100644 --- a/gitnexus/test/integration/multi-branch-analyze.test.ts +++ b/gitnexus/test/integration/multi-branch-analyze.test.ts @@ -5,6 +5,7 @@ import path from 'path'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { getStoragePaths, loadMeta, listRegisteredRepos } from '../../src/storage/repo-manager.js'; import { createTempDir } from '../helpers/test-db.js'; +import { isDetectRejectWarning } from '../helpers/detect-reject-warning.js'; /** * #2106/#2354 — branch handling end-to-end. Proves that a plain analyze @@ -220,7 +221,12 @@ describe('multi-branch analyze (#2106)', () => { execFileSync('git', ['branch', '-M', 'feat`x'], { cwd: repo, stdio: 'pipe' }); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); - await runFullAnalysis(repo, {}, { onProgress: () => {} }); + const logs: string[] = []; + await runFullAnalysis( + repo, + {}, + { onProgress: () => {}, onLog: (message) => logs.push(message) }, + ); // The forbidden ref was normalized to null → flat slot, no branch field, // and no branches/ sub-directory created for an unqueryable slug. @@ -228,6 +234,9 @@ describe('multi-branch analyze (#2106)', () => { expect(existsSync(flat.lbugPath)).toBe(true); expect((await loadMeta(flat.storagePath))?.branch).toBeUndefined(); expect(existsSync(path.join(flat.storagePath, 'branches'))).toBe(false); + const warnings = logs.filter(isDetectRejectWarning); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('feat`x'); } finally { await tmp.cleanup(); } diff --git a/gitnexus/test/integration/resolvers/swift-local-vs-method-label-split.test.ts b/gitnexus/test/integration/resolvers/swift-local-vs-method-label-split.test.ts index ff7c0fff2..4a29528cd 100644 --- a/gitnexus/test/integration/resolvers/swift-local-vs-method-label-split.test.ts +++ b/gitnexus/test/integration/resolvers/swift-local-vs-method-label-split.test.ts @@ -116,19 +116,7 @@ describe.skipIf(!swiftAvailable)('a function-local callable keeps its own node ( }); }); - /** - * KNOWN GAP, pinned so it cannot be mistaken for part of the fix above. - * - * `helper(x, x)` inside `run` still targets the one-argument METHOD instead of - * the two-argument local. That is decided upstream of the graph bridge: the - * free-call binding hands `emitFreeCallFallback` the class-member def - * (`def:src/app.swift#5:4:Method:helper`), never the local's - * (`def:src/app.swift#9:8:Method:helper`), so no def→node mapping can correct - * it — both defs carry the same `qualifiedName` and the same label, and the - * binding walk picks the member. Recorded here rather than fixed because it - * lives in the scope walk, not in `resolveDefGraphId`. - */ - it('KNOWN GAP: the call to the local still binds to the same-named method', () => { - expect(targetsFrom(RUN)).toEqual([METHOD_HELPER]); + it('binds the call in run to the function-local rather than the same-named method', () => { + expect(targetsFrom(RUN)).toEqual([LOCAL_HELPER]); }); }); diff --git a/gitnexus/test/integration/resolvers/swift.test.ts b/gitnexus/test/integration/resolvers/swift.test.ts index 9b80d92a2..9805abf28 100644 --- a/gitnexus/test/integration/resolvers/swift.test.ts +++ b/gitnexus/test/integration/resolvers/swift.test.ts @@ -305,6 +305,52 @@ describe.skipIf(!swiftAvailable)('Swift extension deduplication', () => { }); }); +// --------------------------------------------------------------------------- +// Protocol-extension implicit self (issue #3273): a conforming type may call +// default implementation methods without an explicit receiver. Resolution +// must traverse the protocol's extension surface instead of falling back to +// unrelated same-named private methods elsewhere in the module. +// --------------------------------------------------------------------------- + +describe.skipIf(!swiftAvailable)('Swift protocol-extension implicit self (#3273)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'swift-protocol-extension-implicit-self'), + () => {}, + ); + }, 60000); + + it('resolves unqualified helper calls to the protocol extension', () => { + const calls = getRelationships(result, 'CALLS').filter((c) => c.source === 'run'); + for (const target of ['makeStore', 'makeValue', 'insertItem']) { + const call = calls.find((c) => c.target === target); + expect(call?.targetFilePath).toBe('Support.swift'); + } + }); + + it('keeps unrelated private same-name methods unreachable', () => { + const calls = getRelationships(result, 'CALLS').filter((c) => c.source === 'run'); + expect(calls.some((c) => c.targetFilePath === 'AUnrelated.swift')).toBe(false); + }); + + it('preserves the downstream call through the extension helper return type', () => { + const calls = getRelationships(result, 'CALLS'); + const execute = calls.find((c) => c.source === 'run' && c.target === 'execute'); + expect(execute?.targetFilePath).toBe('Support.swift'); + expect( + result.resolutionOutcomes.some( + (outcome) => + outcome.kind === 'suppressed' && + outcome.reason === 'receiver-unresolved' && + outcome.filePath === 'Scenario.swift' && + outcome.name === 'execute', + ), + ).toBe(false); + }); +}); + // --------------------------------------------------------------------------- // Constructor fallback: Swift constructors look like free function calls // (no `new` keyword). The resolver retries with constructor form when @@ -1251,6 +1297,36 @@ describe.skipIf(!swiftAvailable)('Swift nested-type extension (extension Foo.Bar }); }); +// --------------------------------------------------------------------------- +// A bare constructor inside an extension must prefer a nested type owned by +// the extended type over an unrelated top-level type with the same short name. +// --------------------------------------------------------------------------- + +describe.skipIf(!swiftAvailable)( + 'Swift nested constructor lookup in a public qualified extension (#3262)', + () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'swift-nested-constructor-extension'), + () => {}, + ); + }, 60000); + + it('resolves Entry(id:text:) to Outer.Container.Entry and not the top-level Entry', () => { + const entryCalls = getRelationships(result, 'CALLS').filter( + (call) => call.source === 'makeEntry' && call.target === 'Entry', + ); + + expect(entryCalls.map((call) => call.rel.targetId)).toEqual(['Struct:Types.swift:Entry']); + expect(entryCalls.some((call) => call.rel.targetId === 'Struct:Standalone.swift:Entry')).toBe( + false, + ); + }); + }, +); + // --------------------------------------------------------------------------- // F75: protocol property requirements (`var title: String { get }`) are // extracted as Property symbols owned by the protocol. Before the fix these diff --git a/gitnexus/test/unit/analyze-config.test.ts b/gitnexus/test/unit/analyze-config.test.ts index 890877835..a4245f6c5 100644 --- a/gitnexus/test/unit/analyze-config.test.ts +++ b/gitnexus/test/unit/analyze-config.test.ts @@ -133,6 +133,33 @@ describe('analyze-config (.gitnexusrc support, #243)', () => { expect(() => loadAnalyzeConfig(dir)).toThrow(/Unknown key "defalutBranch"/); }); + it('parses process-detection budget keys as numeric strings (#3313)', async () => { + await writeRc( + JSON.stringify({ + maxProcesses: 40, + maxProcessBranching: '2', + maxProcessTraceDepth: 8, + maxEntryPointCandidates: 400, + }), + ); + expect(loadAnalyzeConfig(dir)).toEqual({ + maxProcesses: '40', + maxProcessBranching: '2', + maxProcessTraceDepth: '8', + maxEntryPointCandidates: '400', + }); + }); + + it('lets a nested analyze block override flat process-detection keys (#3313)', async () => { + await writeRc( + JSON.stringify({ + maxProcesses: 80, + analyze: { maxProcesses: 25 }, + }), + ); + expect(loadAnalyzeConfig(dir)).toEqual({ maxProcesses: '25' }); + }); + it('accepts embeddingBaseUrl / embeddingModel but rejects embeddingDims (CLI/env-only)', async () => { // URL + MODEL are read lazily at runtime, so they are valid config keys. await writeRc(JSON.stringify({ embeddingBaseUrl: 'http://h/v1', embeddingModel: 'm' })); diff --git a/gitnexus/test/unit/analyze-gitnexusrc.test.ts b/gitnexus/test/unit/analyze-gitnexusrc.test.ts index 1909a284c..081b3664c 100644 --- a/gitnexus/test/unit/analyze-gitnexusrc.test.ts +++ b/gitnexus/test/unit/analyze-gitnexusrc.test.ts @@ -237,4 +237,18 @@ describe('analyzeCommand .gitnexusrc wiring (#243)', () => { expect(refreshBaseRefLineMock).toHaveBeenCalledTimes(1); expect(refreshBaseRefLineMock).toHaveBeenCalledWith(dir, 'develop', expect.any(Object)); }); + + it('threads .gitnexusrc process-detection knobs and lets CLI win (AE3)', async () => { + await writeRc({ maxProcesses: '40', maxEntryPointCandidates: 300 }); + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + + await analyzeCommand(dir, { maxProcesses: '25' }); + + expect(runFullAnalysisMock.mock.calls[0][1]).toEqual( + expect.objectContaining({ + maxProcesses: 25, + maxEntryPointCandidates: 300, + }), + ); + }); }); diff --git a/gitnexus/test/unit/analyze-process-budget.test.ts b/gitnexus/test/unit/analyze-process-budget.test.ts new file mode 100644 index 000000000..c186ab3f4 --- /dev/null +++ b/gitnexus/test/unit/analyze-process-budget.test.ts @@ -0,0 +1,133 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const runFullAnalysisMock = vi.fn(); + +vi.mock('../../src/core/run-analyze.js', () => ({ + runFullAnalysis: runFullAnalysisMock, +})); + +vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({ + closeLbug: vi.fn(async () => undefined), + closeLbugBeforeExit: vi.fn(async () => undefined), + isLbugReady: vi.fn(() => false), +})); + +vi.mock('../../src/storage/repo-manager.js', () => ({ + getStoragePaths: vi.fn(() => ({ storagePath: '.gitnexus', lbugPath: '.gitnexus/lbug' })), + getGlobalRegistryPath: vi.fn(() => 'registry.json'), + RegistryNameCollisionError: class RegistryNameCollisionError extends Error {}, + AnalysisNotFinalizedError: class AnalysisNotFinalizedError extends Error {}, + assertAnalysisFinalized: vi.fn(async () => undefined), +})); + +vi.mock('../../src/storage/git.js', () => ({ + getGitRoot: vi.fn(() => '/repo'), + hasGitDir: vi.fn(() => true), +})); + +vi.mock('../../src/core/ingestion/utils/max-file-size.js', () => ({ + getMaxFileSizeBannerMessage: vi.fn(() => null), +})); + +describe('analyzeCommand process-detection budget (#3313)', () => { + const ORIGINAL_NODE_OPTIONS = process.env.NODE_OPTIONS; + + beforeEach(() => { + vi.resetModules(); + runFullAnalysisMock.mockReset(); + process.exitCode = undefined; + process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=8192`.trim(); + }); + + afterEach(() => { + if (ORIGINAL_NODE_OPTIONS === undefined) { + delete process.env.NODE_OPTIONS; + } else { + process.env.NODE_OPTIONS = ORIGINAL_NODE_OPTIONS; + } + vi.unstubAllEnvs(); + }); + + const upToDate = { + repoName: 'repo', + repoPath: '/repo', + stats: {}, + alreadyUpToDate: true, + }; + + it('threads the four CLI flags through runFullAnalysis without env mutation', async () => { + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + runFullAnalysisMock.mockResolvedValue(upToDate); + const before = { + processes: process.env.GITNEXUS_MAX_PROCESSES, + branching: process.env.GITNEXUS_MAX_PROCESS_BRANCHING, + depth: process.env.GITNEXUS_MAX_PROCESS_TRACE_DEPTH, + entries: process.env.GITNEXUS_MAX_ENTRY_POINT_CANDIDATES, + }; + + await analyzeCommand(undefined, { + maxProcesses: '25', + maxProcessBranching: '2', + maxProcessTraceDepth: '8', + maxEntryPointCandidates: '400', + }); + + expect(runFullAnalysisMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + maxProcesses: 25, + maxProcessBranching: 2, + maxProcessTraceDepth: 8, + maxEntryPointCandidates: 400, + }), + expect.any(Object), + ); + expect(process.env.GITNEXUS_MAX_PROCESSES).toBe(before.processes); + expect(process.env.GITNEXUS_MAX_PROCESS_BRANCHING).toBe(before.branching); + expect(process.env.GITNEXUS_MAX_PROCESS_TRACE_DEPTH).toBe(before.depth); + expect(process.env.GITNEXUS_MAX_ENTRY_POINT_CANDIDATES).toBe(before.entries); + }); + + it.each(['0', 'abc', '-4'])( + 'warns and continues when --max-processes is %s (AE5)', + async (value) => { + const { _captureLogger } = await import('../../src/core/logger.js'); + const cap = _captureLogger(); + try { + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + runFullAnalysisMock.mockResolvedValue(upToDate); + + await analyzeCommand(undefined, { maxProcesses: value }); + + expect(process.exitCode).toBeUndefined(); + expect(runFullAnalysisMock).toHaveBeenCalledWith( + expect.any(String), + expect.not.objectContaining({ maxProcesses: expect.any(Number) }), + expect.any(Object), + ); + expect( + cap.records().some((r) => { + const msg = String(r.msg ?? ''); + return ( + msg.includes('--max-processes must be a positive integer') && + msg.includes('next source (env, then the built-in default)') + ); + }), + ).toBe(true); + } finally { + cap.restore(); + } + }, + ); + + it('leaves option fields unset so runFullAnalysis can honor env-only overrides', async () => { + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + runFullAnalysisMock.mockResolvedValue(upToDate); + vi.stubEnv('GITNEXUS_MAX_PROCESSES', '80'); + + await analyzeCommand(undefined, {}); + + const opts = runFullAnalysisMock.mock.calls[0][1] as { maxProcesses?: number }; + expect(opts.maxProcesses).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/unit/build-web-optin.test.ts b/gitnexus/test/unit/build-web-optin.test.ts index 68e8cd464..efb07190e 100644 --- a/gitnexus/test/unit/build-web-optin.test.ts +++ b/gitnexus/test/unit/build-web-optin.test.ts @@ -8,6 +8,8 @@ import { runWebBuild, shouldBuildWeb, shouldPreserveWebOutput } from '../../scri /** Default prepare/build stay CLI-only; the web UI ships only via prepack --web. */ const REPO_ROOT = path.resolve(__dirname, '../../..'); +const CLI_TSC_JS = 'node ../gitnexus/node_modules/typescript/lib/tsc.js'; +const WEB_TSC_JS = 'node ../gitnexus-web/node_modules/typescript/lib/tsc.js'; const PACKAGE_JSON = JSON.parse( readFileSync(path.join(REPO_ROOT, 'gitnexus/package.json'), 'utf8'), ) as { scripts?: Record }; @@ -283,7 +285,7 @@ describe('workflows that need the web UI install it themselves', () => { describe('setup-gitnexus job budget', () => { it('does not npm-ci gitnexus-shared (TypeScript 7 optional-platform install stalls CI)', () => { const shared = setupGitnexus.runs?.steps?.find((step) => step.name === 'Build gitnexus-shared'); - expect(String(shared?.run)).toBe('node ../gitnexus/node_modules/typescript/lib/tsc.js'); + expect(String(shared?.run)).toBe(CLI_TSC_JS); expect(String(shared?.run)).not.toContain('.bin'); expect(shared?.if).toContain("lifecycle-scripts == 'false'"); expect( @@ -314,12 +316,28 @@ describe('setup-gitnexus job budget', () => { expect(String(setupNode?.with?.['cache-dependency-path'])).toBe( 'gitnexus-web/package-lock.json', ); - expect(String(shared?.run)).toBe('node ../gitnexus-web/node_modules/typescript/lib/tsc.js'); + expect(String(shared?.run)).toBe(WEB_TSC_JS); expect(String(shared?.run)).not.toContain('.bin'); expect(String(shared?.run)).not.toContain('npm ci'); expect(webInstall?.env?.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD).toBe('1'); }); + it('Vercel installs web first and compiles shared with the web TypeScript', () => { + const vercel = JSON.parse( + readFileSync(path.join(REPO_ROOT, 'gitnexus-web/vercel.json'), 'utf8'), + ) as { installCommand?: string }; + const install = String(vercel.installCommand); + // Vercel runs installCommand with NODE_ENV=production, so npm ci drops + // typescript unless --include=dev is on that install (not a later step). + expect(install).toContain('npm ci --include=dev'); + expect(install).toContain('gitnexus-shared'); + expect(install.indexOf('npm ci --include=dev')).toBeLessThan( + install.indexOf('gitnexus-shared'), + ); + expect(install).toContain(WEB_TSC_JS); + expect(install).not.toMatch(/gitnexus-shared[^&]*npm (?:ci|install)/); + }); + it('quality typecheck skips prepare/postinstall so tsc --noEmit fits in 10 minutes', () => { const job = qualityJobs.typecheck; const setup = job?.steps?.find((step) => step.uses === './.github/actions/setup-gitnexus'); @@ -327,6 +345,28 @@ describe('setup-gitnexus job budget', () => { expect(setup?.with?.['lifecycle-scripts']).toBe('false'); }); + it('web app tsconfig typechecks React JSX on TypeScript 7 without baseUrl', () => { + const tsconfig = JSON.parse( + readFileSync(path.join(REPO_ROOT, 'gitnexus-web/tsconfig.app.json'), 'utf8'), + ) as { + compilerOptions?: { + baseUrl?: string; + jsx?: string; + jsxImportSource?: string; + lib?: string[]; + rootDir?: string; + types?: string[]; + }; + }; + const options = tsconfig.compilerOptions ?? {}; + expect(options.baseUrl).toBeUndefined(); + expect(options.jsx).toBe('react-jsx'); + expect(options.jsxImportSource).toBe('react'); + expect(options.lib).toEqual(expect.arrayContaining(['ESNext', 'DOM', 'DOM.Iterable'])); + expect(options.rootDir).toBe('./src'); + expect(options.types).toEqual(['vite/client']); + }); + it('quality typecheck-web can finish a cold web install instead of canceling before cache save', () => { expect(qualityJobs['typecheck-web']?.['timeout-minutes']).toBe(15); }); diff --git a/gitnexus/test/unit/cli-index-help.test.ts b/gitnexus/test/unit/cli-index-help.test.ts index 417ecb53d..b9862b87f 100644 --- a/gitnexus/test/unit/cli-index-help.test.ts +++ b/gitnexus/test/unit/cli-index-help.test.ts @@ -4,8 +4,14 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { Command, Option } from 'commander'; -import * as ts from 'typescript'; +import * as t from '@babel/types'; import { afterEach, describe, expect, it } from 'vitest'; +import { + forEachChild, + parseTypeScript, + staticMemberName, + staticStringValue, +} from '../helpers/parse-typescript-source.js'; import { CLI_SPAWN_PREFIX } from '../helpers/cli-entry.js'; import { localizeCliHelp } from '../../src/cli/help-i18n.js'; import { setCliLanguage, type SupportedCliLanguage } from '../../src/cli/i18n/index.js'; @@ -69,17 +75,6 @@ const allHelpCommands = [ ['group', 'contracts'], ]; -function staticStringValue(node: ts.Node | undefined): string | undefined { - if (!node) return undefined; - if (ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text; - if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) { - const left = staticStringValue(node.left); - const right = staticStringValue(node.right); - if (left !== undefined && right !== undefined) return `${left}${right}`; - } - return undefined; -} - function extractRegisteredHelpDescriptions(): string[] { const descriptions = new Set(); const sourceFiles = ['src/cli/index.ts', 'src/cli/group.ts']; @@ -87,27 +82,30 @@ function extractRegisteredHelpDescriptions(): string[] { for (const relativePath of sourceFiles) { const filePath = path.join(repoRoot, relativePath); const source = fs.readFileSync(filePath, 'utf8'); - const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true); + const { ast } = parseTypeScript(filePath, source); - function visit(node: ts.Node): void { - if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) { - const method = node.expression.name.text; - const description = - method === 'description' - ? staticStringValue(node.arguments[0]) - : method === 'option' || method === 'requiredOption' - ? staticStringValue(node.arguments[1]) - : undefined; + function visit(node: t.Node): void { + if ( + t.isCallExpression(node) && + (t.isMemberExpression(node.callee) || t.isOptionalMemberExpression(node.callee)) + ) { + const method = staticMemberName(node.callee); + let description: string | undefined; + if (method === 'description') { + description = staticStringValue(node.arguments[0]); + } else if (method === 'option' || method === 'requiredOption') { + description = staticStringValue(node.arguments[1]); + } if (description && /[A-Za-z]/.test(description)) { descriptions.add(description.replace(/\s+/g, ' ').trim()); } } - ts.forEachChild(node, visit); + forEachChild(node, visit); } - visit(sourceFile); + visit(ast); } return [...descriptions].filter((description) => description.length > 0).sort(); @@ -198,10 +196,13 @@ describe('CLI help surface', () => { expect(result.stdout).toContain('外部索引根目录'); expect(result.stdout).toContain('GITNEXUS_CONTENT_RETENTION=full'); expect(result.stdout).toContain('源码文本保留策略'); - expect(result.stdout).toContain('当参数和对应环境变量同时提供时,参数优先。'); + expect(result.stdout).toContain( + 'CLI 参数优先于 `.gitnexusrc`,后者优先于环境变量,环境变量优先于内置默认值。', + ); expect(result.stdout).toContain('提示:`.gitnexusignore` 支持 `.gitignore` 风格的取反。'); expect(result.stdout).not.toContain('Environment variables:'); expect(result.stdout).not.toContain('Flags override the corresponding env vars'); + expect(result.stdout).not.toContain('当参数和对应环境变量同时提供时,参数优先。'); }); it('analyze help documents the external storage root layout', () => { @@ -215,6 +216,10 @@ describe('CLI help surface', () => { expect(result.stdout).toContain('GITNEXUS_CONTENT_RETENTION=full'); expect(result.stdout).toContain('Source-text retention profile'); expect(result.stdout).toContain('-/'); + expect(result.stdout).toContain( + 'CLI flags take precedence over `.gitnexusrc`, which takes precedence over env vars, which take precedence over built-in defaults.', + ); + expect(result.stdout).not.toContain('Flags override the corresponding env vars'); }); it('query help keeps advanced search options without importing analyze deps', () => { diff --git a/gitnexus/test/unit/embedding-restore-spill.test.ts b/gitnexus/test/unit/embedding-restore-spill.test.ts new file mode 100644 index 000000000..b11695110 --- /dev/null +++ b/gitnexus/test/unit/embedding-restore-spill.test.ts @@ -0,0 +1,274 @@ +import { existsSync, statSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + abortCachedEmbeddingsBuilder, + cacheRowCount, + createCachedEmbeddingsBuilder, + DEFAULT_EMBEDDING_CACHE_IN_MEMORY_ROW_LIMIT, + discardLiveEmbeddingSpills, + discardScopedEmbeddingSpills, + disposeEmbeddingSpill, + EmbeddingSpillReader, + finalizeCachedEmbeddingsSnapshot, + ingestCachedEmbeddingRow, + materializeCachedEmbeddings, + normalizeCachedEmbeddings, + readSpillVectors, + resolveEmbeddingCacheInMemoryRowLimit, + snapshotEmbeddingDims, + withEmbeddingSpillScope, +} from '../../src/core/embeddings/embedding-restore-spill.js'; + +const DIMS = 8; + +function vector(fill: number): number[] { + return Array.from({ length: DIMS }, () => fill); +} + +function row(id: string, fill: number, hash = `hash-${id}`) { + return { + nodeId: id, + chunkIndex: 0, + startLine: 1, + endLine: 2, + embedding: vector(fill), + contentHash: hash, + }; +} + +describe('embedding-restore-spill (#3306)', () => { + const spills: Array<{ path: string }> = []; + afterEach(() => { + for (const spill of spills) disposeEmbeddingSpill(spill); + spills.length = 0; + }); + + it('keeps small tables in RAM and does not leave a spill file', () => { + const builder = createCachedEmbeddingsBuilder({ + inMemoryRowLimit: 4, + spillDir: os.tmpdir(), + }); + ingestCachedEmbeddingRow(builder, row('n1', 0.25), true); + ingestCachedEmbeddingRow(builder, row('n2', 0.5), true); + const snapshot = finalizeCachedEmbeddingsSnapshot(builder); + expect(snapshot.spill).toBeUndefined(); + expect(existsSync(builder.writer.path)).toBe(false); + expect(snapshot.embeddings).toHaveLength(2); + expect(snapshot.rows).toHaveLength(2); + expect(snapshot.embeddings[0]?.embedding[0]).toBeCloseTo(0.25); + expect(cacheRowCount(snapshot)).toBe(2); + expect(snapshotEmbeddingDims(snapshot)).toBe(DIMS); + }); + + it('spills vectors once the in-memory limit is exceeded and materializes a subset', () => { + const builder = createCachedEmbeddingsBuilder({ + inMemoryRowLimit: 2, + spillDir: os.tmpdir(), + }); + for (let i = 0; i < 5; i++) { + ingestCachedEmbeddingRow(builder, row(`n${i}`, i + 1), true); + } + const snapshot = finalizeCachedEmbeddingsSnapshot(builder); + if (snapshot.spill) spills.push(snapshot.spill); + expect(snapshot.embeddings).toEqual([]); + expect(snapshot.spill?.rowCount).toBe(5); + expect(existsSync(snapshot.spill!.path)).toBe(true); + expect(snapshot.embeddingNodeIds.size).toBe(5); + + const subset = materializeCachedEmbeddings(snapshot, snapshot.rows.slice(1, 3)); + expect(subset).toHaveLength(2); + expect(subset[0]?.nodeId).toBe('n1'); + expect(subset[0]?.embedding[0]).toBeCloseTo(2); + expect(subset[1]?.embedding[0]).toBeCloseTo(3); + }); + + it('always spills when the in-memory limit is 0 (no Number[] table in RAM)', () => { + const builder = createCachedEmbeddingsBuilder({ + inMemoryRowLimit: 0, + spillDir: os.tmpdir(), + }); + ingestCachedEmbeddingRow(builder, row('only', 0.75), true); + const snapshot = finalizeCachedEmbeddingsSnapshot(builder); + if (snapshot.spill) spills.push(snapshot.spill); + expect(snapshot.embeddings).toEqual([]); + expect(snapshot.rows).toHaveLength(1); + expect(materializeCachedEmbeddings(snapshot, snapshot.rows)[0]?.embedding[0]).toBeCloseTo(0.75); + }); + + it('aborts an unfinished builder without leaking a spill file', () => { + const builder = createCachedEmbeddingsBuilder({ + inMemoryRowLimit: 0, + spillDir: os.tmpdir(), + }); + ingestCachedEmbeddingRow(builder, row('n1', 1), true); + expect(existsSync(builder.writer.path)).toBe(true); + abortCachedEmbeddingsBuilder(builder); + expect(existsSync(builder.writer.path)).toBe(false); + }); + + it('normalizes mock {embeddings} payloads so Phase 3.5 can restore without a spill', () => { + const snapshot = normalizeCachedEmbeddings({ + embeddingNodeIds: new Set(['Function:a:foo']), + embeddings: [ + { + nodeId: 'Function:a:foo', + chunkIndex: 0, + startLine: 0, + endLine: 3, + embedding: vector(0.1), + contentHash: 'stub', + }, + ], + }); + expect(snapshot.rows).toHaveLength(1); + expect(snapshot.rows[0]?.vectorIndex).toBe(0); + const restored = materializeCachedEmbeddings(snapshot, snapshot.rows); + expect(restored[0]?.contentHash).toBe('stub'); + expect(restored[0]?.embedding).toHaveLength(DIMS); + }); + + it('defaults the in-memory row limit to 2048 and honors GITNEXUS_EMBEDDING_CACHE_IN_MEMORY_LIMIT', () => { + expect(DEFAULT_EMBEDDING_CACHE_IN_MEMORY_ROW_LIMIT).toBe(2048); + vi.stubEnv('GITNEXUS_EMBEDDING_CACHE_IN_MEMORY_LIMIT', ''); + expect(resolveEmbeddingCacheInMemoryRowLimit()).toBe(2048); + vi.stubEnv('GITNEXUS_EMBEDDING_CACHE_IN_MEMORY_LIMIT', '0'); + expect(resolveEmbeddingCacheInMemoryRowLimit()).toBe(0); + vi.stubEnv('GITNEXUS_EMBEDDING_CACHE_IN_MEMORY_LIMIT', '12'); + expect(resolveEmbeddingCacheInMemoryRowLimit()).toBe(12); + vi.stubEnv('GITNEXUS_EMBEDDING_CACHE_IN_MEMORY_LIMIT', 'nope'); + expect(resolveEmbeddingCacheInMemoryRowLimit()).toBe(2048); + vi.unstubAllEnvs(); + expect(resolveEmbeddingCacheInMemoryRowLimit(7)).toBe(7); + expect(resolveEmbeddingCacheInMemoryRowLimit(-1)).toBe(2048); + }); + + it('spills above the default 2048-row limit with header-plus-body size 12 + N * D * 4', () => { + const n = DEFAULT_EMBEDDING_CACHE_IN_MEMORY_ROW_LIMIT + 1; + const builder = createCachedEmbeddingsBuilder({ spillDir: os.tmpdir() }); + for (let i = 0; i < n; i++) { + ingestCachedEmbeddingRow(builder, row(`n${i}`, 1), true); + } + const snapshot = finalizeCachedEmbeddingsSnapshot(builder); + if (snapshot.spill) spills.push(snapshot.spill); + expect(snapshot.embeddings).toEqual([]); + expect(snapshot.spill?.rowCount).toBe(n); + expect(statSync(snapshot.spill!.path).size).toBe(12 + n * DIMS * 4); + }); + + it('rejects a dim mismatch once spilling and rejects a short or bad-magic header', () => { + const builder = createCachedEmbeddingsBuilder({ + inMemoryRowLimit: 0, + spillDir: os.tmpdir(), + }); + ingestCachedEmbeddingRow(builder, row('a', 1), true); + expect(() => + ingestCachedEmbeddingRow(builder, { ...row('b', 2), embedding: [1, 2, 3] }, true), + ).toThrow(/dim mismatch/); + abortCachedEmbeddingsBuilder(builder); + + const shortPath = path.join(os.tmpdir(), `gitnexus-embed-restore-short-${process.pid}.bin`); + writeFileSync(shortPath, Buffer.from('NOPE')); + spills.push({ path: shortPath }); + expect(() => readSpillVectors({ path: shortPath, dims: DIMS, rowCount: 1 }, [0])).toThrow( + /invalid embedding spill header/, + ); + + const badMagic = Buffer.alloc(12); + badMagic.write('NOPE', 0, 4, 'ascii'); + badMagic.writeUInt8(1, 4); + badMagic.writeUInt32LE(DIMS, 5); + const badPath = path.join(os.tmpdir(), `gitnexus-embed-restore-bad-${process.pid}.bin`); + writeFileSync(badPath, badMagic); + spills.push({ path: badPath }); + expect(() => readSpillVectors({ path: badPath, dims: DIMS, rowCount: 1 }, [0])).toThrow( + /invalid embedding spill header/, + ); + }); + + it('reuses an open spill reader across materialize batches', () => { + const builder = createCachedEmbeddingsBuilder({ + inMemoryRowLimit: 0, + spillDir: os.tmpdir(), + }); + for (let i = 0; i < 4; i++) { + ingestCachedEmbeddingRow(builder, row(`n${i}`, i + 1), true); + } + const snapshot = finalizeCachedEmbeddingsSnapshot(builder); + if (snapshot.spill) spills.push(snapshot.spill); + const reader = new EmbeddingSpillReader(snapshot.spill!); + try { + const first = materializeCachedEmbeddings(snapshot, snapshot.rows.slice(0, 2), reader); + const second = materializeCachedEmbeddings(snapshot, snapshot.rows.slice(2, 4), reader); + expect(first[0]?.embedding[0]).toBeCloseTo(1); + expect(second[1]?.embedding[0]).toBeCloseTo(4); + } finally { + reader.close(); + } + }); + + it('scoped discard unlinks only spills created in that analyze run', async () => { + const other = createCachedEmbeddingsBuilder({ + inMemoryRowLimit: 0, + spillDir: os.tmpdir(), + }); + ingestCachedEmbeddingRow(other, row('other', 1), true); + const otherSnapshot = finalizeCachedEmbeddingsSnapshot(other); + if (otherSnapshot.spill) spills.push(otherSnapshot.spill); + expect(existsSync(otherSnapshot.spill!.path)).toBe(true); + + await withEmbeddingSpillScope(async () => { + const builder = createCachedEmbeddingsBuilder({ + inMemoryRowLimit: 0, + spillDir: os.tmpdir(), + }); + ingestCachedEmbeddingRow(builder, row('scoped', 2), true); + const snapshot = finalizeCachedEmbeddingsSnapshot(builder); + expect(existsSync(snapshot.spill!.path)).toBe(true); + discardScopedEmbeddingSpills(); + expect(existsSync(snapshot.spill!.path)).toBe(false); + expect(existsSync(otherSnapshot.spill!.path)).toBe(true); + }); + }); + + it('unlinks a finished spill that was not disposed', () => { + const builder = createCachedEmbeddingsBuilder({ + inMemoryRowLimit: 0, + spillDir: os.tmpdir(), + }); + ingestCachedEmbeddingRow(builder, row('n1', 1), true); + const snapshot = finalizeCachedEmbeddingsSnapshot(builder); + expect(snapshot.spill).toBeDefined(); + expect(existsSync(snapshot.spill!.path)).toBe(true); + discardLiveEmbeddingSpills(); + expect(existsSync(snapshot.spill!.path)).toBe(false); + }); + + it('throws when materializing a meta row with no matching vector', () => { + const snapshot = normalizeCachedEmbeddings({ + embeddings: [ + { + nodeId: 'Function:a:foo', + chunkIndex: 0, + startLine: 0, + endLine: 3, + embedding: vector(0.1), + contentHash: 'stub', + }, + ], + }); + expect(() => + materializeCachedEmbeddings(snapshot, [ + { + nodeId: 'Function:missing:bar', + chunkIndex: 0, + startLine: 0, + endLine: 1, + contentHash: 'x', + vectorIndex: 99, + }, + ]), + ).toThrow(/missing cached embedding Function:missing:bar:0/); + }); +}); diff --git a/gitnexus/test/unit/git-ref.test.ts b/gitnexus/test/unit/git-ref.test.ts index af2492926..7ecf54679 100644 --- a/gitnexus/test/unit/git-ref.test.ts +++ b/gitnexus/test/unit/git-ref.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { InvalidBranchError, validateBranchName } from '../../src/core/git-ref.js'; +import { + InvalidBranchError, + formatRejectedBranchForLog, + sanitizeDetectedBranch, + validateBranchName, +} from '../../src/core/git-ref.js'; describe('core/git-ref', () => { it('throws InvalidBranchError with name "InvalidBranchError"', () => { @@ -12,4 +17,46 @@ describe('core/git-ref', () => { expect((err as Error).name).toBe('InvalidBranchError'); } }); + + it('sanitizeDetectedBranch returns the trimmed name for a legal branch', () => { + expect(sanitizeDetectedBranch('develop')).toBe('develop'); + expect(sanitizeDetectedBranch(' feature/foo-bar ')).toBe('feature/foo-bar'); + }); + + it('sanitizeDetectedBranch returns undefined for null, empty, or whitespace', () => { + expect(sanitizeDetectedBranch(null)).toBeUndefined(); + expect(sanitizeDetectedBranch(undefined)).toBeUndefined(); + expect(sanitizeDetectedBranch('')).toBeUndefined(); + expect(sanitizeDetectedBranch(' ')).toBeUndefined(); + }); + + it('sanitizeDetectedBranch swallows InvalidBranchError and does not throw', () => { + expect(sanitizeDetectedBranch('feat`x')).toBeUndefined(); + expect(sanitizeDetectedBranch('main`evil')).toBeUndefined(); + expect(sanitizeDetectedBranch('HEAD')).toBeUndefined(); + expect(() => sanitizeDetectedBranch('feat`x')).not.toThrow(); + }); + + it('formatRejectedBranchForLog keeps backticks visible and escapes bidi, quotes, and line separators', () => { + expect(formatRejectedBranchForLog('feat`x')).toBe('feat`x'); + expect(formatRejectedBranchForLog('a"b')).toBe('a\\"b'); + expect(formatRejectedBranchForLog(`ok${'\u202e'}bad`)).toBe('ok\\u202ebad'); + expect(formatRejectedBranchForLog(`zw${'\u200b'}sp`)).toBe('zw\\u200bsp'); + expect(formatRejectedBranchForLog(`foo${'\u2028'}bar`)).toBe('foo\\u2028bar'); + expect(formatRejectedBranchForLog(`foo${'\u2029'}bar`)).toBe('foo\\u2029bar'); + }); + + it('formatRejectedBranchForLog escapes NBSP, NEL, and other C1 as \\uXXXX', () => { + expect(formatRejectedBranchForLog(`foo${'\u00a0'}bar`)).toBe('foo\\u00a0bar'); + const nelBacktick = formatRejectedBranchForLog(`feat${'\u0085'}\``); + expect(nelBacktick).toContain('\\u0085'); + expect(nelBacktick).toContain('`'); + expect(nelBacktick).not.toContain('\u0085'); + expect(formatRejectedBranchForLog(`x${'\u009f'}y`)).toBe('x\\u009fy'); + }); + + it('sanitizeDetectedBranch rejects git-legal U+2028/U+2029 as whitespace', () => { + expect(sanitizeDetectedBranch(`foo${'\u2028'}bar`)).toBeUndefined(); + expect(sanitizeDetectedBranch(`foo${'\u2029'}bar`)).toBeUndefined(); + }); }); diff --git a/gitnexus/test/unit/group/sync-partial-extraction.test.ts b/gitnexus/test/unit/group/sync-partial-extraction.test.ts index 0e7d41228..e1588be1c 100644 --- a/gitnexus/test/unit/group/sync-partial-extraction.test.ts +++ b/gitnexus/test/unit/group/sync-partial-extraction.test.ts @@ -4,7 +4,16 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { fileURLToPath } from 'node:url'; -import ts from 'typescript'; +import * as t from '@babel/types'; +import { + type AstNode, + collectDescendants, + lineAt, + nodeStart, + nodeText, + parseTypeScript, + staticMemberName, +} from '../../helpers/parse-typescript-source.js'; import type { ContractRegistry, ExtractedContract, @@ -182,65 +191,63 @@ describe('syncGroup when one extractor fails partway through a repo', () => { */ const SYNC_SOURCE_PATH = fileURLToPath(new URL('../../../src/core/group/sync.ts', import.meta.url)); -/** Every node under `node`, in source order. No branching, so nothing is skippable. */ -function descendants(node: ts.Node): ts.Node[] { - const out: ts.Node[] = []; - const visit = (n: ts.Node): void => { - out.push(n); - n.forEachChild(visit); - }; - node.forEachChild(visit); - return out; -} - /** `const : StoredContract[] = []` — the per-repo staging buffer. */ -function isStagingBufferDeclaration(node: ts.Node): node is ts.VariableDeclaration { +function isStagingBufferDeclaration(node: t.Node): node is t.VariableDeclarator { + if (!t.isVariableDeclarator(node) || !t.isIdentifier(node.id)) return false; + const annotation = node.id.typeAnnotation; + if ( + !annotation || + !t.isTSTypeAnnotation(annotation) || + !t.isTSArrayType(annotation.typeAnnotation) + ) { + return false; + } + const elementType = annotation.typeAnnotation.elementType; return ( - ts.isVariableDeclaration(node) && - node.type !== undefined && - ts.isArrayTypeNode(node.type) && - ts.isTypeReferenceNode(node.type.elementType) && - ts.isIdentifier(node.type.elementType.typeName) && - node.type.elementType.typeName.text === 'StoredContract' && - node.initializer !== undefined && - ts.isArrayLiteralExpression(node.initializer) && - node.initializer.elements.length === 0 && - ts.isVariableDeclarationList(node.parent) && - (node.parent.flags & ts.NodeFlags.Const) !== 0 + t.isTSTypeReference(elementType) && + t.isIdentifier(elementType.typeName) && + elementType.typeName.name === 'StoredContract' && + node.init !== undefined && + node.init !== null && + t.isArrayExpression(node.init) && + node.init.elements.length === 0 && + t.isVariableDeclaration((node as AstNode).parent) && + ((node as AstNode).parent as t.VariableDeclaration).kind === 'const' ); } /** `x.apply(dest, args)` — an argument-limited append in non-spread clothing. */ -function isApplyCall(call: ts.CallExpression): boolean { - return ts.isPropertyAccessExpression(call.expression) && call.expression.name.text === 'apply'; +function isApplyCall(call: t.CallExpression): boolean { + return ( + (t.isMemberExpression(call.callee) || t.isOptionalMemberExpression(call.callee)) && + staticMemberName(call.callee) === 'apply' + ); } -function describeCall(sourceFile: ts.SourceFile, call: ts.CallExpression): string { - const { line } = sourceFile.getLineAndCharacterOfPosition(call.getStart(sourceFile)); - return `${line + 1}: ${call.getText(sourceFile).replace(/\s+/g, ' ')}`; +function describeCall(source: string, call: t.CallExpression): string { + const line = lineAt(source, nodeStart(call)); + return `${line}: ${nodeText(source, call).replace(/\s+/g, ' ')}`; } describe('the per-repo staging append in sync.ts', () => { it('appends the staged contracts without spreading them into a call', () => { const source = fs.readFileSync(SYNC_SOURCE_PATH, 'utf-8'); - const sourceFile = ts.createSourceFile( - SYNC_SOURCE_PATH, - source, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TS, - ); + const { ast } = parseTypeScript(SYNC_SOURCE_PATH, source); - const allNodes = descendants(sourceFile); + const allNodes = collectDescendants(ast); const stagingBuffers = allNodes.filter(isStagingBufferDeclaration); // One staging buffer, or this gate no longer knows which code it guards. - expect(stagingBuffers.map((d) => d.name.getText(sourceFile))).toHaveLength(1); - const stagingNames = stagingBuffers.map((d) => d.name.getText(sourceFile)); + const stagingNames = stagingBuffers.map((d) => + t.isIdentifier(d.id) ? d.id.name : nodeText(source, d.id), + ); + expect(stagingNames).toHaveLength(1); // The block the buffer is declared in — the per-repo loop body. + // VariableDeclarator → VariableDeclaration → BlockStatement (Babel has no + // extra VariableStatement wrapper). const declaringBlocks = stagingBuffers - .map((d) => d.parent.parent.parent) // declaration → list → statement → block - .filter(ts.isBlock); + .map((d) => (d as AstNode).parent?.parent) + .filter((node): node is t.BlockStatement => t.isBlockStatement(node)); expect(declaringBlocks).toHaveLength(1); // The extractor try-block: a DIRECT statement of that block whose `try` reads @@ -249,22 +256,22 @@ describe('the per-repo staging append in sync.ts', () => { // ancestor reads the buffer too. Widening to "any try that mentions it" pulls // in the entire function body, manifest-window spreads and all. const extractorTryBlocks = declaringBlocks.flatMap((block) => - block.statements - .filter(ts.isTryStatement) + block.body + .filter((statement): statement is t.TryStatement => t.isTryStatement(statement)) .filter((statement) => - descendants(statement.tryBlock).some( - (n) => ts.isIdentifier(n) && stagingNames.includes(n.text), + collectDescendants(statement.block).some( + (n) => t.isIdentifier(n) && stagingNames.includes(n.name), ), ) - .map((statement) => statement.tryBlock), + .map((statement) => statement.block), ); expect(extractorTryBlocks).toHaveLength(1); const unboundedAppends = extractorTryBlocks.flatMap((block) => - descendants(block) - .filter(ts.isCallExpression) - .filter((call) => call.arguments.some(ts.isSpreadElement) || isApplyCall(call)) - .map((call) => describeCall(sourceFile, call)), + collectDescendants(block) + .filter((node): node is t.CallExpression => t.isCallExpression(node)) + .filter((call) => call.arguments.some((arg) => t.isSpreadElement(arg)) || isApplyCall(call)) + .map((call) => describeCall(source, call)), ); // Every staged contract must reach `autoContracts` through a bounded loop: diff --git a/gitnexus/test/unit/import-resolver-factory.test.ts b/gitnexus/test/unit/import-resolver-factory.test.ts index 2e22790e0..90c0c5d95 100644 --- a/gitnexus/test/unit/import-resolver-factory.test.ts +++ b/gitnexus/test/unit/import-resolver-factory.test.ts @@ -485,6 +485,31 @@ describe('swiftPackageStrategy', () => { const result = swiftPackageStrategy('Foundation', 'App.swift', ctx); expect(result).toBeNull(); }); + + it('does not resolve inferred grouping folders when the declaration is empty', () => { + const ctx = makeCtx(['Sources/Foundation/Thing.swift', 'Sources/App/main.swift'], { + swiftPackageConfig: { + origin: 'package.swift', + targets: new Map([ + ['Foundation', 'Sources/Foundation'], + ['App', 'Sources/App'], + ]), + declaredTargets: new Map(), + }, + }); + expect(swiftPackageStrategy('Foundation', 'Sources/App/main.swift', ctx)).toBeNull(); + expect(swiftPackageStrategy('App', 'Sources/App/main.swift', ctx)).toBeNull(); + }); + + it('ignores an inferred directories origin', () => { + const ctx = makeCtx(['Sources/Models/User.swift'], { + swiftPackageConfig: { + origin: 'directories', + targets: new Map([['Models', 'Sources/Models']]), + }, + }); + expect(swiftPackageStrategy('Models', 'Sources/App/main.swift', ctx)).toBeNull(); + }); }); describe('rubyRequireStrategy', () => { diff --git a/gitnexus/test/unit/incremental-dirty-recovery.test.ts b/gitnexus/test/unit/incremental-dirty-recovery.test.ts index eafb933ce..0192cac1c 100644 --- a/gitnexus/test/unit/incremental-dirty-recovery.test.ts +++ b/gitnexus/test/unit/incremental-dirty-recovery.test.ts @@ -19,7 +19,7 @@ */ import { writeFile, readFile } from 'fs/promises'; -import { describe, it, expect } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { getStoragePaths, saveMeta, @@ -35,92 +35,109 @@ import { seedEmbeddingsForFiles } from '../helpers/embedding-seed.js'; const setupMiniRepo = () => setupSharedMiniRepo('gitnexus-incr-dirty-rec-'); +async function parkCrashSidecarsAndRebuild(options?: { alwaysSpill?: boolean }) { + vi.stubEnv('GITNEXUS_WORKER_READY_TIMEOUT_MS', '60000'); + if (options?.alwaysSpill) { + vi.stubEnv('GITNEXUS_EMBEDDING_CACHE_IN_MEMORY_LIMIT', '0'); + } + const repo = await setupMiniRepo(); + try { + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); + + // Seed real embeddings BEFORE the tamper (tri-review 4669518496 / U5): + // with meta.stats.embeddings = 0 the recovery run derived + // shouldLoadCache=false and never opened the DB pre-wipe — this test + // was vacuous about the exact open the parking protects. Seeded rows + + // a stats stamp route the recovery (which runs force:true internally, + // so forceRegenerate → shouldLoadCache) through the REAL + // embedding-cache preservation open on the just-parked DB. + const { storagePath, lbugPath } = getStoragePaths(repo.dbPath); + const seededIdsByFile = await seedEmbeddingsForFiles( + repo.dbPath, + ['src/handler.ts', 'src/logger.ts'], + 1, + ); + const seededNodeIds = [...seededIdsByFile.values()].flat(); + expect(seededNodeIds.length).toBeGreaterThan(0); + + // Simulate a crashed incremental writeback: dirty flag in meta plus + // leftover sidecars whose bytes must never be replayed. 8KB puts the + // WAL above the tiny-orphan threshold — the state the sidecar + // preflight deliberately leaves in place for engine replay. + const meta = await loadMeta(storagePath); + const tampered: RepoMeta = { + ...meta!, + stats: { ...meta!.stats, embeddings: seededNodeIds.length }, + incrementalInProgress: { + startedAt: Date.now() - 60_000, + toWriteCount: 12, + phase: 'load-graph', + }, + }; + await saveMeta(storagePath, tampered); + const walGarbage = Buffer.alloc(8192, 0xab); + const shadowGarbage = Buffer.alloc(4096, 0xcd); + await writeFile(`${lbugPath}.wal`, walGarbage); + await writeFile(`${lbugPath}.shadow`, shadowGarbage); + + const logs: string[] = []; + // embeddingsNodeLimit: 1 (KTD9): the recovery runs force:true + // internally, and the seeded stats would otherwise route Phase 4 into + // a real embedder in CI — the 1-node cap suppresses generation while + // leaving the preserve/restore path fully live. On linux the + // wipe-and-restore vector-index seam then fires for real (statically + // linked VECTOR): a CREATE_VECTOR_INDEX over the restored rows is + // expected and harmless here. + const recovered = await runFullAnalysis( + repo.dbPath, + { skipAgentsMd: true, embeddingsNodeLimit: 1 }, + { onProgress: () => {}, onLog: (m) => logs.push(m) }, + ); + expect(recovered.alreadyUpToDate).toBeUndefined(); + + // Both sidecars were parked verbatim (renamed, never deleted) before + // any open could replay them… + expect(Buffer.compare(await readFile(`${lbugPath}.wal.dirty-recovery`), walGarbage)).toBe(0); + expect(Buffer.compare(await readFile(`${lbugPath}.shadow.dirty-recovery`), shadowGarbage)).toBe( + 0, + ); + const joinedLogs = logs.join('\n'); + expect(joinedLogs).toContain('Parked lbug.wal.dirty-recovery, lbug.shadow.dirty-recovery'); + + // …the run traversed the REAL pre-wipe preservation open — recovery's + // internal force on an embedded repo upgrades to regenerate mode, whose + // banner only prints when existingEmbeddingCount was read from the + // seeded stats and the cache-load path engaged… + expect(joinedLogs).toContain( + `--force on a repo with ${seededNodeIds.length} existing embeddings`, + ); + // …with generation itself cap-suppressed (no embedder in CI): + expect(joinedLogs).toContain('exceeds the 1-node safety cap'); + + // …and the rebuild completed into a clean index: dirty flag cleared, + // and the seeded embeddings survived the park → open → wipe → restore + // round-trip (the strongest signal the preservation open really ran: + // the DB was wiped, so these rows can only come from the cache load). + const after = await loadMeta(storagePath); + expect(after!.incrementalInProgress).toBeUndefined(); + expect(after!.stats?.embeddings).toBe(seededNodeIds.length); + } finally { + vi.unstubAllEnvs(); + await repo.cleanup(); + } +} + describe('runFullAnalysis — dirty-flag recovery sidecar parking (#2409)', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + it('parks the crashed run WAL/shadow sidecars before reopening, then rebuilds clean', async () => { - const repo = await setupMiniRepo(); - try { - const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); - await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); + await parkCrashSidecarsAndRebuild(); + }, 300_000); - // Seed real embeddings BEFORE the tamper (tri-review 4669518496 / U5): - // with meta.stats.embeddings = 0 the recovery run derived - // shouldLoadCache=false and never opened the DB pre-wipe — this test - // was vacuous about the exact open the parking protects. Seeded rows + - // a stats stamp route the recovery (which runs force:true internally, - // so forceRegenerate → shouldLoadCache) through the REAL - // embedding-cache preservation open on the just-parked DB. - const { storagePath, lbugPath } = getStoragePaths(repo.dbPath); - const seededIdsByFile = await seedEmbeddingsForFiles( - repo.dbPath, - ['src/handler.ts', 'src/logger.ts'], - 1, - ); - const seededNodeIds = [...seededIdsByFile.values()].flat(); - expect(seededNodeIds.length).toBeGreaterThan(0); - - // Simulate a crashed incremental writeback: dirty flag in meta plus - // leftover sidecars whose bytes must never be replayed. 8KB puts the - // WAL above the tiny-orphan threshold — the state the sidecar - // preflight deliberately leaves in place for engine replay. - const meta = await loadMeta(storagePath); - const tampered: RepoMeta = { - ...meta!, - stats: { ...meta!.stats, embeddings: seededNodeIds.length }, - incrementalInProgress: { - startedAt: Date.now() - 60_000, - toWriteCount: 12, - phase: 'load-graph', - }, - }; - await saveMeta(storagePath, tampered); - const walGarbage = Buffer.alloc(8192, 0xab); - const shadowGarbage = Buffer.alloc(4096, 0xcd); - await writeFile(`${lbugPath}.wal`, walGarbage); - await writeFile(`${lbugPath}.shadow`, shadowGarbage); - - const logs: string[] = []; - // embeddingsNodeLimit: 1 (KTD9): the recovery runs force:true - // internally, and the seeded stats would otherwise route Phase 4 into - // a real embedder in CI — the 1-node cap suppresses generation while - // leaving the preserve/restore path fully live. On linux the - // wipe-and-restore vector-index seam then fires for real (statically - // linked VECTOR): a CREATE_VECTOR_INDEX over the restored rows is - // expected and harmless here. - const recovered = await runFullAnalysis( - repo.dbPath, - { skipAgentsMd: true, embeddingsNodeLimit: 1 }, - { onProgress: () => {}, onLog: (m) => logs.push(m) }, - ); - expect(recovered.alreadyUpToDate).toBeUndefined(); - - // Both sidecars were parked verbatim (renamed, never deleted) before - // any open could replay them… - expect(Buffer.compare(await readFile(`${lbugPath}.wal.dirty-recovery`), walGarbage)).toBe(0); - expect( - Buffer.compare(await readFile(`${lbugPath}.shadow.dirty-recovery`), shadowGarbage), - ).toBe(0); - const joinedLogs = logs.join('\n'); - expect(joinedLogs).toContain('Parked lbug.wal.dirty-recovery, lbug.shadow.dirty-recovery'); - - // …the run traversed the REAL pre-wipe preservation open — recovery's - // internal force on an embedded repo upgrades to regenerate mode, whose - // banner only prints when existingEmbeddingCount was read from the - // seeded stats and the cache-load path engaged… - expect(joinedLogs).toContain( - `--force on a repo with ${seededNodeIds.length} existing embeddings`, - ); - // …with generation itself cap-suppressed (no embedder in CI): - expect(joinedLogs).toContain('exceeds the 1-node safety cap'); - - // …and the rebuild completed into a clean index: dirty flag cleared, - // and the seeded embeddings survived the park → open → wipe → restore - // round-trip (the strongest signal the preservation open really ran: - // the DB was wiped, so these rows can only come from the cache load). - const after = await loadMeta(storagePath); - expect(after!.incrementalInProgress).toBeUndefined(); - expect(after!.stats?.embeddings).toBe(seededNodeIds.length); - } finally { - await repo.cleanup(); - } + it('restores seeded embeddings from a spill after wipe when the in-memory limit is 0', async () => { + await parkCrashSidecarsAndRebuild({ alwaysSpill: true }); }, 300_000); }); diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index f02c3879e..b52cb30a5 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -279,8 +279,10 @@ describe('PARSE_CACHE_VERSION', () => { // Moved 99 -> 100 for #3253: retain absolute Rust import qualifiers. // Moved 100 -> 101 for #3294 review: retain keyword glob paths and distinguish // restricted pub(...) imports from unrestricted reexports. - it('pins SCHEMA_BUMP to 101 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(101); + // Moved 101 -> 102 for #3273: preserve exact call-result assignment facts + // required by post-resolution Swift return-type replay. + it('pins SCHEMA_BUMP to 102 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(102); expect(PARSE_CACHE_BUCKET_COUNT).toBe(128); // The PREVIOUS version must fail the reuse gate, not merely differ from the // current one — a hardcoded number outside the conflict hunk rebases cleanly @@ -288,7 +290,7 @@ describe('PARSE_CACHE_VERSION', () => { // Every nearby historical or in-flight value is rejected. for (const taken of [ 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, - 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, + 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, ]) { expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken); } diff --git a/gitnexus/test/unit/parsedfile-store.test.ts b/gitnexus/test/unit/parsedfile-store.test.ts index 79187f73a..7834a9d3c 100644 --- a/gitnexus/test/unit/parsedfile-store.test.ts +++ b/gitnexus/test/unit/parsedfile-store.test.ts @@ -218,6 +218,55 @@ describe('parsedfile-store', () => { } }); + it('round-trips exact call-result assignment identities', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-')); + try { + const pf = makeStoreEntry('Scenario.swift', { + callResultAssignmentSites: [ + { + callSite: { startLine: 3, startCol: 14, endLine: 3, endCol: 25 }, + inScope: 'scope:run', + lhs: 'store', + }, + ], + }); + await persistParsedFileChunk(dir, 'assignment', [pf]); + + const loaded = await loadParsedFilesForPaths(dir, new Set(['Scenario.swift'])); + expect(loaded.get('Scenario.swift')?.callResultAssignmentSites).toEqual( + pf.callResultAssignmentSites, + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('drops malformed call-result assignments per site and rejects a non-array field', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-')); + try { + const mixed = makeStoreEntry('mixed.swift', { + callResultAssignmentSites: [ + { + callSite: { startLine: 3, startCol: 14, endLine: 3, endCol: 25 }, + inScope: 'scope:run', + lhs: 'store', + }, + { callSite: '3:14', inScope: 'scope:run', lhs: 'poison' }, + ], + }); + const garbage = makeStoreEntry('garbage.swift', { + callResultAssignmentSites: 'not-an-array', + }); + await persistParsedFileChunk(dir, 'assignment-invalid', [mixed, garbage]); + + const loaded = await loadParsedFilesForPaths(dir, new Set(['mixed.swift', 'garbage.swift'])); + expect(loaded.get('mixed.swift')?.callResultAssignmentSites).toHaveLength(1); + expect(loaded.has('garbage.swift')).toBe(false); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + it('drops a malformed callable-flow site but retains the file and its other sites (per-site sanitation, #2522)', async () => { const dir = await mkdtemp(path.join(tmpdir(), 'pfstore-')); try { diff --git a/gitnexus/test/unit/process-detection-budget.test.ts b/gitnexus/test/unit/process-detection-budget.test.ts new file mode 100644 index 000000000..bb0719e88 --- /dev/null +++ b/gitnexus/test/unit/process-detection-budget.test.ts @@ -0,0 +1,235 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + buildProcessDetectionPhaseConfig, + formatInvalidProcessDetectionOverride, + formatProcessDetectionBudgetBanner, + formatWholeFlowsMissingRemedies, + parsePositiveIntegerOverride, + parseProcessDetectionBudgetStrings, + processDetectionBudgetMismatch, + processDetectionEffectiveLimits, + resolveProcessDetectionBudget, + toProcessDetectionStamp, + uncertifyProcessDetectionStamp, +} from '../../src/core/ingestion/process-detection-budget.js'; + +describe('parsePositiveIntegerOverride', () => { + it('accepts positive integers and rejects 0 / non-integers', () => { + const invalid: string[] = []; + expect(parsePositiveIntegerOverride('25')).toBe(25); + expect(parsePositiveIntegerOverride(40)).toBe(40); + expect(parsePositiveIntegerOverride('0', (raw) => invalid.push(raw))).toBeUndefined(); + expect(parsePositiveIntegerOverride('abc', (raw) => invalid.push(raw))).toBeUndefined(); + expect(parsePositiveIntegerOverride('-3', (raw) => invalid.push(raw))).toBeUndefined(); + expect(parsePositiveIntegerOverride('', (raw) => invalid.push(raw))).toBeUndefined(); + expect(invalid).toEqual(['0', 'abc', '-3', '']); + }); +}); + +describe('resolveProcessDetectionBudget (#3313)', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('keeps shipped defaults when nothing is set', () => { + const resolved = resolveProcessDetectionBudget({}, {}); + expect(resolved.maxProcesses).toBeUndefined(); + expect(resolved.maxProcessBranching).toBe(4); + expect(resolved.maxProcessTraceDepth).toBe(10); + expect(resolved.maxEntryPointCandidates).toBe(200); + expect(resolved.overridden).toEqual({ + maxProcesses: false, + maxProcessBranching: false, + maxProcessTraceDepth: false, + maxEntryPointCandidates: false, + }); + }); + + it('lets an invalid option fall through to env instead of claiming a hard default', () => { + const invalid: Array<[string, string]> = []; + const resolved = resolveProcessDetectionBudget( + { maxProcesses: 0 }, + { GITNEXUS_MAX_PROCESSES: '80' }, + (knob, raw) => invalid.push([knob, raw]), + ); + expect(resolved.maxProcesses).toBe(80); + expect(invalid).toEqual([['--max-processes', '0']]); + expect(formatInvalidProcessDetectionOverride('--max-processes', '0')).toContain( + 'next source (env, then the built-in default)', + ); + expect(formatInvalidProcessDetectionOverride('GITNEXUS_MAX_PROCESSES', '0')).toContain( + 'the built-in default', + ); + expect(formatInvalidProcessDetectionOverride('GITNEXUS_MAX_PROCESSES', '0')).not.toContain( + 'next source (env, then the built-in default)', + ); + }); + + it('lets explicit options beat env (AE3 remainder)', () => { + const resolved = resolveProcessDetectionBudget( + { maxProcesses: 25 }, + { GITNEXUS_MAX_PROCESSES: '80' }, + ); + expect(resolved.maxProcesses).toBe(25); + expect(resolved.overridden.maxProcesses).toBe(true); + }); + + it('reads env when option fields are unset', () => { + const resolved = resolveProcessDetectionBudget( + {}, + { + GITNEXUS_MAX_PROCESSES: '40', + GITNEXUS_MAX_PROCESS_BRANCHING: '2', + GITNEXUS_MAX_PROCESS_TRACE_DEPTH: '8', + GITNEXUS_MAX_ENTRY_POINT_CANDIDATES: '400', + }, + ); + expect(resolved.maxProcesses).toBe(40); + expect(resolved.maxProcessBranching).toBe(2); + expect(resolved.maxProcessTraceDepth).toBe(8); + expect(resolved.maxEntryPointCandidates).toBe(400); + }); + + it('warns and falls back on invalid env (AE5)', () => { + const invalid: Array<[string, string]> = []; + const zero = resolveProcessDetectionBudget({}, { GITNEXUS_MAX_PROCESSES: '0' }, (knob, raw) => + invalid.push([knob, raw]), + ); + const garbage = resolveProcessDetectionBudget( + {}, + { GITNEXUS_MAX_PROCESSES: 'abc' }, + (knob, raw) => invalid.push([knob, raw]), + ); + expect(zero.maxProcesses).toBeUndefined(); + expect(garbage.maxProcesses).toBeUndefined(); + expect(invalid).toEqual([ + ['GITNEXUS_MAX_PROCESSES', '0'], + ['GITNEXUS_MAX_PROCESSES', 'abc'], + ]); + }); + + it('replaces the dynamic formula only when maxProcesses is explicit', () => { + const unset = buildProcessDetectionPhaseConfig( + resolveProcessDetectionBudget({}, {}), + 1000, + (n) => Math.max(20, Math.round(n / 10)), + ); + const explicit = buildProcessDetectionPhaseConfig( + resolveProcessDetectionBudget({ maxProcesses: 5 }, {}), + 1000, + (n) => Math.max(20, Math.round(n / 10)), + ); + expect(unset.maxProcesses).toBe(100); + expect(explicit.maxProcesses).toBe(5); + expect(explicit.minSteps).toBe(3); + }); +}); + +describe('processDetectionBudgetMismatch (KTD4)', () => { + const defaults = resolveProcessDetectionBudget({}, {}); + + it('matches a missing stamp against default/dynamic knobs', () => { + expect(processDetectionBudgetMismatch(undefined, defaults)).toBe(false); + }); + + it('mismatches a missing stamp when any override is set', () => { + expect( + processDetectionBudgetMismatch( + undefined, + resolveProcessDetectionBudget({ maxEntryPointCandidates: 400 }, {}), + ), + ).toBe(true); + }); + + it('mismatches when a present stamp differs', () => { + const recorded = toProcessDetectionStamp(defaults); + expect( + processDetectionBudgetMismatch({ ...recorded, maxEntryPointCandidates: 400 }, defaults), + ).toBe(true); + }); + + it('mismatches explicit maxProcesses vs later dynamic even when the integer equals the formula', () => { + const explicit = resolveProcessDetectionBudget({ maxProcesses: 100 }, {}); + expect(processDetectionBudgetMismatch(toProcessDetectionStamp(explicit), defaults)).toBe(true); + expect(toProcessDetectionStamp(explicit).maxProcesses).toBe(100); + expect(toProcessDetectionStamp(defaults).maxProcesses).toBe(null); + }); + + it('matches an identical present stamp', () => { + const resolved = resolveProcessDetectionBudget( + { maxProcesses: 25, maxEntryPointCandidates: 400 }, + {}, + ); + expect(processDetectionBudgetMismatch(toProcessDetectionStamp(resolved), resolved)).toBe(false); + }); + + it('mismatches an uncertified stamp even when the numeric fields match defaults', () => { + const recorded = uncertifyProcessDetectionStamp(toProcessDetectionStamp(defaults)); + expect(recorded.uncertified).toBe(true); + expect(recorded.maxProcesses).toBe(null); + expect(processDetectionBudgetMismatch(recorded, defaults)).toBe(true); + expect(processDetectionBudgetMismatch(toProcessDetectionStamp(defaults), defaults)).toBe(false); + }); +}); + +describe('warning copy and banner', () => { + it('maps loud counters to the matching knobs and names the pre-entry trace gate', () => { + const limits = processDetectionEffectiveLimits(20, resolveProcessDetectionBudget({}, {})); + expect(limits.maxProcessTraces).toBe(40); + expect( + formatWholeFlowsMissingRemedies( + { entryPointCandidatesDropped: 210, entryPointsUnexplored: 0, processesDropped: 0 }, + limits, + 410, + ), + ).toContain('--max-entry-point-candidates'); + expect( + formatWholeFlowsMissingRemedies( + { entryPointCandidatesDropped: 210, entryPointsUnexplored: 0, processesDropped: 0 }, + limits, + 410, + ), + ).not.toContain('--max-process-branching'); + expect( + formatWholeFlowsMissingRemedies( + { entryPointCandidatesDropped: 0, entryPointsUnexplored: 3, processesDropped: 1 }, + limits, + 10, + ), + ).toContain('--max-processes'); + expect( + formatWholeFlowsMissingRemedies( + { entryPointCandidatesDropped: 0, entryPointsUnexplored: 3, processesDropped: 1 }, + limits, + 10, + ), + ).toContain('40'); + expect( + formatWholeFlowsMissingRemedies( + { entryPointCandidatesDropped: 0, entryPointsUnexplored: 3, processesDropped: 1 }, + limits, + 10, + ), + ).toContain('next entry is skipped'); + }); + + it('prints a banner only when an override is active', () => { + expect(formatProcessDetectionBudgetBanner(resolveProcessDetectionBudget({}, {}))).toBeNull(); + expect( + formatProcessDetectionBudgetBanner(resolveProcessDetectionBudget({ maxProcesses: 25 }, {})), + ).toContain('maxProcesses=25'); + expect( + formatProcessDetectionBudgetBanner( + resolveProcessDetectionBudget({ maxProcessBranching: 6 }, {}), + ), + ).toContain('maxProcesses=dynamic (max(20, round(symbols/10)))'); + }); + + it('parses CLI/rc numeric strings without treating 0 as unlimited', () => { + const parsed = parseProcessDetectionBudgetStrings({ + maxProcesses: '25', + maxProcessBranching: '0', + }); + expect(parsed).toEqual({ maxProcesses: 25 }); + }); +}); diff --git a/gitnexus/test/unit/process-processor.test.ts b/gitnexus/test/unit/process-processor.test.ts index 69785034c..2891704fc 100644 --- a/gitnexus/test/unit/process-processor.test.ts +++ b/gitnexus/test/unit/process-processor.test.ts @@ -585,7 +585,13 @@ describe('process depth (D1/D2)', () => { // `findEntryPoints` returns several starting points, so the deep chain is // traced from inside it whatever the traversal order does — a test there // passes under BOTH traversals and guards nothing. - const cfg = { maxTraceDepth: 10, maxBranching: 4, maxProcesses: 75, minSteps: 3 }; + const cfg = { + maxTraceDepth: 10, + maxBranching: 4, + maxProcesses: 75, + minSteps: 3, + maxEntryPointCandidates: 200, + }; const deepAndShallow = (order: readonly string[]): Map => { // Fan-out is capped at maxBranching (4), so the budget is exhausted BELOW @@ -1214,6 +1220,26 @@ describe('the entry-point candidate cap is disclosed too', () => { expect(result.stats.truncation.entryPointCandidatesDropped).toBe(0); expect(result.stats.truncation.truncated).toBe(false); }); + + it('honors maxEntryPointCandidates instead of the compiled 200 (#3313)', async () => { + const result = await processProcesses(manyCandidates(), [], undefined, { + maxProcesses: 1000, + maxEntryPointCandidates: 410, + }); + + expect(result.stats.entryPointsFound).toBe(410); + expect(result.stats.truncation.entryPointCandidatesDropped).toBe(0); + }); + + it('drops one candidate when the override is one below the list length (#3313)', async () => { + const result = await processProcesses(manyCandidates(), [], undefined, { + maxProcesses: 1000, + maxEntryPointCandidates: 409, + }); + + expect(result.stats.entryPointsFound).toBe(409); + expect(result.stats.truncation.entryPointCandidatesDropped).toBe(1); + }); }); // The three trace sorts in this file each joined the path inside the COMPARATOR diff --git a/gitnexus/test/unit/processes-phase-sink-wiring.test.ts b/gitnexus/test/unit/processes-phase-sink-wiring.test.ts index 8f91e79a4..196ec813d 100644 --- a/gitnexus/test/unit/processes-phase-sink-wiring.test.ts +++ b/gitnexus/test/unit/processes-phase-sink-wiring.test.ts @@ -24,11 +24,12 @@ import type { PhaseResult, PipelineContext, } from '../../src/core/ingestion/pipeline-phases/types.js'; +import type { PipelineOptions } from '../../src/core/ingestion/pipeline.js'; import type { KnowledgeGraph } from '../../src/core/graph/types.js'; import type { GraphNode, NodeLabel } from 'gitnexus-shared'; -function makeCtx(graph: KnowledgeGraph): PipelineContext { - return { repoPath: '/tmp/repo', graph, onProgress: () => {}, pipelineStart: 0 }; +function makeCtx(graph: KnowledgeGraph, options?: PipelineOptions): PipelineContext { + return { repoPath: '/tmp/repo', graph, onProgress: () => {}, pipelineStart: 0, options }; } function phaseResult(phaseName: string, output: T): PhaseResult { @@ -219,12 +220,16 @@ describe('processes phase — truncation is disclosed proportionately (#2899)', const runCaptured = async ( graph: KnowledgeGraph, + options?: PipelineOptions, ): Promise<{ output: ProcessesOutput; records: ReturnType }> => { // Captured at `debug` so an ABSENT warn can be distinguished from a silent // phase: the debug line has to be there instead. const capture = _captureLogger('debug'); try { - const output = (await processesPhase.execute(makeCtx(graph), baseDeps())) as ProcessesOutput; + const output = (await processesPhase.execute( + makeCtx(graph, options), + baseDeps(), + )) as ProcessesOutput; return { output, records: capture.records() }; } finally { capture.restore(); @@ -297,5 +302,77 @@ describe('processes phase — truncation is disclosed proportionately (#2899)', const lines = records.filter((r) => PROCESS_LINES.test(String(r.msg))); expect(lines.map((r) => r.level)).toEqual([40]); expect(String(lines[0]?.msg)).toContain('210 of 410 candidate entry point(s) never ranked in'); + expect(String(lines[0]?.msg)).toContain('--max-entry-point-candidates'); + expect(String(lines[0]?.msg)).not.toContain('--max-process-branching'); + expect(lines[0]?.effectiveLimits).toEqual( + expect.objectContaining({ + maxEntryPointCandidates: 200, + maxProcessTraces: expect.any(Number), + }), + ); + }); + + it('honors an explicit maxProcesses instead of the dynamic formula (#3313)', async () => { + const { output } = await runCaptured(flowsMissing(), { maxProcesses: 40 }); + expect(output.processResult.stats.truncation.processesDropped).toBe(0); + expect(output.processResult.stats.truncation.entryPointsUnexplored).toBe(0); + }); + + it('clears the entry-point drop when the candidate cap is raised (#3313 AE2)', async () => { + const graph = createKnowledgeGraph(); + for (let c = 0; c < 205; c++) { + for (let i = 0; i < 3; i++) addFn(graph, `func:r${c}_${i}`); + for (let i = 0; i < 2; i++) addCallEdge(graph, `func:r${c}_${i}`, `func:r${c}_${i + 1}`); + } + const { output } = await runCaptured(graph, { maxEntryPointCandidates: 410 }); + expect(output.processResult.stats.truncation.entryPointCandidatesDropped).toBe(0); + }); + + it('still warns when only maxProcesses is raised on a 410-candidate fixture (#3313 AE2)', async () => { + const graph = createKnowledgeGraph(); + for (let c = 0; c < 205; c++) { + for (let i = 0; i < 3; i++) addFn(graph, `func:s${c}_${i}`); + for (let i = 0; i < 2; i++) addCallEdge(graph, `func:s${c}_${i}`, `func:s${c}_${i + 1}`); + } + const { output, records } = await runCaptured(graph, { maxProcesses: 1000 }); + expect(output.processResult.stats.truncation.entryPointCandidatesDropped).toBe(210); + const lines = records.filter((r) => PROCESS_LINES.test(String(r.msg))); + expect(lines.map((r) => r.level)).toEqual([40]); + }); + + it('increases process count when both binders are raised (#3313 AE6)', async () => { + const graph = createKnowledgeGraph(); + for (let c = 0; c < 210; c++) { + for (let i = 0; i < 3; i++) addFn(graph, `func:ae6_${c}_${i}`); + for (let i = 0; i < 2; i++) + addCallEdge(graph, `func:ae6_${c}_${i}`, `func:ae6_${c}_${i + 1}`); + } + // 210 chains × 3 = 630 symbols. Dynamic maxProcesses needs ≥ 210, so pad. + for (let i = 0; i < 1470; i++) addFn(graph, `func:pad_${i}`); + + const baseline = await runCaptured(graph); + const raised = await runCaptured(graph, { + maxEntryPointCandidates: 420, + maxProcesses: 300, + }); + expect( + baseline.output.processResult.stats.truncation.entryPointCandidatesDropped, + ).toBeGreaterThan(0); + expect(raised.output.processResult.stats.truncation.entryPointCandidatesDropped).toBe(0); + expect(raised.output.processResult.processes.length).toBeGreaterThan( + baseline.output.processResult.processes.length, + ); + }); + + it('keeps shape-only caps at debug when branching or depth is raised (#3313 AE7)', async () => { + const { output, records } = await runCaptured(shortenedOnly(), { + maxProcessBranching: 8, + maxProcessTraceDepth: 20, + }); + expect(output.processResult.stats.truncation.calleesDropped).toBe(0); + expect(output.processResult.stats.truncation.tracesDepthCapped).toBe(0); + const lines = records.filter((r) => PROCESS_LINES.test(String(r.msg))); + expect(lines.some((r) => r.level === 40)).toBe(false); + expect(lines.some((r) => String(r.msg).includes('whole flows are MISSING'))).toBe(false); }); }); diff --git a/gitnexus/test/unit/query-determinism-guard.test.ts b/gitnexus/test/unit/query-determinism-guard.test.ts index e6f7acd72..ebad4efb9 100644 --- a/gitnexus/test/unit/query-determinism-guard.test.ts +++ b/gitnexus/test/unit/query-determinism-guard.test.ts @@ -47,10 +47,21 @@ * and `detect-changes-local-id-stability.test.ts`. */ import { describe, expect, it } from 'vitest'; -import ts from 'typescript'; +import * as t from '@babel/types'; import { readdirSync, readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { + type AstNode, + type CommentRange, + forEachChild, + leadingCommentRanges, + lineAt, + nodeStart, + nodeText, + parseTypeScript, + trailingCommentRanges, +} from '../helpers/parse-typescript-source.js'; const SRC_DIR = fileURLToPath(new URL('../../src/', import.meta.url)); @@ -135,18 +146,18 @@ function walkTs(dir: string, relBase: string, out: string[]): string[] { * helpers (`seedBlockQuery`, `buildFtsQueryCypher`), and anything built from * those. Resolved to a fixpoint so an ordering constant can be composed. */ -function collectOrderingNames(sf: ts.SourceFile): Set { +function collectOrderingNames(source: string, ast: t.Node): Set { const initializers = new Map(); - const visit = (node: ts.Node): void => { - if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) { - initializers.set(node.name.text, node.initializer.getText(sf)); + const visit = (node: t.Node): void => { + if (t.isVariableDeclarator(node) && t.isIdentifier(node.id) && node.init) { + initializers.set(node.id.name, nodeText(source, node.init)); } - if (ts.isFunctionDeclaration(node) && node.name !== undefined) { - initializers.set(node.name.text, node.getText(sf)); + if (t.isFunctionDeclaration(node) && t.isIdentifier(node.id)) { + initializers.set(node.id.name, nodeText(source, node)); } - ts.forEachChild(node, visit); + forEachChild(node, visit); }; - visit(sf); + visit(ast); const ordering = new Set(); const referencesOrdering = (text: string): boolean => @@ -166,38 +177,38 @@ function collectOrderingNames(sf: ts.SourceFile): Set { // --------------------------------------------------------------------------- // Query units // --------------------------------------------------------------------------- -const isLiteralUnit = (node: ts.Node): boolean => - ts.isStringLiteralLike(node) || ts.isTemplateExpression(node); +const isLiteralUnit = (node: t.Node): boolean => + t.isStringLiteral(node) || t.isTemplateLiteral(node); /** Climb `+` concatenation so a query split across fragments is ONE unit. */ -function concatenationRoot(node: ts.Node): ts.Node { - let current = node; +function concatenationRoot(node: AstNode): AstNode { + let current: AstNode = node; while ( current.parent !== undefined && - ts.isBinaryExpression(current.parent) && - current.parent.operatorToken.kind === ts.SyntaxKind.PlusToken + t.isBinaryExpression(current.parent) && + current.parent.operator === '+' ) { current = current.parent; } return current; } -function collectQueryUnits(sf: ts.SourceFile): ts.Node[] { - const byPosition = new Map(); - const visit = (node: ts.Node): void => { +function collectQueryUnits(source: string, ast: t.Node): AstNode[] { + const byPosition = new Map(); + const visit = (node: AstNode): void => { if (!isLiteralUnit(node)) { - ts.forEachChild(node, visit); + forEachChild(node, visit); return; } // Never descend INTO a literal: a nested template inside `${...}` is // already covered by the outer unit's text. const unit = concatenationRoot(node); - const text = unit.getText(sf); + const text = nodeText(source, unit); if (LIMIT_RE.test(text) && (CYPHER_CLAUSE_RE.test(text) || BARE_LIMIT_FRAGMENT_RE.test(text))) { - byPosition.set(unit.getStart(sf), unit); + byPosition.set(nodeStart(unit), unit); } }; - visit(sf); + visit(ast as AstNode); return [...byPosition.entries()].sort((a, b) => a[0] - b[0]).map(([, node]) => node); } @@ -216,14 +227,14 @@ interface CommentBlock { end: number; } -const isFunctionLike = (node: ts.Node): boolean => - ts.isFunctionDeclaration(node) || - ts.isFunctionExpression(node) || - ts.isArrowFunction(node) || - ts.isMethodDeclaration(node) || - ts.isConstructorDeclaration(node) || - ts.isGetAccessorDeclaration(node) || - ts.isSetAccessorDeclaration(node); +const isFunctionLike = (node: t.Node): boolean => + t.isFunctionDeclaration(node) || + t.isFunctionExpression(node) || + t.isArrowFunctionExpression(node) || + t.isClassMethod(node) || + t.isClassPrivateMethod(node) || + t.isObjectMethod(node) || + t.isTSDeclareMethod(node); /** * Comment blocks a marker for `node` may live in, NEAREST FIRST: the query's @@ -239,22 +250,21 @@ const isFunctionLike = (node: ts.Node): boolean => * Consecutive `//` lines arrive as one range EACH, so they are merged back into * a block: a reason may then wrap across lines and still fit `printWidth: 100`. */ -function markerBlocks(source: string, node: ts.Node): CommentBlock[] { - const nearestFirst: ts.CommentRange[] = []; +function markerBlocks(source: string, node: AstNode): CommentBlock[] { + const nearestFirst: CommentRange[] = []; const seen = new Set(); - let current: ts.Node | undefined = node; + let current: AstNode | undefined = node; for (let depth = 0; current !== undefined && depth < 20; depth++) { - for (const range of [ - ...(ts.getLeadingCommentRanges(source, current.getFullStart()) ?? []), - ...(ts.getTrailingCommentRanges(source, current.getEnd()) ?? []), - ]) { + for (const range of [...leadingCommentRanges(current), ...trailingCommentRanges(current)]) { if (!seen.has(range.pos)) { seen.add(range.pos); nearestFirst.push(range); } } - const parent: ts.Node | undefined = current.parent; - if (parent === undefined || isFunctionLike(parent) || ts.isSourceFile(parent)) break; + const parent: AstNode | undefined = current.parent; + if (parent === undefined || isFunctionLike(parent) || t.isFile(parent) || t.isProgram(parent)) { + break; + } current = parent; } @@ -318,15 +328,15 @@ const reasonIsSubstantive = (reason: string): boolean => // --------------------------------------------------------------------------- export function analyzeSource(file: string, source: string): QueryFinding[] { if (!LIMIT_RE.test(source)) return []; - const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true); - const ordering = collectOrderingNames(sf); + const { ast } = parseTypeScript(file, source); + const ordering = collectOrderingNames(source, ast); /** A marker declares ONE query: a claimed range cannot also cover the next. */ const claimed = new Map(); - return collectQueryUnits(sf).map((unit) => { - const text = unit.getText(sf); + return collectQueryUnits(source, ast).map((unit) => { + const text = nodeText(source, unit); const limitOffset = Math.max(text.search(LIMIT_RE), 0); - const line = sf.getLineAndCharacterOfPosition(unit.getStart(sf) + limitOffset).line + 1; + const line = lineAt(source, nodeStart(unit) + limitOffset); const excerpt = text.replace(/\s+/g, ' ').slice(0, 160); const base = { file, line, excerpt }; diff --git a/gitnexus/test/unit/run-analyze-fts-crash-marker.test.ts b/gitnexus/test/unit/run-analyze-fts-crash-marker.test.ts index bc33cd9f2..eba29a5ac 100644 --- a/gitnexus/test/unit/run-analyze-fts-crash-marker.test.ts +++ b/gitnexus/test/unit/run-analyze-fts-crash-marker.test.ts @@ -9,7 +9,7 @@ import { readFileSync } from 'node:fs'; import fs from 'fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getStoragePaths, loadMeta, @@ -17,11 +17,16 @@ import { type RepoMeta, } from '../../src/storage/repo-manager.js'; import { createTempDir } from '../helpers/test-db.js'; +import { computeFileHash } from '../../src/storage/file-hash.js'; import { ANALYSIS_FEATURES } from '../../src/core/analysis-feature-registry.js'; import { resolveAnalysisFeatureVersions } from '../../src/core/analysis-features.js'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; import { resolveAnalyzerRunnerIdentity } from '../../src/core/analyzer-identity.js'; import { EMBEDDING_DIMS, SCHEMA_FINGERPRINT } from '../../src/core/lbug/schema.js'; +import { + PROCESS_DETECTION_BUDGET_DEFAULTS, + PROCESS_DETECTION_ENV, +} from '../../src/core/ingestion/process-detection-budget.js'; import { getSearchFTSCjkSegmentation } from '../../src/core/search/cjk-segmentation.js'; import { FTS_DIRTY_PHASE, @@ -303,6 +308,11 @@ describe('FTS crash-marker policy (characterization)', () => { }); describe('runFullAnalysis FTS crash marker', () => { + beforeEach(() => { + for (const key of Object.values(PROCESS_DETECTION_ENV)) { + vi.stubEnv(key, undefined); + } + }); afterEach(() => { vi.doUnmock('../../src/core/lbug/lbug-adapter.js'); vi.doUnmock('../../src/core/search/fts-indexes.js'); @@ -378,6 +388,7 @@ describe('runFullAnalysis FTS crash marker', () => { writePlan: 'in-place', checkpointSucceeded: true, }); + expect(midBuild?.processDetection?.uncertified).toBeUndefined(); expect(sequence.indexOf('checkpoint')).toBeLessThan(sequence.indexOf('stamp-fts')); expect(sequence.indexOf('stamp-fts')).toBeLessThan(sequence.indexOf('build')); expect(checkpointOnce).toHaveBeenCalled(); @@ -389,6 +400,73 @@ describe('runFullAnalysis FTS crash marker', () => { } }); + it('stamps processDetection.uncertified before in-place FTS when the budget mismatched', async () => { + const sequence: string[] = []; + let midBuild: RepoMeta | null = null; + vi.doMock('../../src/core/lbug/wal-checkpoint-driver.js', async (importActual) => ({ + ...(await importActual()), + checkpointOnce: vi.fn(async () => true), + })); + vi.doMock('../../src/core/lbug/lbug-adapter.js', mockLbugAdapter); + vi.doMock('../../src/core/search/fts-indexes.js', async (importActual) => ({ + ...(await importActual()), + initialiseSearchFTSStemmer: vi.fn(() => 'porter'), + missingSearchFTSIndexTables: vi.fn(async () => []), + dropSearchFTSIndexes: vi.fn(async () => undefined), + buildSearchIndexesOrDegrade: vi.fn(async () => { + sequence.push('build'); + return { ok: true }; + }), + })); + vi.doMock('../../src/core/ingestion/pipeline.js', () => ({ + runPipelineFromRepo: vi.fn(async (repoPath: string) => ({ + repoPath, + graph: fileGraph(), + })), + })); + vi.doMock('../../src/storage/repo-manager.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + saveMeta: async (...args: Parameters) => { + if (args[1].incrementalInProgress?.phase === FTS_DIRTY_PHASE) { + sequence.push('stamp-fts'); + midBuild = args[1]; + } + return actual.saveMeta(...args); + }, + }; + }); + + const tmpRepo = await createTempDir('gitnexus-fts-crash-uncertify-'); + try { + await seedGitFile(tmpRepo.dbPath); + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await fs.mkdir(storagePath, { recursive: true }); + await saveMeta(storagePath, incrementalMeta(tmpRepo.dbPath)); + + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + await runFullAnalysis( + tmpRepo.dbPath, + { maxProcesses: 25, skipAgentsMd: true, skipSkills: true }, + { onProgress: () => {}, onLog: () => {} }, + ); + + expect(midBuild?.processDetection).toMatchObject({ + uncertified: true, + maxProcesses: null, + }); + expect(sequence.indexOf('stamp-fts')).toBeGreaterThan(-1); + expect(sequence.indexOf('build')).toBeGreaterThan(-1); + expect(sequence.indexOf('stamp-fts')).toBeLessThan(sequence.indexOf('build')); + const finalMeta = await loadMeta(storagePath); + expect(finalMeta?.processDetection?.uncertified).toBeUndefined(); + expect(finalMeta?.processDetection?.maxProcesses).toBe(25); + } finally { + await tmpRepo.cleanup(); + } + }); + it.skipIf(process.platform === 'win32')( 'does not stamp an FTS phase on a staging plan', async () => { @@ -442,6 +520,123 @@ describe('runFullAnalysis FTS crash marker', () => { }, ); + it.skipIf(process.platform === 'win32')( + 'does not persist live incrementalInProgress when atomic incremental dies during staging copy', + async () => { + vi.doMock('../../src/core/lbug/lbug-adapter.js', mockLbugAdapter); + vi.doMock('../../src/core/search/fts-indexes.js', async (importActual) => ({ + ...(await importActual()), + initialiseSearchFTSStemmer: vi.fn(() => 'porter'), + missingSearchFTSIndexTables: vi.fn(async () => []), + dropSearchFTSIndexes: vi.fn(async () => undefined), + buildSearchIndexesOrDegrade: vi.fn(async () => ({ ok: true })), + })); + vi.doMock('../../src/core/ingestion/pipeline.js', () => ({ + runPipelineFromRepo: vi.fn(async (repoPath: string) => ({ + repoPath, + graph: fileGraph(), + })), + })); + + const tmpRepo = await createTempDir('gitnexus-atomic-incr-copy-crash-'); + try { + await seedGitFile(tmpRepo.dbPath); + const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath); + await fs.mkdir(storagePath, { recursive: true }); + const fileHash = await computeFileHash(path.join(tmpRepo.dbPath, REL_FILE)); + await saveMeta(storagePath, { + ...incrementalMeta(tmpRepo.dbPath), + lastCommit: headCommit(tmpRepo.dbPath), + fileHashes: { [REL_FILE]: fileHash! }, + processDetection: { + maxProcesses: 80, + maxProcessBranching: 4, + maxProcessTraceDepth: 10, + maxEntryPointCandidates: 200, + }, + }); + await createPlaceholderGraphStore(lbugPath); + + const originalCopyFile: typeof fs.copyFile = fs.copyFile.bind(fs); + const copyFile = vi.spyOn(fs, 'copyFile').mockImplementation(async (src, dest, mode) => { + if (String(dest).includes('.staging.')) { + throw new Error('simulated staging copy crash'); + } + return originalCopyFile(src, dest, mode); + }); + + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + await expect( + runFullAnalysis( + tmpRepo.dbPath, + { atomicIncremental: true, maxProcesses: 25, skipAgentsMd: true, skipSkills: true }, + { onProgress: () => {}, onLog: () => {} }, + ), + ).rejects.toThrow('simulated staging copy crash'); + expect(copyFile).toHaveBeenCalled(); + + const liveMeta = await loadMeta(storagePath); + expect(liveMeta?.incrementalInProgress).toBeUndefined(); + expect(liveMeta?.processDetection?.maxProcesses).toBe(80); + expect(liveMeta?.processDetection?.uncertified).toBeUndefined(); + } finally { + await tmpRepo.cleanup(); + } + }, + ); + + it('stamps phase pre-write on live meta before in-place incremental writeback', async () => { + vi.doMock('../../src/core/lbug/lbug-adapter.js', mockLbugAdapter); + vi.doMock('../../src/core/search/fts-indexes.js', async (importActual) => ({ + ...(await importActual()), + initialiseSearchFTSStemmer: vi.fn(() => 'porter'), + missingSearchFTSIndexTables: vi.fn(async () => []), + dropSearchFTSIndexes: vi.fn(async () => undefined), + buildSearchIndexesOrDegrade: vi.fn(async () => ({ ok: true })), + })); + vi.doMock('../../src/core/ingestion/pipeline.js', () => ({ + runPipelineFromRepo: vi.fn(async (repoPath: string) => ({ + repoPath, + graph: fileGraph(), + })), + })); + vi.doMock('../../src/storage/repo-manager.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + saveMeta: async (...args: Parameters) => { + const result = await actual.saveMeta(...args); + if (args[1].incrementalInProgress?.phase === 'pre-write') { + throw new Error('stop after in-place dirty stamp'); + } + return result; + }, + }; + }); + + const tmpRepo = await createTempDir('gitnexus-inplace-pre-write-stamp-'); + try { + await seedGitFile(tmpRepo.dbPath); + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await fs.mkdir(storagePath, { recursive: true }); + await saveMeta(storagePath, incrementalMeta(tmpRepo.dbPath)); + + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + await expect( + runFullAnalysis( + tmpRepo.dbPath, + { skipAgentsMd: true, skipSkills: true }, + { onProgress: () => {}, onLog: () => {} }, + ), + ).rejects.toThrow('stop after in-place dirty stamp'); + + const liveMeta = await loadMeta(storagePath); + expect(liveMeta?.incrementalInProgress).toMatchObject({ phase: 'pre-write' }); + } finally { + await tmpRepo.cleanup(); + } + }); + it('clears the FTS phase on the degrade path as well as on success', async () => { vi.doMock('../../src/core/lbug/lbug-adapter.js', mockLbugAdapter); vi.doMock('../../src/core/search/fts-indexes.js', async (importActual) => ({ @@ -908,6 +1103,64 @@ describe('runFullAnalysis FTS crash marker', () => { } }); + it('re-detects flows after FTS park when processDetection is uncertified', async () => { + const wipeLbugDbFiles = vi.fn(async () => undefined); + const runDeferredDerivedPhases = vi.fn(async () => undefined); + const runPipelineFromRepo = vi.fn(async (repoPath: string) => ({ + repoPath, + graph: fileGraph(), + runDeferredDerivedPhases, + })); + vi.doMock('../../src/core/lbug/lbug-adapter.js', async () => ({ + ...(await mockLbugAdapter()), + wipeLbugDbFiles, + })); + vi.doMock('../../src/core/search/fts-indexes.js', async (importActual) => ({ + ...(await importActual()), + initialiseSearchFTSStemmer: vi.fn(() => 'porter'), + missingSearchFTSIndexTables: vi.fn(async () => []), + dropSearchFTSIndexes: vi.fn(async () => undefined), + buildSearchIndexesOrDegrade: vi.fn(async () => ({ ok: true })), + })); + vi.doMock('../../src/core/ingestion/pipeline.js', () => ({ runPipelineFromRepo })); + + const tmpRepo = await createTempDir('gitnexus-fts-crash-uncertified-park-'); + try { + await seedGitFile(tmpRepo.dbPath); + const { storagePath, lbugPath } = getStoragePaths(tmpRepo.dbPath); + await fs.mkdir(storagePath, { recursive: true }); + await saveMeta(storagePath, { + ...incrementalMeta(tmpRepo.dbPath), + lastCommit: headCommit(tmpRepo.dbPath), + processDetection: { + maxProcesses: null, + maxProcessBranching: PROCESS_DETECTION_BUDGET_DEFAULTS.maxProcessBranching, + maxProcessTraceDepth: PROCESS_DETECTION_BUDGET_DEFAULTS.maxProcessTraceDepth, + maxEntryPointCandidates: PROCESS_DETECTION_BUDGET_DEFAULTS.maxEntryPointCandidates, + uncertified: true, + }, + incrementalInProgress: ftsInPlaceDirty, + }); + await fs.writeFile(lbugPath, GRAPH_BYTES); + await fs.writeFile(`${lbugPath}.wal`, WAL_PATTERN); + + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + const result = await runFullAnalysis( + tmpRepo.dbPath, + { skipAgentsMd: true, skipSkills: true }, + { onProgress: () => {}, onLog: () => {} }, + ); + + expect(result.alreadyUpToDate).not.toBe(true); + expect(runPipelineFromRepo).toHaveBeenCalled(); + expect(runDeferredDerivedPhases).toHaveBeenCalled(); + const finalMeta = await loadMeta(storagePath); + expect(finalMeta?.processDetection?.uncertified).toBeUndefined(); + } finally { + await tmpRepo.cleanup(); + } + }); + it('does not keep the graph for a non-FTS dirty flag', async () => { const wipeLbugDbFiles = vi.fn(async () => undefined); vi.doMock('../../src/core/lbug/lbug-adapter.js', async () => ({ diff --git a/gitnexus/test/unit/run-analyze-fts-repair.test.ts b/gitnexus/test/unit/run-analyze-fts-repair.test.ts index 8d68c8ddd..79ec0c4b1 100644 --- a/gitnexus/test/unit/run-analyze-fts-repair.test.ts +++ b/gitnexus/test/unit/run-analyze-fts-repair.test.ts @@ -7,7 +7,7 @@ import { saveMeta, type RepoMeta, } from '../../src/storage/repo-manager.js'; -import { EMBEDDING_DIMS } from '../../src/core/lbug/schema.js'; +import { EMBEDDING_DIMS, STALE_HASH_SENTINEL } from '../../src/core/lbug/schema.js'; import { getIndexIncompleteReasons } from '../../src/core/index-freshness.js'; import type { EmbeddingPipelineOptions, @@ -1225,6 +1225,313 @@ describe('runFullAnalysis wipe-and-restore vector-index stamp (tri-review 466951 await tmpRepo.cleanup(); } }); + + it('warns and continues without restore when loadCachedEmbeddings rejects (R11)', async () => { + const RESTORED_NODE_ID = 'Function:src/app.ts:handler:1'; + const stubNode = { + id: RESTORED_NODE_ID, + label: 'Function', + name: 'handler', + properties: { filePath: 'src/app.ts' }, + }; + const executeWithReusedStatement = vi.fn(async () => []); + vi.doMock('../../src/core/lbug/lbug-adapter.js', () => ({ + initLbug: vi.fn(async () => undefined), + loadGraphToLbug: vi.fn(async () => undefined), + getLbugStats: vi.fn(async () => ({ nodes: 2, edges: 0, communities: 0, processes: 0 })), + executeQuery: vi.fn(async () => []), + executeWithReusedStatement, + closeLbug: vi.fn(async () => undefined), + wipeLbugDbFiles: vi.fn(async () => undefined), + tryFlushWAL: vi.fn(async () => true), + loadCachedEmbeddings: vi.fn(async () => { + throw new Error('spill write failed'); + }), + deleteNodesForFile: vi.fn(async () => undefined), + deleteNodesForFiles: vi.fn(async () => undefined), + deleteAllCommunitiesAndProcesses: vi.fn(async () => undefined), + queryImporters: vi.fn(async () => []), + queryImportersBatch: vi.fn(async () => []), + loadFTSExtension: vi.fn(async () => false), + })); + vi.doMock('../../src/core/search/fts-indexes.js', () => ({ + initialiseSearchFTSStemmer: vi.fn(() => 'porter'), + createSearchFTSIndexes: vi.fn(async () => []), + verifySearchFTSIndexes: vi.fn(async () => []), + })); + vi.doMock('../../src/core/ingestion/pipeline.js', () => ({ + runPipelineFromRepo: vi.fn(async (repoPath: string) => ({ + repoPath, + totalFileCount: 1, + graph: { + forEachNode: (fn: (node: typeof stubNode) => void) => fn(stubNode), + getNode: (id: string) => (id === RESTORED_NODE_ID ? stubNode : undefined), + }, + })), + })); + vi.doMock('../../src/storage/repo-manager.js', async (importActual) => ({ + ...(await importActual()), + registerRepo: vi.fn(async () => 'cache-load-reject-repo'), + ensureGitNexusIgnored: vi.fn(async () => undefined), + })); + vi.doMock('../../src/core/embeddings/embedding-pipeline.js', async (importActual) => ({ + ...(await importActual()), + batchInsertEmbeddings: vi.fn(async () => { + throw new Error('restore must not run after a cache-load failure'); + }), + })); + + const tmpRepo = await createTempDir('gitnexus-run-analyze-cache-reject-'); + try { + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await fs.mkdir(storagePath, { recursive: true }); + await saveMeta(storagePath, { + repoPath: tmpRepo.dbPath, + lastCommit: '', + indexedAt: new Date().toISOString(), + stats: { embeddings: 1 }, + }); + + const logs: string[] = []; + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + await runFullAnalysis( + tmpRepo.dbPath, + { force: true, embeddingsNodeLimit: 1 }, + { onProgress: () => {}, onLog: (m) => logs.push(m) }, + ); + + expect(logs.some((m) => m.includes('Warning: could not load cached embeddings'))).toBe(true); + expect(logs.some((m) => m.includes('spill write failed'))).toBe(true); + expect( + executeWithReusedStatement.mock.calls.some((call) => + String(call[0]).includes('CREATE (e:CodeEmbedding'), + ), + ).toBe(false); + } finally { + await tmpRepo.cleanup(); + } + }); + + it('omits failed restore rows from the Phase 4 skip-set so they can be re-embedded', async () => { + const RESTORED_NODE_ID = 'Function:src/app.ts:handler:1'; + const CACHED_HASH = 'cached-stable-hash'; + const stubNode = { + id: RESTORED_NODE_ID, + label: 'Function', + name: 'handler', + properties: { filePath: 'src/app.ts' }, + }; + let existingEmbeddings: Map | undefined; + vi.doMock('../../src/core/lbug/lbug-adapter.js', () => ({ + initLbug: vi.fn(async () => undefined), + loadGraphToLbug: vi.fn(async () => undefined), + getLbugStats: vi.fn(async () => ({ nodes: 2, edges: 0, communities: 0, processes: 0 })), + executeQuery: vi.fn(async () => []), + executeWithReusedStatement: vi.fn(async () => []), + closeLbug: vi.fn(async () => undefined), + wipeLbugDbFiles: vi.fn(async () => undefined), + tryFlushWAL: vi.fn(async () => true), + loadCachedEmbeddings: vi.fn(async () => ({ + embeddingNodeIds: new Set([RESTORED_NODE_ID]), + embeddings: [ + { + nodeId: RESTORED_NODE_ID, + chunkIndex: 0, + startLine: 0, + endLine: 3, + embedding: new Array(EMBEDDING_DIMS).fill(0), + contentHash: CACHED_HASH, + }, + ], + })), + deleteNodesForFile: vi.fn(async () => undefined), + deleteNodesForFiles: vi.fn(async () => undefined), + deleteAllCommunitiesAndProcesses: vi.fn(async () => undefined), + queryImporters: vi.fn(async () => []), + queryImportersBatch: vi.fn(async () => []), + loadFTSExtension: vi.fn(async () => false), + })); + vi.doMock('../../src/core/search/fts-indexes.js', () => ({ + initialiseSearchFTSStemmer: vi.fn(() => 'porter'), + createSearchFTSIndexes: vi.fn(async () => []), + verifySearchFTSIndexes: vi.fn(async () => []), + })); + vi.doMock('../../src/core/ingestion/pipeline.js', () => ({ + runPipelineFromRepo: vi.fn(async (repoPath: string) => ({ + repoPath, + totalFileCount: 1, + graph: { + forEachNode: (fn: (node: typeof stubNode) => void) => fn(stubNode), + getNode: (id: string) => (id === RESTORED_NODE_ID ? stubNode : undefined), + }, + })), + })); + vi.doMock('../../src/storage/repo-manager.js', async (importActual) => ({ + ...(await importActual()), + registerRepo: vi.fn(async () => 'ktd7-skip-set-repo'), + ensureGitNexusIgnored: vi.fn(async () => undefined), + })); + const runEmbeddingPipeline = vi.fn( + async ( + _executeQuery: unknown, + _executeWithReusedStatement: unknown, + _onProgress: unknown, + _config: unknown, + _cachedNodeIds: unknown, + embeddings: Map | undefined, + ) => { + existingEmbeddings = embeddings; + return { + nodesProcessed: 0, + chunksProcessed: 0, + vectorIndexReady: false, + semanticMode: 'exact-scan' as const, + failedNodeIds: [], + }; + }, + ); + vi.doMock('../../src/core/embeddings/embedding-pipeline.js', () => ({ + runEmbeddingPipeline, + buildVectorIndex: vi.fn(async () => false), + batchInsertEmbeddings: vi.fn(async () => { + throw new Error('nth batch insert failed'); + }), + })); + + const tmpRepo = await createTempDir('gitnexus-run-analyze-ktd7-'); + try { + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await fs.mkdir(storagePath, { recursive: true }); + await saveMeta(storagePath, { + repoPath: tmpRepo.dbPath, + lastCommit: '', + indexedAt: new Date().toISOString(), + stats: { embeddings: 1 }, + }); + + const logs: string[] = []; + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + await runFullAnalysis( + tmpRepo.dbPath, + { force: true }, + { onProgress: () => {}, onLog: (m) => logs.push(m) }, + ); + + expect(runEmbeddingPipeline).toHaveBeenCalled(); + expect(existingEmbeddings?.get(RESTORED_NODE_ID)).toBe(STALE_HASH_SENTINEL); + expect(logs.some((m) => m.includes('Warning: could not restore'))).toBe(true); + } finally { + await tmpRepo.cleanup(); + } + }); + + it('marks a node stale when only some of its restore batches succeed', async () => { + const RESTORED_NODE_ID = 'Function:src/app.ts:handler:1'; + const CACHED_HASH = 'cached-stable-hash'; + const stubNode = { + id: RESTORED_NODE_ID, + label: 'Function', + name: 'handler', + properties: { filePath: 'src/app.ts' }, + }; + const embeddings = Array.from({ length: 201 }, (_, chunkIndex) => ({ + nodeId: RESTORED_NODE_ID, + chunkIndex, + startLine: chunkIndex, + endLine: chunkIndex + 1, + embedding: new Array(EMBEDDING_DIMS).fill(0), + contentHash: CACHED_HASH, + })); + let existingEmbeddings: Map | undefined; + vi.doMock('../../src/core/lbug/lbug-adapter.js', () => ({ + initLbug: vi.fn(async () => undefined), + loadGraphToLbug: vi.fn(async () => undefined), + getLbugStats: vi.fn(async () => ({ nodes: 2, edges: 0, communities: 0, processes: 0 })), + executeQuery: vi.fn(async () => []), + executeWithReusedStatement: vi.fn(async () => []), + closeLbug: vi.fn(async () => undefined), + wipeLbugDbFiles: vi.fn(async () => undefined), + tryFlushWAL: vi.fn(async () => true), + loadCachedEmbeddings: vi.fn(async () => ({ + embeddingNodeIds: new Set([RESTORED_NODE_ID]), + embeddings, + })), + deleteNodesForFile: vi.fn(async () => undefined), + deleteNodesForFiles: vi.fn(async () => undefined), + deleteAllCommunitiesAndProcesses: vi.fn(async () => undefined), + queryImporters: vi.fn(async () => []), + queryImportersBatch: vi.fn(async () => []), + loadFTSExtension: vi.fn(async () => false), + })); + vi.doMock('../../src/core/search/fts-indexes.js', () => ({ + initialiseSearchFTSStemmer: vi.fn(() => 'porter'), + createSearchFTSIndexes: vi.fn(async () => []), + verifySearchFTSIndexes: vi.fn(async () => []), + })); + vi.doMock('../../src/core/ingestion/pipeline.js', () => ({ + runPipelineFromRepo: vi.fn(async (repoPath: string) => ({ + repoPath, + totalFileCount: 1, + graph: { + forEachNode: (fn: (node: typeof stubNode) => void) => fn(stubNode), + getNode: (id: string) => (id === RESTORED_NODE_ID ? stubNode : undefined), + }, + })), + })); + vi.doMock('../../src/storage/repo-manager.js', async (importActual) => ({ + ...(await importActual()), + registerRepo: vi.fn(async () => 'partial-restore-skip-set-repo'), + ensureGitNexusIgnored: vi.fn(async () => undefined), + })); + const runEmbeddingPipeline = vi.fn( + async ( + _executeQuery: unknown, + _executeWithReusedStatement: unknown, + _onProgress: unknown, + _config: unknown, + _cachedNodeIds: unknown, + embeddings: Map | undefined, + ) => { + existingEmbeddings = embeddings; + return { + nodesProcessed: 0, + chunksProcessed: 0, + vectorIndexReady: false, + semanticMode: 'exact-scan' as const, + failedNodeIds: [], + }; + }, + ); + let inserts = 0; + vi.doMock('../../src/core/embeddings/embedding-pipeline.js', () => ({ + runEmbeddingPipeline, + buildVectorIndex: vi.fn(async () => false), + batchInsertEmbeddings: vi.fn(async () => { + inserts += 1; + if (inserts > 1) throw new Error('second batch insert failed'); + }), + })); + + const tmpRepo = await createTempDir('gitnexus-run-analyze-partial-restore-'); + try { + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await fs.mkdir(storagePath, { recursive: true }); + await saveMeta(storagePath, { + repoPath: tmpRepo.dbPath, + lastCommit: '', + indexedAt: new Date().toISOString(), + stats: { embeddings: 201 }, + }); + + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + await runFullAnalysis(tmpRepo.dbPath, { force: true }, { onProgress: () => {} }); + + expect(inserts).toBe(2); + expect(existingEmbeddings?.get(RESTORED_NODE_ID)).toBe(STALE_HASH_SENTINEL); + } finally { + await tmpRepo.cleanup(); + } + }); }); /** diff --git a/gitnexus/test/unit/run-analyze.test.ts b/gitnexus/test/unit/run-analyze.test.ts index 3dbf0e3f5..fe1dc8660 100644 --- a/gitnexus/test/unit/run-analyze.test.ts +++ b/gitnexus/test/unit/run-analyze.test.ts @@ -1,4 +1,4 @@ -import { execSync } from 'child_process'; +import { execFileSync, execSync } from 'child_process'; import fs from 'fs/promises'; import path from 'path'; import { pathToFileURL } from 'url'; @@ -21,6 +21,7 @@ import { import { SCHEMA_FINGERPRINT } from '../../src/core/lbug/schema.js'; import { taintModelVersion } from '../../src/core/ingestion/taint/typescript-model.js'; import { createTempDir } from '../helpers/test-db.js'; +import { isDetectRejectWarning } from '../helpers/detect-reject-warning.js'; import { readEmbeddingNodeIds } from '../helpers/embedding-seed.js'; import { getIndexIncompleteReasons } from '../../src/core/index-freshness.js'; import { CLASS_FRAMEWORK_ANNOTATIONS_FEATURE } from '../../src/core/analysis-features.js'; @@ -40,6 +41,14 @@ describe('run-analyze module', () => { expect(typeof mod.runFullAnalysis).toBe('function'); }); + it('does not import cli/analyze-config', async () => { + const source = await fs.readFile( + path.resolve(__dirname, '../../src/core/run-analyze.ts'), + 'utf8', + ); + expect(source).not.toMatch(/cli\/analyze-config/); + }); + it('exports PHASE_LABELS', async () => { const mod = await import('../../src/core/run-analyze.js'); expect(mod.PHASE_LABELS).toBeDefined(); @@ -1015,18 +1024,26 @@ describe('run-analyze module', () => { // Detached HEAD → branchLabel is null → the restamp block must not // fire: the existing stamp survives, mirroring the end-of-run write. + // Null detect (detached / getCurrentBranch null / non-git) must stay + // silent — it is not a rejected checkout name. const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); - const result = await runFullAnalysis(tmpRepo.dbPath, {}, { onProgress: () => {} }); + const logs: string[] = []; + const result = await runFullAnalysis( + tmpRepo.dbPath, + {}, + { onProgress: () => {}, onLog: (message) => logs.push(message) }, + ); expect(result.alreadyUpToDate).toBe(true); const flatMeta = await loadMeta(flat.storagePath); expect(flatMeta?.branch).toBe('main'); + expect(logs.filter(isDetectRejectWarning)).toEqual([]); } finally { if (savedHome === undefined) delete process.env.GITNEXUS_HOME; else process.env.GITNEXUS_HOME = savedHome; await tmpHome.cleanup(); await tmpRepo.cleanup(); } - }); + }, 180_000); it('reports isPrimaryBranch false for an up-to-date explicit --branch run (#2106 R2)', async () => { const tmpRepo = await createTempDir('gitnexus-run-analyze-nonprimary-'); @@ -1102,6 +1119,230 @@ describe('run-analyze module', () => { await tmpRepo.cleanup(); } }); + + it('warns once when the checkout name is not a usable index label', async () => { + const tmpRepo = await createTempDir('gitnexus-run-analyze-detect-reject-'); + try { + execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); + execSync('git -c user.name=test -c user.email=test@test commit --allow-empty -m init', { + cwd: tmpRepo.dbPath, + stdio: 'pipe', + }); + execFileSync('git', ['branch', '-M', 'feat`x'], { cwd: tmpRepo.dbPath, stdio: 'pipe' }); + const currentCommit = execSync('git rev-parse HEAD', { + cwd: tmpRepo.dbPath, + encoding: 'utf-8', + }).trim(); + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await saveMeta(storagePath, { + repoPath: tmpRepo.dbPath, + lastCommit: currentCommit, + indexedAt: new Date().toISOString(), + schemaFingerprint: SCHEMA_FINGERPRINT, + analysisFeatures: CURRENT_ANALYSIS_FEATURES, + runnerIdentity: currentRunnerIdentity(), + }); + + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + const logs: string[] = []; + const result = await runFullAnalysis( + tmpRepo.dbPath, + {}, + { onProgress: () => {}, onLog: (message) => logs.push(message) }, + ); + + expect(result.alreadyUpToDate).toBe(true); + expect((await loadMeta(storagePath))?.branch).toBeUndefined(); + await expect(fs.access(path.join(storagePath, 'branches'))).rejects.toThrow(); + const warnings = logs.filter(isDetectRejectWarning); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('feat`x'); + expect(warnings[0]).toMatch(/^Warning:.*continuing\.$/); + expect(warnings[0]).not.toMatch(/stamp|cleared|unlabeled/i); + } finally { + await tmpRepo.cleanup(); + } + }, 180_000); + + it('applies an explicit --branch on a rejected checkout and still warns', async () => { + const tmpRepo = await createTempDir('gitnexus-run-analyze-detect-reject-pin-'); + try { + execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); + execSync('git -c user.name=test -c user.email=test@test commit --allow-empty -m init', { + cwd: tmpRepo.dbPath, + stdio: 'pipe', + }); + execFileSync('git', ['branch', '-M', 'feat`x'], { cwd: tmpRepo.dbPath, stdio: 'pipe' }); + const currentCommit = execSync('git rev-parse HEAD', { + cwd: tmpRepo.dbPath, + encoding: 'utf-8', + }).trim(); + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await saveMeta(storagePath, { + repoPath: tmpRepo.dbPath, + lastCommit: currentCommit, + indexedAt: new Date().toISOString(), + schemaFingerprint: SCHEMA_FINGERPRINT, + analysisFeatures: CURRENT_ANALYSIS_FEATURES, + runnerIdentity: currentRunnerIdentity(), + }); + + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + const logs: string[] = []; + const result = await runFullAnalysis( + tmpRepo.dbPath, + { branch: 'main' }, + { onProgress: () => {}, onLog: (message) => logs.push(message) }, + ); + + expect(result.alreadyUpToDate).toBe(true); + expect((await loadMeta(storagePath))?.branch).toBe('main'); + const warnings = logs.filter(isDetectRejectWarning); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('feat`x'); + } finally { + await tmpRepo.cleanup(); + } + }, 180_000); + + it('keeps the detect-reject warning on one line for U+2028 checkout names', async () => { + const tmpRepo = await createTempDir('gitnexus-run-analyze-detect-reject-ls-'); + try { + execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); + execSync('git -c user.name=test -c user.email=test@test commit --allow-empty -m init', { + cwd: tmpRepo.dbPath, + stdio: 'pipe', + }); + execFileSync('git', ['branch', '-M', 'foo\u2028bar'], { + cwd: tmpRepo.dbPath, + stdio: 'pipe', + }); + const currentCommit = execSync('git rev-parse HEAD', { + cwd: tmpRepo.dbPath, + encoding: 'utf-8', + }).trim(); + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await saveMeta(storagePath, { + repoPath: tmpRepo.dbPath, + lastCommit: currentCommit, + indexedAt: new Date().toISOString(), + schemaFingerprint: SCHEMA_FINGERPRINT, + analysisFeatures: CURRENT_ANALYSIS_FEATURES, + runnerIdentity: currentRunnerIdentity(), + }); + + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + const logs: string[] = []; + const result = await runFullAnalysis( + tmpRepo.dbPath, + {}, + { onProgress: () => {}, onLog: (message) => logs.push(message) }, + ); + + expect(result.alreadyUpToDate).toBe(true); + const warnings = logs.filter(isDetectRejectWarning); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('\\u2028'); + expect(warnings[0]).not.toContain('\u2028'); + expect(warnings[0].split(/\n|\r|\u2028|\u2029/)).toHaveLength(1); + expect(warnings[0]).toMatch(/^Warning:.*continuing\.$/); + } finally { + await tmpRepo.cleanup(); + } + }, 180_000); + + it('keeps the detect-reject warning on one line for NEL+backtick checkout names', async () => { + const tmpRepo = await createTempDir('gitnexus-run-analyze-detect-reject-nel-'); + try { + execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); + execSync('git -c user.name=test -c user.email=test@test commit --allow-empty -m init', { + cwd: tmpRepo.dbPath, + stdio: 'pipe', + }); + execFileSync('git', ['branch', '-M', 'feat\u0085`'], { + cwd: tmpRepo.dbPath, + stdio: 'pipe', + }); + const currentCommit = execSync('git rev-parse HEAD', { + cwd: tmpRepo.dbPath, + encoding: 'utf-8', + }).trim(); + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await saveMeta(storagePath, { + repoPath: tmpRepo.dbPath, + lastCommit: currentCommit, + indexedAt: new Date().toISOString(), + schemaFingerprint: SCHEMA_FINGERPRINT, + analysisFeatures: CURRENT_ANALYSIS_FEATURES, + runnerIdentity: currentRunnerIdentity(), + }); + + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + const logs: string[] = []; + const result = await runFullAnalysis( + tmpRepo.dbPath, + {}, + { onProgress: () => {}, onLog: (message) => logs.push(message) }, + ); + + expect(result.alreadyUpToDate).toBe(true); + const warnings = logs.filter(isDetectRejectWarning); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('\\u0085'); + expect(warnings[0]).not.toContain('\u0085'); + expect(warnings[0]).toContain('`'); + expect(warnings[0].split(/\n|\r|\u2028|\u2029/)).toHaveLength(1); + expect(warnings[0]).toMatch(/^Warning:.*continuing\.$/); + } finally { + await tmpRepo.cleanup(); + } + }, 180_000); + + it('keeps the detect-reject warning on one line for NBSP checkout names', async () => { + const tmpRepo = await createTempDir('gitnexus-run-analyze-detect-reject-nbsp-'); + try { + execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); + execSync('git -c user.name=test -c user.email=test@test commit --allow-empty -m init', { + cwd: tmpRepo.dbPath, + stdio: 'pipe', + }); + execFileSync('git', ['branch', '-M', 'foo\u00a0bar'], { + cwd: tmpRepo.dbPath, + stdio: 'pipe', + }); + const currentCommit = execSync('git rev-parse HEAD', { + cwd: tmpRepo.dbPath, + encoding: 'utf-8', + }).trim(); + const { storagePath } = getStoragePaths(tmpRepo.dbPath); + await saveMeta(storagePath, { + repoPath: tmpRepo.dbPath, + lastCommit: currentCommit, + indexedAt: new Date().toISOString(), + schemaFingerprint: SCHEMA_FINGERPRINT, + analysisFeatures: CURRENT_ANALYSIS_FEATURES, + runnerIdentity: currentRunnerIdentity(), + }); + + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + const logs: string[] = []; + const result = await runFullAnalysis( + tmpRepo.dbPath, + {}, + { onProgress: () => {}, onLog: (message) => logs.push(message) }, + ); + + expect(result.alreadyUpToDate).toBe(true); + const warnings = logs.filter(isDetectRejectWarning); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('\\u00a0'); + expect(warnings[0]).not.toContain('\u00a0'); + expect(warnings[0].split(/\n|\r|\u2028|\u2029/)).toHaveLength(1); + expect(warnings[0]).toMatch(/^Warning:.*continuing\.$/); + } finally { + await tmpRepo.cleanup(); + } + }, 180_000); }); describe('collectBranchCacheKeys (#2106 R6)', () => { diff --git a/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts b/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts index c1932e02c..3e56563f1 100644 --- a/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts +++ b/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts @@ -336,7 +336,7 @@ const CASES: ReadonlyMap = new Map([ resolutionConfig: undefined, external: 'Foundation', decoy: 'Sources/Foundation/Thing.swift', - reachesDecoy: 'Models', + reachesDecoy: 'Sources', }, ], [ @@ -417,7 +417,6 @@ const CASES: ReadonlyMap = new Map([ */ const KNOWN_GAPS: ReadonlyMap = new Map([ [SupportedLanguages.Dart, '`package:http/http.dart` -> `lib/http.dart`'], - [SupportedLanguages.Swift, '`Foundation` -> `Sources/Foundation/Thing.swift`'], [SupportedLanguages.C, '`stdio.h` -> `src/stdio.h`'], [SupportedLanguages.CPlusPlus, '`cstdio.h` -> `src/cstdio.h`'], ]); diff --git a/gitnexus/test/unit/scope-resolution/pick-implicit-this-overload.test.ts b/gitnexus/test/unit/scope-resolution/pick-implicit-this-overload.test.ts index 26f526382..3302485f5 100644 --- a/gitnexus/test/unit/scope-resolution/pick-implicit-this-overload.test.ts +++ b/gitnexus/test/unit/scope-resolution/pick-implicit-this-overload.test.ts @@ -47,25 +47,38 @@ const mkClassScope = (): Scope => ownedDefs: [], }) as unknown as Scope; -const mkScopes = (scope: Scope): ScopeResolutionIndexes => +const mkScopes = ( + scope: Scope, + mroByOwner: ReadonlyMap = new Map(), +): ScopeResolutionIndexes => ({ scopeTree: { getScope: (id: ScopeId) => (id === scope.id ? scope : undefined), }, + methodDispatch: { + mroFor: (classDefId: string) => mroByOwner.get(classDefId) ?? [], + }, }) as unknown as ScopeResolutionIndexes; -const mkWorkspaceIndex = (mapping: ReadonlyMap): WorkspaceResolutionIndex => +const mkWorkspaceIndex = ( + mapping: ReadonlyMap, + classScopeByDefId: ReadonlyMap = new Map(), +): WorkspaceResolutionIndex => ({ classScopeIdToDefId: mapping, + classScopeByDefId, }) as unknown as WorkspaceResolutionIndex; const mkModel = ( overloadsByName: ReadonlyMap, + overloadsByOwnerAndName: ReadonlyMap = new Map(), ): SemanticModel => ({ methods: { - lookupAllByOwner: (_classDefId: string, name: string) => - overloadsByName.get(name) ?? ([] as readonly SymbolDefinition[]), + lookupAllByOwner: (classDefId: string, name: string) => + overloadsByOwnerAndName.get(`${classDefId}::${name}`) ?? + overloadsByName.get(name) ?? + ([] as readonly SymbolDefinition[]), }, }) as unknown as SemanticModel; @@ -154,3 +167,238 @@ describe('pickImplicitThisOverload — uniqueness guard (Codex #1497 finding 2)' expect(result).toBeUndefined(); }); }); + +describe('pickImplicitThisOverload — inherited implicit-this', () => { + const PROTO_A = 'def:P.swift:A'; + const PROTO_B = 'def:P.swift:B'; + const site0 = { + inScope: CLASS_SCOPE_ID, + name: 'foo', + arity: 0, + argumentTypes: undefined, + }; + + it('arity-narrows a singleton on the enclosing type instead of returning it blindly', () => { + const oneArg = mkMethod({ + nodeId: 'm:own-1', + parameterCount: 1, + requiredParameterCount: 1, + }); + const scopes = mkScopes(mkClassScope()); + const workspace = mkWorkspaceIndex(new Map([[CLASS_SCOPE_ID, CLASS_DEF_ID]])); + const model = mkModel(new Map([['foo', [oneArg]]])); + + expect(pickImplicitThisOverload(site0, scopes, workspace, model)?.nodeId).toBeUndefined(); + }); + + it('unions inherited owners and arity-narrows instead of taking the first MRO name hit', () => { + const aFoo = mkMethod({ + nodeId: 'm:A.foo', + ownerId: PROTO_A, + parameterCount: 1, + requiredParameterCount: 1, + }); + const bFoo = mkMethod({ + nodeId: 'm:B.foo', + ownerId: PROTO_B, + parameterCount: 0, + requiredParameterCount: 0, + }); + const scopes = mkScopes(mkClassScope(), new Map([[CLASS_DEF_ID, [PROTO_A, PROTO_B]]])); + const workspace = mkWorkspaceIndex(new Map([[CLASS_SCOPE_ID, CLASS_DEF_ID]])); + const model = mkModel( + new Map(), + new Map([ + [`${CLASS_DEF_ID}::foo`, []], + [`${PROTO_A}::foo`, [aFoo]], + [`${PROTO_B}::foo`, [bFoo]], + ]), + ); + + const result = pickImplicitThisOverload(site0, scopes, workspace, model, { + implicitThisWalksMro: true, + }); + expect(result?.nodeId).toBe('m:B.foo'); + }); + + it('picks the nearest inherited override when two MRO ancestors share a signature', () => { + const MID = 'def:Mid.swift:Mid'; + const GRAND = 'def:Grand.swift:Grand'; + const midFoo = mkMethod({ + nodeId: 'm:Mid.foo', + ownerId: MID, + parameterCount: 0, + requiredParameterCount: 0, + }); + const grandFoo = mkMethod({ + nodeId: 'm:Grand.foo', + ownerId: GRAND, + parameterCount: 0, + requiredParameterCount: 0, + }); + const scopes = mkScopes(mkClassScope(), new Map([[CLASS_DEF_ID, [MID, GRAND]]])); + const workspace = mkWorkspaceIndex(new Map([[CLASS_SCOPE_ID, CLASS_DEF_ID]])); + const model = mkModel( + new Map(), + new Map([ + [`${CLASS_DEF_ID}::foo`, []], + [`${MID}::foo`, [midFoo]], + [`${GRAND}::foo`, [grandFoo]], + ]), + ); + + const result = pickImplicitThisOverload(site0, scopes, workspace, model, { + implicitThisWalksMro: true, + }); + expect(result?.nodeId).toBe('m:Mid.foo'); + }); + + it('prefers a protocol-extension default over the protocol requirement of the same arity', () => { + const requirement = mkMethod({ + nodeId: 'm:P.req', + ownerId: PROTO_A, + parameterCount: 0, + requiredParameterCount: 0, + }); + const body = mkMethod({ + nodeId: 'm:P.ext', + ownerId: PROTO_A, + parameterCount: 0, + requiredParameterCount: 0, + }); + const protocolScope = { + id: 'scope:P.swift#1:1-20:1:Class' as ScopeId, + parent: null, + kind: 'Class', + range: { startLine: 1, startCol: 1, endLine: 20, endCol: 1 }, + filePath: 'P.swift', + bindings: new Map(), + typeBindings: new Map(), + ownedDefs: [ + { + nodeId: PROTO_A, + filePath: 'P.swift', + type: 'Protocol', + qualifiedName: 'A', + } as SymbolDefinition, + requirement, + ], + } as unknown as Scope; + const scopes = mkScopes(mkClassScope(), new Map([[CLASS_DEF_ID, [PROTO_A]]])); + const workspace = mkWorkspaceIndex( + new Map([[CLASS_SCOPE_ID, CLASS_DEF_ID]]), + new Map([[PROTO_A, protocolScope]]), + ); + const model = mkModel( + new Map(), + new Map([ + [`${CLASS_DEF_ID}::foo`, []], + [`${PROTO_A}::foo`, [requirement, body]], + ]), + ); + + const result = pickImplicitThisOverload(site0, scopes, workspace, model, { + implicitThisWalksMro: true, + }); + expect(result?.nodeId).toBe('m:P.ext'); + }); + + it('does not prefer a protocol-extension witness over an inherited class member', () => { + const BASE = 'def:Base.swift:Base'; + const baseType = { + nodeId: BASE, + filePath: 'Base.swift', + type: 'Class', + qualifiedName: 'Base', + } as SymbolDefinition; + const baseFoo = mkMethod({ + nodeId: 'm:Base.foo', + ownerId: BASE, + parameterCount: 0, + requiredParameterCount: 0, + }); + const extFoo = mkMethod({ + nodeId: 'm:P.ext', + ownerId: PROTO_A, + parameterCount: 0, + requiredParameterCount: 0, + }); + const baseScope = { + id: 'scope:Base.swift#1:1-20:1:Class' as ScopeId, + parent: null, + kind: 'Class', + range: { startLine: 1, startCol: 1, endLine: 20, endCol: 1 }, + filePath: 'Base.swift', + bindings: new Map(), + typeBindings: new Map(), + ownedDefs: [baseType, baseFoo], + } as unknown as Scope; + const protocolScope = { + id: 'scope:P.swift#1:1-20:1:Class' as ScopeId, + parent: null, + kind: 'Class', + range: { startLine: 1, startCol: 1, endLine: 20, endCol: 1 }, + filePath: 'P.swift', + bindings: new Map(), + typeBindings: new Map(), + ownedDefs: [ + { + nodeId: PROTO_A, + filePath: 'P.swift', + type: 'Protocol', + qualifiedName: 'A', + } as SymbolDefinition, + ], + } as unknown as Scope; + const scopes = mkScopes(mkClassScope(), new Map([[CLASS_DEF_ID, [BASE, PROTO_A]]])); + const workspace = mkWorkspaceIndex( + new Map([[CLASS_SCOPE_ID, CLASS_DEF_ID]]), + new Map([ + [BASE, baseScope], + [PROTO_A, protocolScope], + ]), + ); + const model = mkModel( + new Map(), + new Map([ + [`${CLASS_DEF_ID}::foo`, []], + [`${BASE}::foo`, [baseFoo]], + [`${PROTO_A}::foo`, [extFoo]], + ]), + ); + + const result = pickImplicitThisOverload(site0, scopes, workspace, model, { + implicitThisWalksMro: true, + }); + expect(result?.nodeId).toBe('m:Base.foo'); + }); + + it('falls through to inherited when the enclosing type only has an arity-incompatible decoy', () => { + const decoy = mkMethod({ + nodeId: 'm:own-decoy', + ownerId: CLASS_DEF_ID, + parameterCount: 1, + requiredParameterCount: 1, + }); + const inherited = mkMethod({ + nodeId: 'm:ext', + ownerId: PROTO_A, + parameterCount: 0, + requiredParameterCount: 0, + }); + const scopes = mkScopes(mkClassScope(), new Map([[CLASS_DEF_ID, [PROTO_A]]])); + const workspace = mkWorkspaceIndex(new Map([[CLASS_SCOPE_ID, CLASS_DEF_ID]])); + const model = mkModel( + new Map(), + new Map([ + [`${CLASS_DEF_ID}::foo`, [decoy]], + [`${PROTO_A}::foo`, [inherited]], + ]), + ); + + const result = pickImplicitThisOverload(site0, scopes, workspace, model, { + implicitThisWalksMro: true, + }); + expect(result?.nodeId).toBe('m:ext'); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts b/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts index 33c8cab51..d2061cfe0 100644 --- a/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts +++ b/gitnexus/test/unit/scope-resolution/scope-extractor.test.ts @@ -261,6 +261,22 @@ describe('Pass 2: declarations + local bindings', () => { expect(result.localDefs[0]!.type).toBe('Function'); }); + it('backfills return types across duplicate declaration captures', () => { + const plain = declMatch('function', 'makeStore', 5, 0, 10, 0); + const annotated = declMatch('function', 'makeStore', 5, 0, 10, 0, { + '@declaration.return-type': cap('@declaration.return-type', 5, 0, 10, 0, 'Store'), + }); + const result = extract( + [scopeMatch('module', 1, 0, 100, 0), scopeMatch('function', 5, 0, 10, 0), plain, annotated], + 'Support.swift', + mockProvider(), + ); + + expect(result.localDefs).toHaveLength(2); + expect(new Set(result.localDefs.map((def) => def.nodeId)).size).toBe(1); + expect(result.localDefs.map((def) => def.returnType)).toEqual(['Store', 'Store']); + }); + it('preserves a synthetic declaration marker on the definition', () => { const result = extract( [ @@ -869,6 +885,55 @@ describe('Pass 6: callable-value-flow facts', () => { }); }); +describe('Pass 7: call-result assignment identity', () => { + it('keeps same-name calls isolated by exact call-expression position', () => { + const result = extract( + [ + scopeMatch('module', 1, 0, 100, 0), + scopeMatch('function', 10, 0, 40, 0), + { + '@call-result-assignment.call': cap( + '@call-result-assignment.call', + 20, + 14, + 20, + 25, + 'makeStore()', + ), + '@call-result-assignment.lhs': cap('@call-result-assignment.lhs', 20, 6, 20, 11, 'store'), + }, + { + '@call-result-assignment.call': cap( + '@call-result-assignment.call', + 21, + 14, + 21, + 25, + 'makeStore()', + ), + '@call-result-assignment.lhs': cap('@call-result-assignment.lhs', 21, 6, 21, 11, 'other'), + }, + ], + 'a.swift', + mockProvider(), + ); + + const fnScope = result.scopes.find((scope) => scope.kind === 'Function')!; + expect(result.callResultAssignmentSites).toEqual([ + { + callSite: { startLine: 20, startCol: 14, endLine: 20, endCol: 25 }, + inScope: fnScope.id, + lhs: 'store', + }, + { + callSite: { startLine: 21, startCol: 14, endLine: 21, endCol: 25 }, + inScope: fnScope.id, + lhs: 'other', + }, + ]); + }); +}); + // ─── §End-to-end fixture ────────────────────────────────────────────────── describe('end-to-end fixture (all 5 passes together)', () => { diff --git a/gitnexus/test/unit/scope-resolution/swift-extension-owners.test.ts b/gitnexus/test/unit/scope-resolution/swift-extension-owners.test.ts new file mode 100644 index 000000000..45d5ef408 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/swift-extension-owners.test.ts @@ -0,0 +1,198 @@ +/** + * `populateWorkspaceOwners` for Swift must stamp extension members onto a + * unique owner in the same SPM target, skip nested locals, and fail closed + * when `Bar` and `Foo.Bar` both exist. + */ +import { describe, it, expect } from 'vitest'; +import { extractParsedFile } from '../../../src/core/ingestion/scope-extractor-bridge.js'; +import { swiftScopeResolver } from '../../../src/core/ingestion/languages/swift/scope-resolver.js'; +import { isLanguageAvailable } from '../../../src/core/tree-sitter/parser-loader.js'; +import { SupportedLanguages } from '../../../src/config/supported-languages.js'; + +const swiftAvailable = isLanguageAvailable(SupportedLanguages.Swift); + +function parseSwift(source: string, filePath: string) { + const parsed = extractParsedFile(swiftScopeResolver.languageProvider, source, filePath, () => {}); + if (parsed === undefined) throw new Error('scope extraction failed'); + swiftScopeResolver.populateOwners(parsed); + return parsed; +} + +function stamp( + files: Parameters>[0], + resolutionConfig?: unknown, +) { + swiftScopeResolver.populateWorkspaceOwners?.(files, { + fileContents: new Map(), + resolutionConfig, + }); +} + +describe.skipIf(!swiftAvailable)('populateSwiftExtensionOwners', () => { + it('stamps protocol-extension methods onto the unique protocol', () => { + const parsed = parseSwift( + ` +protocol ScenarioSupport {} +struct Store {} +extension ScenarioSupport { + func makeStore() -> Store { Store() } +} +`, + 'Support.swift', + ); + const protocol = parsed.localDefs.find((d) => d.qualifiedName === 'ScenarioSupport'); + const method = parsed.localDefs.find( + (d) => d.qualifiedName?.split('.').at(-1) === 'makeStore' && d.type === 'Method', + ); + expect(protocol).toBeDefined(); + expect(method?.ownerId).toBeUndefined(); + stamp([parsed]); + expect(method?.ownerId).toBe(protocol?.nodeId); + }); + + it('does not stamp a nested local onto the enclosing type', () => { + const parsed = parseSwift( + ` +class Host { + func helper(_ a: Int) -> Int { a } + func run(_ x: Int) -> Int { + func helper(_ v: Int, _ w: Int) -> Int { v + w } + return helper(x, x) + } +} +`, + 'Host.swift', + ); + stamp([parsed]); + const twoArg = parsed.localDefs.filter( + (d) => d.type === 'Method' && d.qualifiedName?.endsWith('helper') && d.parameterCount === 2, + ); + expect(twoArg).toHaveLength(1); + expect(twoArg[0]?.ownerId).toBeUndefined(); + }); + + it('stamps extension Foo.Bar members onto Foo.Bar when no top-level Bar exists', () => { + const parsed = parseSwift( + ` +enum Foo { + struct Bar {} +} +extension Foo.Bar { + func added() {} +} +`, + 'Nested.swift', + ); + stamp([parsed]); + const added = parsed.localDefs.find((d) => d.qualifiedName?.split('.').at(-1) === 'added'); + const bar = parsed.localDefs.find( + (d) => d.qualifiedName === 'Foo.Bar' || d.qualifiedName === 'Bar', + ); + expect(added?.ownerId).toBeDefined(); + expect(added?.ownerId).toBe(bar?.nodeId); + }); + + it('fails closed when top-level Bar and nested Foo.Bar both exist', () => { + const parsed = parseSwift( + ` +struct Bar {} +enum Foo { + struct Bar {} +} +extension Foo.Bar { + func added() {} +} +`, + 'Collide.swift', + ); + stamp([parsed]); + const added = parsed.localDefs.find((d) => d.qualifiedName?.split('.').at(-1) === 'added'); + expect(added).toBeDefined(); + expect(added?.ownerId).toBeUndefined(); + }); + + it('stamps extension members when the extension also declares a nested type', () => { + const parsed = parseSwift( + ` +struct Foo {} +extension Foo { + struct Helper {} + func added() {} +} +`, + 'NestedInExt.swift', + ); + stamp([parsed]); + const added = parsed.localDefs.find((d) => d.qualifiedName?.split('.').at(-1) === 'added'); + const foo = parsed.localDefs.find( + (d) => d.qualifiedName === 'Foo' && (d.type === 'Struct' || d.type === 'Class'), + ); + expect(added).toBeDefined(); + expect(foo).toBeDefined(); + expect(added?.ownerId).toBe(foo?.nodeId); + }); + + it('does not let one target’s Foo own another target’s extension', () => { + const app = parseSwift( + ` +protocol Foo {} +extension Foo { + func added() {} +} +`, + 'Sources/App/A.swift', + ); + const lib = parseSwift( + ` +protocol Foo {} +`, + 'Sources/Lib/B.swift', + ); + stamp([app, lib], { + targets: new Map([ + ['App', 'Sources/App'], + ['Lib', 'Sources/Lib'], + ]), + }); + const added = app.localDefs.find((d) => d.qualifiedName?.split('.').at(-1) === 'added'); + const appFoo = app.localDefs.find((d) => d.qualifiedName === 'Foo'); + expect(added).toBeDefined(); + expect(appFoo).toBeDefined(); + expect(added?.ownerId).toBe(appFoo?.nodeId); + const libFoo = lib.localDefs.find((d) => d.qualifiedName === 'Foo'); + expect(added?.ownerId).not.toBe(libFoo?.nodeId); + }); + + it('stamps a one-line extension whose synthetic class shares the Class start line', () => { + const parsed = parseSwift( + ` +struct Foo {} +extension Foo { func added() {} } +`, + 'OneLine.swift', + ); + const added = parsed.localDefs.find((d) => d.qualifiedName?.split('.').at(-1) === 'added'); + const foo = parsed.localDefs.find( + (d) => d.qualifiedName === 'Foo' && (d.type === 'Struct' || d.type === 'Class'), + ); + expect(added).toBeDefined(); + expect(foo).toBeDefined(); + expect(added?.ownerId).toBeUndefined(); + stamp([parsed]); + expect(added?.ownerId).toBe(foo?.nodeId); + }); + + it('stamps a cross-file extension onto the real type, not the synthetic class', () => { + const typeFile = parseSwift('struct Foo {}', 'Foo.swift'); + const extFile = parseSwift('extension Foo { func added() {} }', 'Foo+Added.swift'); + const added = extFile.localDefs.find((d) => d.qualifiedName?.split('.').at(-1) === 'added'); + const foo = typeFile.localDefs.find( + (d) => d.qualifiedName === 'Foo' && (d.type === 'Struct' || d.type === 'Class'), + ); + expect(added).toBeDefined(); + expect(foo).toBeDefined(); + expect(added?.ownerId).toBeUndefined(); + stamp([typeFile, extFile]); + expect(added?.ownerId).toBe(foo?.nodeId); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/swift-target-grouping.test.ts b/gitnexus/test/unit/scope-resolution/swift-target-grouping.test.ts new file mode 100644 index 000000000..f8e139968 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/swift-target-grouping.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { groupSwiftFilesBySpmTarget } from '../../../src/core/ingestion/languages/swift/target-grouping.js'; + +describe('groupSwiftFilesBySpmTarget', () => { + it('matches a later segment-aligned target prefix after an earlier partial occurrence', () => { + const targets = new Map([['Core', 'Modules/Core']]); + const items = ['vendor/SubModules/Core/shim/Modules/Core/Thing.swift']; + + const groups = groupSwiftFilesBySpmTarget(items, (item) => item, targets); + + expect(groups.get('Core')).toEqual(items); + expect(groups.get('__default__')).toBeUndefined(); + }); + + it('keeps an earlier segment-aligned match when a later occurrence is partial', () => { + const targets = new Map([['Core', 'Modules/Core']]); + const items = ['Modules/Core/Thing/SubModules/Core/Thing.swift']; + + const groups = groupSwiftFilesBySpmTarget(items, (item) => item, targets); + + expect(groups.get('Core')).toEqual(items); + expect(groups.get('__default__')).toBeUndefined(); + }); + + it('does not treat a partial path segment as a target match', () => { + const targets = new Map([['Core', 'Modules/Core']]); + const item = 'vendor/SubModules/Core/Thing.swift'; + + const groups = groupSwiftFilesBySpmTarget([item], (value) => value, targets); + + expect(groups.get('Core')).toBeUndefined(); + expect(groups.get('__default__')).toEqual([item]); + }); + + it('keeps first-target-wins behavior when target paths overlap', () => { + const targets = new Map([ + ['Outer', 'Sources'], + ['Inner', 'Sources/Feature'], + ]); + const item = 'Sources/Feature/Thing.swift'; + + const groups = groupSwiftFilesBySpmTarget([item], (value) => value, targets); + + expect(groups.get('Outer')).toEqual([item]); + expect(groups.get('Inner')).toBeUndefined(); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/swift/implicit-imports.test.ts b/gitnexus/test/unit/scope-resolution/swift/implicit-imports.test.ts new file mode 100644 index 000000000..50cb49099 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/swift/implicit-imports.test.ts @@ -0,0 +1,74 @@ +/** + * Intra-group implicit IMPORTS. @_exported is client-facing + * (`resolveSwiftImportTarget`); siblings in the exporting target + * must not gain edges to the reexported module. + */ +import { describe, expect, it } from 'vitest'; +import type { ParsedFile, ParsedImport, ScopeId } from 'gitnexus-shared'; +import { createKnowledgeGraph } from '../../../../src/core/graph/graph.js'; +import { generateId } from '../../../../src/lib/utils.js'; +import { emitSwiftImplicitImportEdges } from '../../../../src/core/ingestion/languages/swift/implicit-imports.js'; +import { resolveSwiftImportTarget } from '../../../../src/core/ingestion/languages/swift/import-target.js'; + +const DECLARED = { + origin: 'package.swift' as const, + targets: new Map([ + ['A', 'Sources/A'], + ['B', 'Sources/B'], + ]), +}; + +function stubFile(filePath: string, parsedImports: ParsedImport[] = []): ParsedFile { + return { + filePath, + moduleScope: `module:${filePath}` as ScopeId, + scopes: [], + parsedImports, + localDefs: [], + referenceSites: [], + }; +} + +function reexport(targetRaw: string): ParsedImport { + return { kind: 'reexport', localName: targetRaw, importedName: targetRaw, targetRaw }; +} + +function ns(targetRaw: string): ParsedImport { + return { kind: 'namespace', localName: targetRaw, importedName: targetRaw, targetRaw }; +} + +function importPair(rels: readonly { sourceId: string; targetId: string; type: string }[]) { + return rels + .filter((rel) => rel.type === 'IMPORTS') + .map((rel) => `${rel.sourceId}->${rel.targetId}`) + .sort(); +} + +describe('emitSwiftImplicitImportEdges', () => { + it('does not paint sibling files into a @_exported module', () => { + const a = 'Sources/A/A.swift'; + const other = 'Sources/A/Other.swift'; + const b = 'Sources/B/B.swift'; + const parsed = [stubFile(a, [reexport('B')]), stubFile(other), stubFile(b)]; + const graph = createKnowledgeGraph(); + + emitSwiftImplicitImportEdges(graph, parsed, new Map(), DECLARED); + + const pairs = importPair(graph.relationships); + expect(pairs).toEqual( + [ + `${generateId('File', a)}->${generateId('File', other)}`, + `${generateId('File', other)}->${generateId('File', a)}`, + ].sort(), + ); + expect(pairs.some((pair) => pair.includes(generateId('File', b)))).toBe(false); + + const fromApp = resolveSwiftImportTarget(ns('A'), { + fromFile: 'Sources/App/main.swift', + allFilePaths: new Set([a, other, b, 'Sources/App/main.swift']), + resolutionConfig: DECLARED, + parsedFiles: parsed, + }); + expect(fromApp).toEqual(expect.arrayContaining([a, other, b])); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/swift/import-decomposer.test.ts b/gitnexus/test/unit/scope-resolution/swift/import-decomposer.test.ts new file mode 100644 index 000000000..aeded6eaf --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/swift/import-decomposer.test.ts @@ -0,0 +1,154 @@ +/** + * Swift import capture: import-kind, @_exported, and module path (R5, R6). + */ +import { describe, expect, it } from 'vitest'; +import { SupportedLanguages } from '../../../../src/config/supported-languages.js'; +import { emitSwiftScopeCaptures } from '../../../../src/core/ingestion/languages/swift/index.js'; +import { interpretSwiftImport } from '../../../../src/core/ingestion/languages/swift/interpret.js'; +import { isLanguageAvailable } from '../../../../src/core/tree-sitter/parser-loader.js'; + +function importsOf(src: string) { + return emitSwiftScopeCaptures(src, 'Probe.swift') + .map((match) => interpretSwiftImport(match)) + .filter((imp): imp is NonNullable => imp !== null); +} + +const swiftAvailable = isLanguageAvailable(SupportedLanguages.Swift); + +describe.skipIf(!swiftAvailable)('interpretSwiftImport via emitSwiftScopeCaptures', () => { + it('import Foundation is a namespace, not exported', () => { + expect(importsOf('import Foundation')).toEqual([ + { + kind: 'namespace', + localName: 'Foundation', + importedName: 'Foundation', + targetRaw: 'Foundation', + }, + ]); + }); + + it('import struct Models.User is a named binding of User', () => { + expect(importsOf('import struct Models.User')).toEqual([ + { + kind: 'named', + localName: 'User', + importedName: 'User', + targetRaw: 'Models', + }, + ]); + }); + + it('allows a block comment between import and its kind', () => { + expect(importsOf('import /* selected API */ struct Models.User')).toEqual([ + { + kind: 'named', + localName: 'User', + importedName: 'User', + targetRaw: 'Models', + }, + ]); + }); + + it('allows a line comment between import and its kind', () => { + expect(importsOf('import // selected API\nstruct Models.User')).toEqual([ + { + kind: 'named', + localName: 'User', + importedName: 'User', + targetRaw: 'Models', + }, + ]); + }); + + it('reads the kind after a long block comment that repeats star-slash-slash-star', () => { + const noise = '*//*'.repeat(80); + expect(importsOf(`import /* ${noise} */ struct Models.User`)).toEqual([ + { + kind: 'named', + localName: 'User', + importedName: 'User', + targetRaw: 'Models', + }, + ]); + }); + + it('does not take import kind from an @available message string', () => { + expect( + importsOf('@available(*, deprecated, message: "import struct") import Foo.Bar'), + ).toMatchObject([{ kind: 'namespace', targetRaw: 'Foo' }]); + }); + + it('preserves @testable as the same module', () => { + const [imp] = importsOf('@testable import App'); + expect(imp).toMatchObject({ + kind: 'namespace', + targetRaw: 'App', + }); + }); + + it('@_exported import Models is a reexport of the module handle', () => { + expect(importsOf('@_exported import Models')).toEqual([ + { + kind: 'reexport', + localName: 'Models', + importedName: 'Models', + targetRaw: 'Models', + }, + ]); + }); + + it('@_exported import struct Models.User is a reexport of User', () => { + expect(importsOf('@_exported import struct Models.User')).toEqual([ + { + kind: 'reexport', + localName: 'User', + importedName: 'User', + targetRaw: 'Models', + }, + ]); + }); + + it('public import Models is not a reexport', () => { + expect(importsOf('public import Models')).toEqual([ + { + kind: 'namespace', + localName: 'Models', + importedName: 'Models', + targetRaw: 'Models', + }, + ]); + }); + + it('does not treat _exported inside an @available message as @_exported', () => { + expect(importsOf('@available(*, deprecated, message: "_exported") import Models')).toEqual([ + { + kind: 'namespace', + localName: 'Models', + importedName: 'Models', + targetRaw: 'Models', + }, + ]); + }); + + it('reads the kind after a comment that itself contains import', () => { + expect(importsOf('import /* import */ struct Models.User')).toEqual([ + { + kind: 'named', + localName: 'User', + importedName: 'User', + targetRaw: 'Models', + }, + ]); + }); + + it('reads the kind after nested block comments', () => { + expect(importsOf('import /* outer /* inner */ */ struct Models.User')).toEqual([ + { + kind: 'named', + localName: 'User', + importedName: 'User', + targetRaw: 'Models', + }, + ]); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/swift/import-target.test.ts b/gitnexus/test/unit/scope-resolution/swift/import-target.test.ts new file mode 100644 index 000000000..4bbe2f732 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/swift/import-target.test.ts @@ -0,0 +1,129 @@ +/** + * Registry-primary Swift import resolve (PR 3105 / #2964). + */ +import { describe, expect, it } from 'vitest'; +import type { ParsedImport } from 'gitnexus-shared'; +import { resolveSwiftImportTarget } from '../../../../src/core/ingestion/languages/swift/import-target.js'; + +const AE1_FILES = [ + 'Sources/Foundation/Thing.swift', + 'Sources/Models/User.swift', + 'Sources/App/main.swift', +] as const; + +function ns(targetRaw: string): ParsedImport { + return { kind: 'namespace', localName: targetRaw, importedName: targetRaw, targetRaw }; +} + +function resolve( + targetRaw: string, + files: readonly string[] = AE1_FILES, + fromFile = 'Sources/App/main.swift', + resolutionConfig?: unknown, +): string | readonly string[] | null { + return resolveSwiftImportTarget(ns(targetRaw), { + fromFile, + allFilePaths: new Set(files), + resolutionConfig, + }); +} + +describe('resolveSwiftImportTarget — inferred / no config (R7)', () => { + it('AE1: Foundation is null; Models reaches User.swift', () => { + expect(resolve('Foundation')).toBeNull(); + expect(resolve('Models')).toEqual(['Sources/Models/User.swift']); + }); + + it('AE6: UIKit / CoreData / CoreGraphics are null on inferred decoys', () => { + const files = [ + ...AE1_FILES, + 'Sources/UIKit/Thing.swift', + 'Sources/CoreData/Thing.swift', + 'Sources/CoreGraphics/Thing.swift', + ]; + const inferred = { + origin: 'directories' as const, + targets: new Map([ + ['App', 'Sources/App'], + ['Models', 'Sources/Models'], + ['Foundation', 'Sources/Foundation'], + ['UIKit', 'Sources/UIKit'], + ['CoreData', 'Sources/CoreData'], + ['CoreGraphics', 'Sources/CoreGraphics'], + ]), + }; + expect(resolve('UIKit', files, 'Sources/App/main.swift', inferred)).toBeNull(); + expect(resolve('CoreData', files, 'Sources/App/main.swift', inferred)).toBeNull(); + expect(resolve('CoreGraphics', files, 'Sources/App/main.swift', inferred)).toBeNull(); + expect(resolve('Models', files, 'Sources/App/main.swift', inferred)).toEqual([ + 'Sources/Models/User.swift', + ]); + }); +}); + +describe('resolveSwiftImportTarget — declared Package.swift (R3, R4)', () => { + const declared = { + origin: 'package.swift' as const, + targets: new Map([ + ['Models', 'Sources/Models'], + ['App', 'Sources/App'], + ]), + }; + + it('AE2: Models reaches files; Foundation is null', () => { + expect(resolve('Models', AE1_FILES, 'Sources/App/main.swift', declared)).toEqual([ + 'Sources/Models/User.swift', + ]); + expect(resolve('Foundation', AE1_FILES, 'Sources/App/main.swift', declared)).toBeNull(); + }); + + it('empty declared map makes every name external, including Foundation', () => { + const empty = { origin: 'package.swift' as const, targets: new Map() }; + expect(resolve('Foundation', AE1_FILES, 'Sources/App/main.swift', empty)).toBeNull(); + expect(resolve('Models', AE1_FILES, 'Sources/App/main.swift', empty)).toBeNull(); + }); + + it('empty declaredTargets with inferred grouping folders stays external', () => { + const loadedShape = { + origin: 'package.swift' as const, + targets: new Map([ + ['Foundation', 'Sources/Foundation'], + ['App', 'Sources/App'], + ['Models', 'Sources/Models'], + ]), + declaredTargets: new Map(), + }; + expect(resolve('Foundation', AE1_FILES, 'Sources/App/main.swift', loadedShape)).toBeNull(); + expect(resolve('Models', AE1_FILES, 'Sources/App/main.swift', loadedShape)).toBeNull(); + }); + + it('a declared target literally named Foundation still resolves', () => { + const namedFoundation = { + origin: 'package.swift' as const, + targets: new Map([['Foundation', 'Sources/Foundation']]), + }; + expect(resolve('Foundation', AE1_FILES, 'Sources/App/main.swift', namedFoundation)).toEqual([ + 'Sources/Foundation/Thing.swift', + ]); + }); + + it('a declared path: "." target reaches root-level Swift files', () => { + const files = ['Lib.swift', 'Sources/Other/X.swift']; + expect( + resolve('Lib', files, 'Sources/App/main.swift', { + origin: 'package.swift', + targets: new Map([['Lib', '.']]), + }), + ).toEqual(['Lib.swift', 'Sources/Other/X.swift']); + }); + + it('excludes the importer from its own module file list', () => { + const files = ['Sources/Models/User.swift', 'Sources/Models/Other.swift']; + expect( + resolve('Models', files, 'Sources/Models/User.swift', { + origin: 'package.swift', + targets: new Map([['Models', 'Sources/Models']]), + }), + ).toEqual(['Sources/Models/Other.swift']); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/swift/package-config.test.ts b/gitnexus/test/unit/scope-resolution/swift/package-config.test.ts new file mode 100644 index 000000000..03ef37247 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/swift/package-config.test.ts @@ -0,0 +1,452 @@ +/** + * Package.swift target loading (PR 3105 / #2964). + * + * `loadSwiftPackageConfig` must distinguish a declaration map + * (`origin: 'package.swift'`) from an inferred `Sources/*` folder map + * (`origin: 'directories'`). Grouping uses either; explicit import + * resolve uses only the declared origin. + */ +import { afterAll, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + loadSwiftPackageConfig, + parseSwiftPackageManifest, + swiftDeclaredTargetPrefix, +} from '../../../../src/core/ingestion/language-config.js'; +import { coerceDeclaredSwiftTargets } from '../../../../src/core/ingestion/languages/swift/target-grouping.js'; + +const roots: string[] = []; + +function repo(files: Readonly>): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-swift-pkg-')); + roots.push(root); + for (const [rel, contents] of Object.entries(files)) { + const full = path.join(root, rel); + if (contents === null) { + fs.mkdirSync(full, { recursive: true }); + continue; + } + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, contents); + } + return root; +} + +afterAll(() => { + for (const root of roots) fs.rmSync(root, { recursive: true, force: true }); +}); + +const MODELS_APP = ` +let package = Package( + name: "Demo", + targets: [ + .target(name: "Models"), + .target(name: "App"), + ] +) +`; + +describe('parseSwiftPackageManifest', () => { + it('maps .target(name:) with no path to Sources/', () => { + const parsed = parseSwiftPackageManifest(MODELS_APP); + expect(parsed.complete).toBe(true); + expect(parsed.targets.get('Models')).toBe('Sources/Models'); + expect(parsed.targets.get('App')).toBe('Sources/App'); + }); + + it('honors an explicit path:', () => { + const src = ` + .target(name: "Core", path: "Modules/Core") + `; + const parsed = parseSwiftPackageManifest(src); + expect({ complete: parsed.complete, entries: [...parsed.targets] }).toEqual({ + complete: true, + entries: [['Core', 'Modules/Core']], + }); + }); + + it('maps .testTarget to Tests/', () => { + const parsed = parseSwiftPackageManifest(`.testTarget(name: "AppTests")`); + expect(parsed.complete).toBe(true); + expect(parsed.targets.get('AppTests')).toBe('Tests/AppTests'); + }); + + it('skips binary / plugin / systemLibrary targets', () => { + const parsed = parseSwiftPackageManifest(` + .binaryTarget(name: "Lib", path: "Lib.xcframework") + .plugin(name: "Gen") + .systemLibrary(name: "CFoo") + `); + expect(parsed.complete).toBe(true); + expect(parsed.targets.size).toBe(0); + }); + + it('treats #if as a completeness hazard', () => { + const parsed = parseSwiftPackageManifest(` +#if os(macOS) + .target(name: "MacOnly") +#endif + .target(name: "Models") +`); + expect(parsed.complete).toBe(false); + }); + + it('treats a helper-built targets: list as incomplete', () => { + const parsed = parseSwiftPackageManifest(` +let package = Package(name: "Demo", targets: makeTargets()) +`); + expect(parsed.complete).toBe(false); + }); + + it('treats a computed name: as incomplete', () => { + const parsed = parseSwiftPackageManifest(`.target(name: targetName)`); + expect(parsed.complete).toBe(false); + }); + + it('treats a computed path: as incomplete', () => { + const parsed = parseSwiftPackageManifest(`.target(name: "Core", path: corePath)`); + expect(parsed.complete).toBe(false); + }); + + it('ignores a block-commented factory', () => { + const parsed = parseSwiftPackageManifest(` + /* .target(name: "Ghost") */ + .target(name: "Models") + `); + expect(parsed.complete).toBe(true); + expect(parsed.targets.has('Ghost')).toBe(false); + expect(parsed.targets.get('Models')).toBe('Sources/Models'); + }); + + it('records path: "." as the package root', () => { + const parsed = parseSwiftPackageManifest(`.target(name: "Lib", path: ".")`); + expect({ complete: parsed.complete, entries: [...parsed.targets] }).toEqual({ + complete: true, + entries: [['Lib', '.']], + }); + }); + + it('ignores a // commented factory', () => { + const parsed = parseSwiftPackageManifest(` + // .target(name: "Ghost") + .target(name: "Models") + `); + expect(parsed.complete).toBe(true); + expect(parsed.targets.has('Ghost')).toBe(false); + expect(parsed.targets.get('Models')).toBe('Sources/Models'); + }); + + it('does not treat a dependency .target(name:) as a declared target', () => { + const parsed = parseSwiftPackageManifest(` + .target(name: "App", dependencies: [.target(name: "Core")]) + `); + expect(parsed.complete).toBe(true); + expect(parsed.targets.get('App')).toBe('Sources/App'); + expect(parsed.targets.has('Core')).toBe(false); + }); + + it('prefers an explicit path over an earlier same-name factory', () => { + const parsed = parseSwiftPackageManifest(` + .target(name: "Core") + .target(name: "Core", path: "Modules/Core") + `); + expect(parsed.complete).toBe(true); + expect(parsed.targets.get('Core')).toBe('Modules/Core'); + }); + + it('treats a string-interpolated path as incomplete', () => { + const parsed = parseSwiftPackageManifest(`.target(name: "Core", path: "Modules/\\(name)")`); + expect(parsed.complete).toBe(false); + }); + + it('treats a parenthesized name string as a complete factory', () => { + const parsed = parseSwiftPackageManifest(`.target(name: "Foo (experimental)")`); + expect(parsed.complete).toBe(true); + expect(parsed.targets.get('Foo (experimental)')).toBe('Sources/Foo (experimental)'); + }); + + it('ignores a commented parenthesis while balancing a factory', () => { + const parsed = parseSwiftPackageManifest(` + .target(name: "Core", // ) + path: "Modules/Core") + `); + expect({ complete: parsed.complete, entries: [...parsed.targets] }).toEqual({ + complete: true, + entries: [['Core', 'Modules/Core']], + }); + }); + + it('ignores a block-comment parenthesis while balancing a factory', () => { + const parsed = parseSwiftPackageManifest(`.target(name: "Core", /* ) */ path: "Modules/Core")`); + expect({ complete: parsed.complete, entries: [...parsed.targets] }).toEqual({ + complete: true, + entries: [['Core', 'Modules/Core']], + }); + }); + + it('treats a mixed literal + helper-built targets: list as incomplete', () => { + const parsed = parseSwiftPackageManifest(` +let package = Package(name: "Demo", targets: [.target(name: "Core")] + makeTargets()) +`); + expect(parsed.complete).toBe(false); + }); + + it('treats a helper-built list concatenated before literals as incomplete', () => { + const parsed = parseSwiftPackageManifest(` +let package = Package(name: "Demo", targets: makeTargets() + [.target(name: "Core")]) +`); + expect(parsed.complete).toBe(false); + }); + + it('does not treat .library(..., targets: names) as a helper-built list', () => { + const parsed = parseSwiftPackageManifest(` + .library(name: "Demo", targets: libTargets) + .target(name: "Models") + `); + expect(parsed.complete).toBe(true); + expect(parsed.targets.get('Models')).toBe('Sources/Models'); + }); + + it('ignores a factory-like spelling inside a string literal', () => { + const parsed = parseSwiftPackageManifest(` + let example = ".target(name: 'Ghost')" + .target(name: "Models") + `); + expect(parsed.complete).toBe(true); + expect(parsed.targets.has('Ghost')).toBe(false); + expect(parsed.targets.get('Models')).toBe('Sources/Models'); + }); + + it('treats a variable-prefix targets: concatenation as incomplete', () => { + const parsed = parseSwiftPackageManifest(` +let package = Package(name: "Demo", targets: extraTargets + [.target(name: "Core")]) +`); + expect(parsed.complete).toBe(false); + }); + + it('skips a commented name: field and uses the real one', () => { + const parsed = parseSwiftPackageManifest(`.target(/* name: "Ghost" */ name: "Models")`); + expect(parsed.complete).toBe(true); + expect(parsed.targets.has('Ghost')).toBe(false); + expect(parsed.targets.get('Models')).toBe('Sources/Models'); + }); + + it('does not treat a commented #if as a completeness hazard', () => { + const parsed = parseSwiftPackageManifest(` + // #if os(macOS) + .target(name: "Models") + `); + expect(parsed.complete).toBe(true); + expect(parsed.targets.get('Models')).toBe('Sources/Models'); + }); + + it('does not treat a block-commented #if as a completeness hazard', () => { + const parsed = parseSwiftPackageManifest(` +/* +#if os(macOS) + .target(name: "MacOnly") +#endif +*/ + .target(name: "Models") + `); + expect(parsed.complete).toBe(true); + expect(parsed.targets.has('MacOnly')).toBe(false); + expect(parsed.targets.get('Models')).toBe('Sources/Models'); + }); + + it('rejects a name string with a Swift escape', () => { + const parsed = parseSwiftPackageManifest(`.target(name: "\\u{43}ore")`); + expect(parsed.complete).toBe(false); + expect(parsed.targets.size).toBe(0); + }); + + it('does not treat https:// on the same line as a commented factory', () => { + const parsed = parseSwiftPackageManifest( + 'let package = Package(name: "Demo", dependencies: [.package(url: "https://example.com/foo.git", from: "1.0.0")], targets: [.target(name: "T")])', + ); + expect(parsed.complete).toBe(true); + expect(parsed.targets.get('T')).toBe('Sources/T'); + }); + + it('ignores a commented helper-built targets: list', () => { + const parsed = parseSwiftPackageManifest(` +let package = Package( + name: "Demo", + targets: [ + .target(name: "Models"), + ] +) +// targets: makeTargets() +`); + expect(parsed.complete).toBe(true); + expect(parsed.targets.get('Models')).toBe('Sources/Models'); + }); + + it('ignores a helper-built targets: spelling inside a string', () => { + const parsed = parseSwiftPackageManifest(` +let note = "targets: makeTargets()" +let package = Package(name: "Demo", targets: [.target(name: "Models")]) +`); + expect(parsed.complete).toBe(true); + expect(parsed.targets.get('Models')).toBe('Sources/Models'); + }); + + it('treats Package(targets: variable) as incomplete even when a factory was collected', () => { + const parsed = parseSwiftPackageManifest(` +func unused() { _ = Target.target(name: "Ghost") } +let package = Package(name: "Demo", targets: actualTargets) +.target(name: "Incidental") +`); + expect(parsed.complete).toBe(false); + expect(parsed.targets.size).toBe(0); + }); + + it('does not collect a factory outside Package(targets: [...])', () => { + const parsed = parseSwiftPackageManifest(` +func unused() { _ = Target.target(name: "Ghost") } +let package = Package(name: "Demo", targets: [.target(name: "Incidental")]) +`); + expect(parsed.complete).toBe(true); + expect(parsed.targets.has('Ghost')).toBe(false); + expect(parsed.targets.get('Incidental')).toBe('Sources/Incidental'); + }); + + it('treats a computed element inside the targets: array as incomplete', () => { + const parsed = parseSwiftPackageManifest(` +let package = Package(name: "Demo", targets: [makeTargets()]) +`); + expect(parsed.complete).toBe(false); + expect(parsed.targets.size).toBe(0); + }); + + it('does not collect factories when Package omits targets:', () => { + const parsed = parseSwiftPackageManifest(` +let unused = Target.target(name: "Ghost") +let package = Package(name: "Empty") +`); + expect(parsed.complete).toBe(true); + expect(parsed.targets.has('Ghost')).toBe(false); + expect(parsed.targets.size).toBe(0); + }); + + it('does not treat a line comment after a label colon as live source', () => { + const parsed = parseSwiftPackageManifest(` +let package = Package( + name: "Demo", + targets: [ + .target(name: // .target(name: "Ghost") + "Models"), + ] +) +`); + expect(parsed.complete).toBe(true); + expect(parsed.targets.has('Ghost')).toBe(false); + expect(parsed.targets.get('Models')).toBe('Sources/Models'); + }); +}); + +describe('swiftDeclaredTargetPrefix', () => { + it('treats . and ./ as the package root', () => { + expect(swiftDeclaredTargetPrefix('.')).toBe(''); + expect(swiftDeclaredTargetPrefix('./')).toBe(''); + expect(swiftDeclaredTargetPrefix('./.')).toBe(''); + }); + + it('strips a leading ./ from a relative target path', () => { + expect(swiftDeclaredTargetPrefix('./Sources/Core')).toBe('Sources/Core/'); + expect(swiftDeclaredTargetPrefix('Sources/Core')).toBe('Sources/Core/'); + }); +}); + +describe('loadSwiftPackageConfig', () => { + it('returns a declared map from Package.swift and ignores undeclared Sources/* folders', async () => { + const root = repo({ + 'Package.swift': MODELS_APP, + 'Sources/Models/User.swift': '', + 'Sources/App/main.swift': '', + 'Sources/Foundation/Thing.swift': '', + }); + + const cfg = await loadSwiftPackageConfig(root); + expect(cfg?.origin).toBe('package.swift'); + expect([...cfg!.targets.keys()].sort()).toEqual(['App', 'Models']); + expect(cfg!.targets.has('Foundation')).toBe(false); + }); + + it('returns an empty declared map when the manifest only has skipped target kinds', async () => { + const root = repo({ + 'Package.swift': ` +let package = Package( + name: "OnlyBinary", + targets: [.binaryTarget(name: "Lib", path: "Lib.xcframework")] +) +`, + 'Sources/Foundation/Thing.swift': '', + }); + + const cfg = await loadSwiftPackageConfig(root); + expect(cfg?.origin).toBe('package.swift'); + expect(cfg!.declaredTargets?.size).toBe(0); + // Grouping still uses inferred folders so App/Foundation stay isolated. + expect(cfg!.targets.get('Foundation')).toBe('Sources/Foundation'); + expect(coerceDeclaredSwiftTargets(cfg)?.size).toBe(0); + }); + + it('infers Sources/* folders when Package.swift is missing', async () => { + const root = repo({ + 'Sources/App/main.swift': '', + 'Sources/Models/User.swift': '', + }); + + const cfg = await loadSwiftPackageConfig(root); + expect(cfg?.origin).toBe('directories'); + expect(cfg!.targets.get('App')).toBe('Sources/App'); + expect(cfg!.targets.get('Models')).toBe('Sources/Models'); + }); + + it('infers directories when Package.swift is unreadable (is a directory)', async () => { + const root = repo({ + 'Package.swift': null, + 'Sources/App/main.swift': '', + }); + + const cfg = await loadSwiftPackageConfig(root); + expect(cfg?.origin).toBe('directories'); + expect(cfg!.targets.get('App')).toBe('Sources/App'); + }); + + it('infers directories when the manifest has completeness hazards', async () => { + const root = repo({ + 'Package.swift': ` +#if os(Linux) + .target(name: "LinuxOnly") +#endif +`, + 'Sources/App/main.swift': '', + }); + + const cfg = await loadSwiftPackageConfig(root); + expect(cfg?.origin).toBe('directories'); + expect(cfg!.targets.get('App')).toBe('Sources/App'); + expect(cfg!.targets.has('LinuxOnly')).toBe(false); + }); + + it('returns null when there is no manifest and no source folders', async () => { + const root = repo({ 'README.md': '' }); + expect(await loadSwiftPackageConfig(root)).toBeNull(); + }); + + it('declares a one-line manifest that includes an https:// dependency URL', async () => { + const root = repo({ + 'Package.swift': + 'let package = Package(name: "Demo", dependencies: [.package(url: "https://example.com/foo.git", from: "1.0.0")], targets: [.target(name: "T")])', + 'Sources/T/T.swift': '', + }); + const cfg = await loadSwiftPackageConfig(root); + expect(cfg?.origin).toBe('package.swift'); + expect(cfg!.declaredTargets?.get('T')).toBe('Sources/T'); + expect(cfg!.targets.get('T')).toBe('Sources/T'); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/swift/reexport-visibility.test.ts b/gitnexus/test/unit/scope-resolution/swift/reexport-visibility.test.ts new file mode 100644 index 000000000..8b901e387 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/swift/reexport-visibility.test.ts @@ -0,0 +1,131 @@ +/** + * In-repo @_exported visibility (R5 / AE4). + */ +import { describe, expect, it } from 'vitest'; +import type { ParsedFile, ParsedImport, ScopeId } from 'gitnexus-shared'; +import { resolveSwiftImportTarget } from '../../../../src/core/ingestion/languages/swift/import-target.js'; + +const DECLARED = { + origin: 'package.swift' as const, + targets: new Map([ + ['A', 'Sources/A'], + ['B', 'Sources/B'], + ['C', 'Sources/C'], + ]), +}; + +function stubFile(filePath: string, parsedImports: ParsedImport[] = []): ParsedFile { + return { + filePath, + moduleScope: `module:${filePath}` as ScopeId, + scopes: [], + parsedImports, + localDefs: [], + referenceSites: [], + }; +} + +function reexport(targetRaw: string, name = targetRaw): ParsedImport { + return { kind: 'reexport', localName: name, importedName: name, targetRaw }; +} + +function ns(targetRaw: string): ParsedImport { + return { kind: 'namespace', localName: targetRaw, importedName: targetRaw, targetRaw }; +} + +function resolve( + targetRaw: string, + files: readonly string[], + parsedFiles: ParsedFile[], + fromFile = 'Sources/App/main.swift', + resolutionConfig: unknown = DECLARED, +): string | readonly string[] | null { + return resolveSwiftImportTarget(ns(targetRaw), { + fromFile, + allFilePaths: new Set(files), + resolutionConfig, + parsedFiles, + }); +} + +describe('Swift @_exported visibility', () => { + it('AE4: import A includes B when A @_exported import B', () => { + const files = ['Sources/A/A.swift', 'Sources/B/B.swift', 'Sources/App/main.swift']; + const parsed = [ + stubFile('Sources/A/A.swift', [reexport('B')]), + stubFile('Sources/B/B.swift'), + stubFile('Sources/App/main.swift'), + ]; + expect(resolve('A', files, parsed)).toEqual( + expect.arrayContaining(['Sources/A/A.swift', 'Sources/B/B.swift']), + ); + }); + + it('transitive A → B → C unions C', () => { + const files = ['Sources/A/A.swift', 'Sources/B/B.swift', 'Sources/C/C.swift']; + const parsed = [ + stubFile('Sources/A/A.swift', [reexport('B')]), + stubFile('Sources/B/B.swift', [reexport('C')]), + stubFile('Sources/C/C.swift'), + ]; + expect(resolve('A', files, parsed, 'Sources/App/main.swift')).toEqual( + expect.arrayContaining(['Sources/A/A.swift', 'Sources/B/B.swift', 'Sources/C/C.swift']), + ); + }); + + it('cycle A → B → A terminates', () => { + const files = ['Sources/A/A.swift', 'Sources/B/B.swift']; + const parsed = [ + stubFile('Sources/A/A.swift', [reexport('B')]), + stubFile('Sources/B/B.swift', [reexport('A')]), + ]; + expect(resolve('A', files, parsed)).toEqual( + expect.arrayContaining(['Sources/A/A.swift', 'Sources/B/B.swift']), + ); + }); + + it('@_exported import Foundation adds no files', () => { + const files = ['Sources/A/A.swift', 'Sources/Foundation/Thing.swift', 'Sources/App/main.swift']; + const parsed = [ + stubFile('Sources/A/A.swift', [reexport('Foundation')]), + stubFile('Sources/Foundation/Thing.swift'), + ]; + expect(resolve('A', files, parsed)).toEqual(['Sources/A/A.swift']); + }); + + it('member-only @_exported import does not paint the rest of the module', () => { + const files = [ + 'Sources/A/A.swift', + 'Sources/Models/User.swift', + 'Sources/Models/Other.swift', + 'Sources/App/main.swift', + ]; + const parsed = [ + stubFile('Sources/A/A.swift', [reexport('Models', 'User')]), + stubFile('Sources/Models/User.swift'), + stubFile('Sources/Models/Other.swift'), + ]; + const declared = { + origin: 'package.swift' as const, + targets: new Map([ + ['A', 'Sources/A'], + ['Models', 'Sources/Models'], + ]), + }; + expect(resolve('A', files, parsed, 'Sources/App/main.swift', declared)).toEqual([ + 'Sources/A/A.swift', + ]); + }); + + it('a sibling file that does not write @_exported still contributes the reexport', () => { + const files = ['Sources/A/A.swift', 'Sources/A/Other.swift', 'Sources/B/B.swift']; + const parsed = [ + stubFile('Sources/A/A.swift', [reexport('B')]), + stubFile('Sources/A/Other.swift'), + stubFile('Sources/B/B.swift'), + ]; + expect(resolve('A', files, parsed)).toEqual( + expect.arrayContaining(['Sources/A/A.swift', 'Sources/A/Other.swift', 'Sources/B/B.swift']), + ); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/swift/target-grouping.test.ts b/gitnexus/test/unit/scope-resolution/swift/target-grouping.test.ts index f661dd21f..95d53bb43 100644 --- a/gitnexus/test/unit/scope-resolution/swift/target-grouping.test.ts +++ b/gitnexus/test/unit/scope-resolution/swift/target-grouping.test.ts @@ -3,18 +3,19 @@ * KTD2). * * `groupSwiftFilesBySpmTarget` (`languages/swift/target-grouping.ts`) - * DUPLICATES the legacy `groupSwiftFilesByTarget` (`languages/swift.ts`) - * SPM-subtree semantics so the registry-primary same-module hooks group by - * the SPM target subtree without touching the legacy pipeline (hard - * constraint: legacy stays byte-identical). Because the duplication can - * silently drift if legacy is later changed, this test pins the exact - * bucketing for representative inputs so a future divergence surfaces - * loudly: + * preserves the legacy `groupSwiftFilesByTarget` (`languages/swift.ts`) + * bucketing contract for ordinary SPM layouts: one target bucket per file, + * first-target-wins ordering, and the same `__default__` fallback. It now + * intentionally differs for issue #2931's repeated-prefix edge case by + * accepting a later segment-boundary occurrence when an earlier textual + * occurrence is embedded inside a longer path segment. These tests pin the + * shared ordinary-layout contract plus that documented #2931 fix. * * 1. A multi-subdir single target buckets into ONE group. * 2. A file matching two overlapping same-named target prefixes is * assigned to the FIRST target only (legacy `break`s — no fan-out). - * 3. Unmatched files AND the no-targets case route to `__default__` = all. + * 3. Target dirs match only at path-segment boundaries. + * 4. Unmatched files AND the no-targets case route to `__default__` = all. * * `coerceSwiftTargets` is also covered: it duck-types `{ targets: Map }` * (no `instanceof` on the config object) and returns `null` otherwise. @@ -27,7 +28,7 @@ import { const id = (s: string) => s; -describe('groupSwiftFilesBySpmTarget — legacy SPM-subtree parity (drift guard)', () => { +describe('groupSwiftFilesBySpmTarget — shared SPM bucketing contract', () => { it('buckets a multi-subdir single target into ONE group', () => { const files = [ 'Sources/Alpha/Core/User.swift', @@ -45,7 +46,7 @@ describe('groupSwiftFilesBySpmTarget — legacy SPM-subtree parity (drift guard) it('assigns a file matching two overlapping same-named prefixes to the FIRST target only', () => { // Both targets are prefixes of the file's path (Beta dir nested under - // Alpha). Legacy `break`s on the first match → one bucket per file. + // Alpha). The first configured match wins → one bucket per file. const files = ['Sources/Alpha/Beta/User.swift']; const targets = new Map([ ['Alpha', 'Sources/Alpha'], @@ -58,9 +59,19 @@ describe('groupSwiftFilesBySpmTarget — legacy SPM-subtree parity (drift guard) expect(groups.has('Beta')).toBe(false); }); + it('assigns root-level files to a path: "." target', () => { + const files = ['Lib.swift', 'Sources/Other/X.swift']; + const targets = new Map([['Lib', '.']]); + + const groups = groupSwiftFilesBySpmTarget(files, id, targets); + + expect(groups.get('Lib')).toEqual(files); + expect(groups.has('__default__')).toBe(false); + }); + it('matches a target dir only at a `/` boundary, not a substring', () => { - // "Sources/Alpha" must NOT match "Sources/AlphaBeta/..." — the legacy - // predicate requires idx===0 or a preceding `/`. + // "Sources/Alpha" must NOT match "Sources/AlphaBeta/...". The matcher + // accepts only a path-start or slash-delimited target occurrence. const files = ['Sources/AlphaBeta/User.swift']; const targets = new Map([['Alpha', 'Sources/Alpha']]); @@ -114,6 +125,26 @@ describe('groupSwiftFilesBySpmTarget — legacy SPM-subtree parity (drift guard) expect(groups.get('Beta')).toEqual([items[1]]); }); + it('keeps inferred Sources/* folders in separate buckets', () => { + const targets = new Map([ + ['App', 'Sources/App'], + ['Models', 'Sources/Models'], + ['Foundation', 'Sources/Foundation'], + ]); + const items = [ + 'Sources/App/main.swift', + 'Sources/Models/User.swift', + 'Sources/Foundation/Thing.swift', + ]; + + const groups = groupSwiftFilesBySpmTarget(items, id, targets); + + expect(groups.get('App')).toEqual(['Sources/App/main.swift']); + expect(groups.get('Models')).toEqual(['Sources/Models/User.swift']); + expect(groups.get('Foundation')).toEqual(['Sources/Foundation/Thing.swift']); + expect(groups.get('__default__')).toBeUndefined(); + }); + it('normalizes backslash paths to forward-slash before matching', () => { const files = ['Sources\\Alpha\\Core\\User.swift']; const targets = new Map([['Alpha', 'Sources/Alpha']]); diff --git a/gitnexus/test/unit/scope-resolution/swift/target-siblings.test.ts b/gitnexus/test/unit/scope-resolution/swift/target-siblings.test.ts new file mode 100644 index 000000000..eef725014 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/swift/target-siblings.test.ts @@ -0,0 +1,391 @@ +import { + buildDefIndex, + type ParsedFile, + type ScopeId, + type SymbolDefinition, +} from 'gitnexus-shared'; +import { describe, expect, it } from 'vitest'; +import { populateSwiftTargetSiblings } from '../../../../src/core/ingestion/languages/swift/target-siblings.js'; +import type { ScopeResolutionIndexes } from '../../../../src/core/ingestion/model/scope-resolution-indexes.js'; + +const moduleId = (filePath: string) => `scope:${filePath}:module` as ScopeId; +const classId = (filePath: string) => `scope:${filePath}:class` as ScopeId; + +function parsedFile( + filePath: string, + classOwnedDefs: readonly SymbolDefinition[], + classBindings: ReadonlyMap, + localDefs: readonly SymbolDefinition[], + classRange = { startLine: 1, startCol: 0, endLine: 10, endCol: 0 }, +): ParsedFile { + return { + filePath, + moduleScope: moduleId(filePath), + scopes: [ + { + id: moduleId(filePath), + parent: null, + kind: 'Module', + range: { startLine: 1, startCol: 0, endLine: 10, endCol: 0 }, + filePath, + bindings: new Map(), + ownedDefs: [], + imports: [], + typeBindings: new Map(), + }, + { + id: classId(filePath), + parent: moduleId(filePath), + kind: 'Class', + range: classRange, + filePath, + bindings: classBindings, + ownedDefs: classOwnedDefs, + imports: [], + typeBindings: new Map(), + }, + ], + parsedImports: [], + localDefs, + referenceSites: [], + }; +} + +describe('Swift target sibling visibility', () => { + it('binds a nested type into a same-target extension fragment', () => { + const container: SymbolDefinition = { + nodeId: 'def:Types.swift:Container', + filePath: 'Types.swift', + type: 'Class', + qualifiedName: 'Container', + }; + const entry: SymbolDefinition = { + nodeId: 'def:Types.swift:Container.Entry', + filePath: 'Types.swift', + type: 'Class', + qualifiedName: 'Container.Entry', + ownerId: container.nodeId, + }; + const makeEntry: SymbolDefinition = { + nodeId: 'def:Builder.swift:Container.makeEntry', + filePath: 'Builder.swift', + type: 'Method', + qualifiedName: 'Container.makeEntry', + }; + const declaration = parsedFile( + 'Types.swift', + [container], + new Map([['Entry', [{ def: entry, origin: 'local' }]]]), + [container, entry], + ); + const extension = parsedFile( + 'Builder.swift', + [], + new Map([['makeEntry', [{ def: makeEntry, origin: 'local' }]]]), + [makeEntry], + ); + const bindingAugmentations = new Map(); + const indexes = makeIndexes([container, entry, makeEntry], bindingAugmentations); + + populateSwiftTargetSiblings([declaration, extension], indexes, { + fileContents: new Map(), + }); + + expectAugmentedEntry(bindingAugmentations, entry); + }); + + it('recovers Outer.Container from public extension source when members are trailing-only', () => { + const extensionSource = 'public extension Outer.Container {\n static func makeEntry() {}\n}\n'; + const { declaration, extension, entry, indexes, bindingAugmentations } = + qualifiedExtensionFixture(extensionSource, { ownerQualifiedName: 'Outer.Container' }); + + populateSwiftTargetSiblings([declaration, extension], indexes, { + fileContents: new Map([['Builder.swift', extensionSource]]), + }); + + expectAugmentedEntry(bindingAugmentations, entry); + }); + + it('recovers a qualified owner when the extension Class scope has no bindings', () => { + const extensionSource = 'public extension Outer.Inner {\n subscript(i: Int) -> Int { i }\n}\n'; + const { declaration, extension, entry, indexes, bindingAugmentations } = + qualifiedExtensionFixture(extensionSource, { classBindings: new Map() }); + + populateSwiftTargetSiblings([declaration, extension], indexes, { + fileContents: new Map([['Builder.swift', extensionSource]]), + }); + + expectAugmentedEntry(bindingAugmentations, entry); + }); + + it('does not infer an extension owner from inconsistent qualified members', () => { + const container: SymbolDefinition = { + nodeId: 'def:Types.swift:Container', + filePath: 'Types.swift', + type: 'Class', + qualifiedName: 'Container', + }; + const entry: SymbolDefinition = { + nodeId: 'def:Types.swift:Container.Entry', + filePath: 'Types.swift', + type: 'Class', + qualifiedName: 'Container.Entry', + ownerId: container.nodeId, + }; + const containerMethod: SymbolDefinition = { + nodeId: 'def:Builder.swift:Container.makeEntry', + filePath: 'Builder.swift', + type: 'Method', + qualifiedName: 'Container.makeEntry', + }; + const otherMethod: SymbolDefinition = { + nodeId: 'def:Builder.swift:Other.makeEntry', + filePath: 'Builder.swift', + type: 'Method', + qualifiedName: 'Other.makeEntry', + }; + const declaration = parsedFile( + 'Types.swift', + [container], + new Map([['Entry', [{ def: entry, origin: 'local' }]]]), + [container, entry], + ); + const ambiguousExtension = parsedFile( + 'Builder.swift', + [], + new Map([ + ['containerMethod', [{ def: containerMethod, origin: 'local' }]], + ['otherMethod', [{ def: otherMethod, origin: 'local' }]], + ]), + [containerMethod, otherMethod], + ); + const bindingAugmentations = new Map(); + const indexes = makeIndexes( + [container, entry, containerMethod, otherMethod], + bindingAugmentations, + ); + + populateSwiftTargetSiblings([declaration, ambiguousExtension], indexes, { + fileContents: new Map(), + }); + + expect(bindingAugmentations.get(classId('Builder.swift'))?.get('Entry')).toBeUndefined(); + }); + + it('preserves the qualified owner of a nested-type extension', () => { + // Capture generation keeps only the trailing owner on members (`Inner.f`), + // so source text must recover `Outer.Inner`. + const extensionSource = 'extension Outer.Inner {\n static func makeEntry() {}\n}\n'; + const { declaration, extension, entry, indexes, bindingAugmentations } = + qualifiedExtensionFixture(extensionSource); + + populateSwiftTargetSiblings([declaration, extension], indexes, { + fileContents: new Map([['Builder.swift', extensionSource]]), + }); + + expectAugmentedEntry(bindingAugmentations, entry); + }); + + it.each([ + ['public extension Outer.Inner {\n static func makeEntry() {}\n}\n', 0], + ['@MainActor\nextension Outer.Inner {\n static func makeEntry() {}\n}\n', 0], + ['@available(iOS 15, *)\npublic extension Outer.Inner {\n static func makeEntry() {}\n}\n', 0], + [ + '@available(*, deprecated, message: "Use extension Other.Inner")\npublic extension Outer.Inner {\n static func makeEntry() {}\n}\n', + 0, + ], + [' public extension Outer.Inner {\n static func makeEntry() {}\n}\n', 2], + [ + '/* outer /* inner */ extension Wrong */ extension Outer.Inner {\n static func makeEntry() {}\n}\n', + 0, + ], + [ + '@available(*, deprecated, message: #"Use extension Wrong"#)\npublic extension Outer.Inner {\n static func makeEntry() {}\n}\n', + 0, + ], + ])( + 'recovers a qualified owner through modifiers and attributes: %j', + (extensionSource, startCol = 0) => { + const { declaration, extension, entry, indexes, bindingAugmentations } = + qualifiedExtensionFixture(extensionSource, { startCol }); + + populateSwiftTargetSiblings([declaration, extension], indexes, { + fileContents: new Map([['Builder.swift', extensionSource]]), + }); + + expectAugmentedEntry(bindingAugmentations, entry); + }, + ); + + it('converts Tree-sitter UTF-8 columns before slicing JS source', () => { + const prefix = 'struct Café {}; '; + const extensionSource = `${prefix}extension Outer.Inner {\n static func makeEntry() {}\n}\n`; + const { declaration, extension, entry, indexes, bindingAugmentations } = + qualifiedExtensionFixture(extensionSource, { + startCol: Buffer.byteLength(prefix, 'utf8'), + }); + + populateSwiftTargetSiblings([declaration, extension], indexes, { + fileContents: new Map([['Builder.swift', extensionSource]]), + }); + + expectAugmentedEntry(bindingAugmentations, entry); + }); + + it('recovers a Unicode qualified extension owner', () => { + const extensionSource = 'public extension Café.Container {\n static func makeEntry() {}\n}\n'; + const { declaration, extension, entry, indexes, bindingAugmentations } = + qualifiedExtensionFixture(extensionSource, { ownerQualifiedName: 'Café.Container' }); + + populateSwiftTargetSiblings([declaration, extension], indexes, { + fileContents: new Map([['Builder.swift', extensionSource]]), + }); + + expectAugmentedEntry(bindingAugmentations, entry); + }); + + it('prefers Outer.Inner.Entry over a colliding top-level Inner.Entry', () => { + const { collision, topInner, wrongEntry } = collidingTopLevelInner(); + const extensionSource = 'public extension Outer.Inner {\n static func makeEntry() {}\n}\n'; + const { declaration, extension, entry, indexes, bindingAugmentations } = + qualifiedExtensionFixture(extensionSource, { extraDefs: [topInner, wrongEntry] }); + + populateSwiftTargetSiblings([declaration, extension, collision], indexes, { + fileContents: new Map([['Builder.swift', extensionSource]]), + }); + + expectAugmentedEntry(bindingAugmentations, entry); + expect(bindingAugmentations.get(classId('TopInner.swift'))?.get('Entry')).toEqual([ + { def: wrongEntry, origin: 'namespace' }, + ]); + }); + + it('does not last-dot-guess Inner when source is present but not an extension', () => { + const { collision, topInner, wrongEntry } = collidingTopLevelInner(); + const { declaration, extension, indexes, bindingAugmentations } = qualifiedExtensionFixture( + 'struct Unrelated {}\n', + { extraDefs: [topInner, wrongEntry] }, + ); + + populateSwiftTargetSiblings([declaration, extension, collision], indexes, { + fileContents: new Map([['Builder.swift', 'struct Unrelated {}\n']]), + }); + + expect(bindingAugmentations.get(classId('Builder.swift'))?.get('Entry')).toBeUndefined(); + expect(bindingAugmentations.get(classId('TopInner.swift'))?.get('Entry')).toEqual([ + { def: wrongEntry, origin: 'namespace' }, + ]); + }); +}); + +function collidingTopLevelInner() { + const topInner: SymbolDefinition = { + nodeId: 'def:TopInner.swift:Inner', + filePath: 'TopInner.swift', + type: 'Class', + qualifiedName: 'Inner', + }; + const wrongEntry: SymbolDefinition = { + nodeId: 'def:TopInner.swift:Inner.Entry', + filePath: 'TopInner.swift', + type: 'Class', + qualifiedName: 'Inner.Entry', + ownerId: topInner.nodeId, + }; + const collision = parsedFile( + 'TopInner.swift', + [topInner], + new Map([['Entry', [{ def: wrongEntry, origin: 'local' }]]]), + [topInner, wrongEntry], + ); + return { collision, topInner, wrongEntry }; +} + +function expectAugmentedEntry( + bindingAugmentations: Map>, + entry: SymbolDefinition, +) { + expect(bindingAugmentations.get(classId('Builder.swift'))?.get('Entry')).toEqual([ + { def: entry, origin: 'namespace' }, + ]); +} + +function makeIndexes( + defs: readonly SymbolDefinition[], + bindingAugmentations: Map, +): ScopeResolutionIndexes { + return { + defs: buildDefIndex(defs), + moduleScopes: { + byFilePath: new Map([ + ['Types.swift', moduleId('Types.swift')], + ['Builder.swift', moduleId('Builder.swift')], + ]), + }, + bindingAugmentations, + } as unknown as ScopeResolutionIndexes; +} + +function rangeForSource(source: string, startCol = 0) { + const lines = source.split('\n'); + const last = source.endsWith('\n') ? lines.length - 2 : lines.length - 1; + const lastLine = Math.max(last, 0); + return { + startLine: 1, + startCol, + endLine: lastLine + 1, + endCol: (lines[lastLine] ?? '').length, + }; +} + +function qualifiedExtensionFixture( + extensionSource: string, + options: { + extraDefs?: readonly SymbolDefinition[]; + classBindings?: ReadonlyMap; + startCol?: number; + ownerQualifiedName?: string; + } = {}, +) { + const ownerQualifiedName = options.ownerQualifiedName ?? 'Outer.Inner'; + const ownerSimple = ownerQualifiedName.split('.').pop() ?? ownerQualifiedName; + const owner: SymbolDefinition = { + nodeId: `def:Types.swift:${ownerQualifiedName}`, + filePath: 'Types.swift', + type: 'Class', + qualifiedName: ownerQualifiedName, + }; + const entry: SymbolDefinition = { + nodeId: `def:Types.swift:${ownerQualifiedName}.Entry`, + filePath: 'Types.swift', + type: 'Class', + qualifiedName: `${ownerQualifiedName}.Entry`, + ownerId: owner.nodeId, + }; + const makeEntry: SymbolDefinition = { + nodeId: `def:Builder.swift:${ownerSimple}.makeEntry`, + filePath: 'Builder.swift', + type: 'Method', + qualifiedName: `${ownerSimple}.makeEntry`, + }; + const extraDefs = options.extraDefs ?? []; + const classBindings = + options.classBindings ?? + new Map([['makeEntry', [{ def: makeEntry, origin: 'local' as const }]]]); + const declaration = parsedFile( + 'Types.swift', + [owner], + new Map([['Entry', [{ def: entry, origin: 'local' }]]]), + [owner, entry], + ); + const extension = parsedFile( + 'Builder.swift', + [], + classBindings, + [makeEntry], + rangeForSource(extensionSource, options.startCol ?? 0), + ); + const bindingAugmentations = new Map(); + const indexes = makeIndexes([owner, entry, makeEntry, ...extraDefs], bindingAugmentations); + return { declaration, extension, entry, indexes, bindingAugmentations }; +} diff --git a/gitnexus/test/unit/scope-resolution/workspace-index.test.ts b/gitnexus/test/unit/scope-resolution/workspace-index.test.ts index 234c8d733..f0ea5d290 100644 --- a/gitnexus/test/unit/scope-resolution/workspace-index.test.ts +++ b/gitnexus/test/unit/scope-resolution/workspace-index.test.ts @@ -18,6 +18,9 @@ import { describe, it, expect } from 'vitest'; import { extractParsedFile } from '../../../src/core/ingestion/scope-extractor-bridge.js'; import { pythonScopeResolver } from '../../../src/core/ingestion/languages/python/scope-resolver.js'; +import { swiftScopeResolver } from '../../../src/core/ingestion/languages/swift/scope-resolver.js'; +import { isLanguageAvailable } from '../../../src/core/tree-sitter/parser-loader.js'; +import { SupportedLanguages } from '../../../src/config/supported-languages.js'; import { buildWorkspaceResolutionIndex } from '../../../src/core/ingestion/scope-resolution/workspace-index.js'; import { findExportedDef, @@ -39,6 +42,14 @@ function parsePython(source: string, filePath: string) { return parsed; } +const swiftAvailable = isLanguageAvailable(SupportedLanguages.Swift); + +function parseSwift(source: string, filePath: string) { + const parsed = extractParsedFile(swiftScopeResolver.languageProvider, source, filePath, () => {}); + if (parsed === undefined) throw new Error('scope extraction failed'); + return parsed; +} + describe('WorkspaceResolutionIndex — scope-only maps', () => { it('exposes classScopeByDefId, classScopeIdToDefId, and moduleScopeByFile', () => { const parsed = parsePython( @@ -87,6 +98,192 @@ def helper() -> int: }); }); +describe.skipIf(!swiftAvailable)('declaredReturnTypeByCallableId — exact callable identity', () => { + it('keeps same-file methods and parameter names from overwriting callable returns', () => { + const parsed = parseSwift( + ` +struct AResult {} +struct BResult {} +struct OtherStore {} +struct Store {} +struct A { + func make() -> AResult { AResult() } +} +struct B { + func make() -> BResult { BResult() } +} +func makeStore(makeStore: OtherStore) -> Store { Store() } +`, + 'Collisions.swift', + ); + const index = buildWorkspaceResolutionIndex([parsed]); + const callableDefs = parsed.localDefs.filter((def) => def.returnType !== undefined); + expect( + callableDefs + .map((def) => index.declaredReturnTypeByCallableId.get(def.nodeId)?.rawName) + .sort(), + ).toEqual(['AResult', 'BResult', 'Store']); + }); + + it('keeps extension and decoy return types separate despite the same method name', () => { + const extension = parseSwift( + ` +protocol ScenarioSupport {} +struct Store {} +extension ScenarioSupport { + func makeStore() -> Store { Store() } +} +`, + 'Support.swift', + ); + const decoy = parseSwift( + ` +struct OtherStore {} +struct OtherScenario { + private func makeStore() -> OtherStore { OtherStore() } +} +`, + 'AUnrelated.swift', + ); + const index = buildWorkspaceResolutionIndex([extension, decoy]); + const methods = [...extension.localDefs, ...decoy.localDefs].filter( + (def) => def.qualifiedName?.split('.').at(-1) === 'makeStore', + ); + + expect(methods).toHaveLength(2); + const byFile = new Map(methods.map((def) => [def.filePath, def])); + expect( + index.declaredReturnTypeByCallableId.get(byFile.get('Support.swift')!.nodeId)?.rawName, + ).toBe('Store'); + expect( + index.declaredReturnTypeByCallableId.get(byFile.get('AUnrelated.swift')!.nodeId)?.rawName, + ).toBe('OtherStore'); + expect(index.declaredReturnTypeByCallableId.has('makeStore')).toBe(false); + }); + + it('does not infer a return type from a same-named parameter', () => { + const parsed = parseSwift( + ` +struct OtherStore {} +func makeStore(makeStore: OtherStore) {} +`, + 'Unannotated.swift', + ); + const index = buildWorkspaceResolutionIndex([parsed]); + const makeStore = parsed.localDefs.find( + (def) => def.qualifiedName?.split('.').at(-1) === 'makeStore', + ); + + expect(makeStore).toBeDefined(); + expect(index.declaredReturnTypeByCallableId.has(makeStore!.nodeId)).toBe(false); + }); + + it('does not peel array return types without a language stripper', () => { + const parsed = parseSwift( + ` +struct User {} +func makeUsers() -> [User] { [] } +`, + 'Peel.swift', + ); + const index = buildWorkspaceResolutionIndex([parsed]); + const makeUsers = parsed.localDefs.find( + (def) => def.qualifiedName?.split('.').at(-1) === 'makeUsers', + ); + expect(makeUsers?.returnType).toBeDefined(); + expect(index.declaredReturnTypeByCallableId.get(makeUsers!.nodeId)?.rawName).toBe( + makeUsers!.returnType, + ); + }); + + it('applies stripTypePreservingDecoration to optional returns only', () => { + const parsed = parseSwift( + ` +struct Store {} +func makeStore() -> Store? { nil } +func makeUsers() -> [User] { [] } +`, + 'Optional.swift', + ); + const index = buildWorkspaceResolutionIndex([parsed], undefined, { + stripTypePreservingDecoration: (typeName) => + typeName.trim().endsWith('?') ? typeName.trim().slice(0, -1).trim() : undefined, + }); + const byName = new Map( + parsed.localDefs + .filter((def) => def.returnType !== undefined) + .map((def) => [def.qualifiedName?.split('.').at(-1), def]), + ); + expect(index.declaredReturnTypeByCallableId.get(byName.get('makeStore')!.nodeId)?.rawName).toBe( + 'Store', + ); + expect(index.declaredReturnTypeByCallableId.get(byName.get('makeUsers')!.nodeId)?.rawName).toBe( + byName.get('makeUsers')!.returnType, + ); + }); +}); + +describe.skipIf(!swiftAvailable)('Swift call-result assignment extraction', () => { + it('aligns each lhs with its own same-name call-resolution anchor', () => { + const parsed = parseSwift( + ` +func run() { + let store = makeStore() + let other = makeStore() +} +`, + 'Scenario.swift', + ); + + const assignments = parsed.callResultAssignmentSites ?? []; + expect(assignments.map(({ lhs }) => lhs)).toEqual(['store', 'other']); + expect( + new Set(assignments.map(({ callSite }) => `${callSite.startLine}:${callSite.startCol}`)).size, + ).toBe(2); + + const callAnchors = parsed.referenceSites + .filter((site) => site.name === 'makeStore') + .map(({ atRange }) => `${atRange.startLine}:${atRange.startCol}`); + expect(assignments.map(({ callSite }) => `${callSite.startLine}:${callSite.startCol}`)).toEqual( + callAnchors, + ); + expect(new Set(assignments.map(({ inScope }) => inScope)).size).toBe(1); + }); + + it('does not emit replay facts for explicitly typed declarations', () => { + const parsed = parseSwift( + ` +func run() { + let explicit: Store = makeStore() +} +`, + 'Typed.swift', + ); + expect(parsed.callResultAssignmentSites).toBeUndefined(); + }); + + it('unwraps await and try expressions to the exact call position', () => { + const parsed = parseSwift( + ` +func run() async throws { + let awaited = await makeStore() + let tried = try makeStore() + let triedAwaited = try await makeStore() +} +`, + 'Wrapped.swift', + ); + const assignments = parsed.callResultAssignmentSites ?? []; + expect(assignments.map(({ lhs }) => lhs)).toEqual(['awaited', 'tried', 'triedAwaited']); + const callAnchors = parsed.referenceSites + .filter((site) => site.name === 'makeStore') + .map(({ atRange }) => `${atRange.startLine}:${atRange.startCol}`); + expect(assignments.map(({ callSite }) => `${callSite.startLine}:${callSite.startCol}`)).toEqual( + callAnchors, + ); + }); +}); + describe('findExportedDef — module-export visibility filter', () => { it('keeps top-level class and function defs', () => { const parsed = parsePython( diff --git a/gitnexus/test/unit/skill-evolution-workflow.test.ts b/gitnexus/test/unit/skill-evolution-workflow.test.ts index 6916c7028..0b46e2736 100644 --- a/gitnexus/test/unit/skill-evolution-workflow.test.ts +++ b/gitnexus/test/unit/skill-evolution-workflow.test.ts @@ -39,6 +39,7 @@ const workflowDocument = load(workflow) as { 'timeout-minutes'?: unknown; with?: Record; env?: Record; + 'working-directory'?: string; }>; } >; @@ -476,16 +477,26 @@ exit 1`); expect(containment).toContain('gitnexus-cursor-integration/skills/gitnexus-review/*'); }); - it('installs node_modules for the monorepo root, gitnexus-shared, and gitnexus', () => { - // The benchmark sandbox-copies node_modules from all three (tasks.scenarios.yaml). - // The root tree was absent on the first real run because only the two subpackage - // steps ran, so capture_task_dependency_binding aborted at task binding. + it('installs node_modules for the monorepo root and gitnexus, and compiles shared from the parent', () => { + // The benchmark sandbox-copies node_modules from root and gitnexus + // (tasks.scenarios.yaml). Shared is compiled by gitnexus `npm run build` + // (scripts/build.js); a dedicated npm ci in gitnexus-shared stalls CI. const rootStep = findStep('Install monorepo root dependencies'); expect(rootStep).toBeDefined(); expect(rootStep).not.toHaveProperty('working-directory'); // installs at the repo root expect(String(rootStep?.run)).toContain('npm ci'); - expect(stepRun('Build pinned shared runtime')).toContain('npm ci'); - expect(stepRun('Install and build pinned GitNexus runtime')).toContain('npm ci'); + expect(findStep('Build pinned shared runtime')).toBeUndefined(); + const gitnexusRun = stepRun('Install and build pinned GitNexus runtime'); + expect(gitnexusRun).toContain('npm ci'); + expect(gitnexusRun).toContain('npm run build'); + expect(gitnexusRun).toContain('mkdir -p ../gitnexus-shared/node_modules'); + expect( + evolveJob?.steps?.some( + (step) => + step['working-directory'] === 'gitnexus-shared' && + String(step.run ?? '').includes('npm ci'), + ), + ).toBe(false); }); it('waits for the runner boot-time package lock before installing containment tools', () => { diff --git a/gitnexus/test/unit/watch-paths.test.ts b/gitnexus/test/unit/watch-paths.test.ts index eae53a1fd..5dc5ce3f9 100644 --- a/gitnexus/test/unit/watch-paths.test.ts +++ b/gitnexus/test/unit/watch-paths.test.ts @@ -171,6 +171,27 @@ describe('watch path selection', () => { } }); + it('applies process-detection budget keys from rc and CLI without throwing (#3313)', async () => { + await fs.writeFile( + path.join(repoPath, '.gitnexusrc'), + JSON.stringify({ maxProcesses: '40', maxEntryPointCandidates: 400 }), + ); + const baseline = { maxFileSize: undefined, workerTimeout: undefined, verbose: undefined }; + await expect(resolveWatchOptions(repoPath, {}, baseline)).resolves.toMatchObject({ + maxProcesses: 40, + maxEntryPointCandidates: 400, + }); + await expect( + resolveWatchOptions(repoPath, { maxProcesses: '25' }, baseline), + ).resolves.toMatchObject({ + maxProcesses: 25, + maxEntryPointCandidates: 400, + }); + const zeroBudget = await resolveWatchOptions(repoPath, { maxProcesses: '0' }, baseline); + expect(zeroBudget).toMatchObject({ maxEntryPointCandidates: 400 }); + expect(zeroBudget.maxProcesses).toBeUndefined(); + }); + it('rejects a watch file-size threshold above the parser ceiling', async () => { await expect( resolveWatchOptions( diff --git a/gitnexus/vitest.config.ts b/gitnexus/vitest.config.ts index 75e487265..642b509b6 100644 --- a/gitnexus/vitest.config.ts +++ b/gitnexus/vitest.config.ts @@ -101,6 +101,7 @@ export default defineConfig({ 'test/integration/analyze-wal-checkpoint-failure.test.ts', 'test/integration/lbug-non-ascii-path.test.ts', 'test/integration/lbug-conn-serialization.test.ts', + 'test/integration/load-cached-embeddings-spill.test.ts', 'test/integration/group/manifest-resolve-symbol-2325.test.ts', 'test/integration/group/manifest-synthetic-impact-lbug.test.ts', 'test/integration/group/http-route-resolve-symbol.test.ts', @@ -181,6 +182,7 @@ export default defineConfig({ 'test/integration/analyze-wal-checkpoint-failure.test.ts', 'test/integration/lbug-non-ascii-path.test.ts', 'test/integration/lbug-conn-serialization.test.ts', + 'test/integration/load-cached-embeddings-spill.test.ts', 'test/integration/group/manifest-resolve-symbol-2325.test.ts', 'test/integration/group/manifest-synthetic-impact-lbug.test.ts', 'test/integration/group/http-route-resolve-symbol.test.ts', diff --git a/package-lock.json b/package-lock.json index f3d31e09a..f34ffa491 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ "husky": "^9.1.7", "lint-staged": "^15.5.0", "prettier": "^3.8.0", - "prettier-plugin-tailwindcss": "^0.7.0" + "prettier-plugin-tailwindcss": "^0.7.0", + "typescript": "^5.9.3" } }, "node_modules/@babel/code-frame": { @@ -626,6 +627,189 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", + "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", + "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.57.2", + "@typescript-eslint/tsconfig-utils": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/project-service": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", + "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.57.2", + "@typescript-eslint/types": "^8.57.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", + "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", + "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", + "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.57.2", + "@typescript-eslint/tsconfig-utils": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/project-service": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", + "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.57.2", + "@typescript-eslint/types": "^8.57.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", + "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/@typescript-eslint/parser": { "version": "8.57.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", @@ -651,7 +835,35 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/project-service": { + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", + "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.57.2", + "@typescript-eslint/tsconfig-utils": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/project-service": { "version": "8.57.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", @@ -673,6 +885,23 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", + "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/@typescript-eslint/scope-manager": { "version": "8.57.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", @@ -691,48 +920,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", - "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", - "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2", - "@typescript-eslint/utils": "8.57.2", - "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, "node_modules/@typescript-eslint/types": { "version": "8.57.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", @@ -747,58 +934,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", - "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.57.2", - "@typescript-eslint/tsconfig-utils": "8.57.2", - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", - "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.57.2", - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, "node_modules/@typescript-eslint/visitor-keys": { "version": "8.57.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", @@ -943,9 +1078,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2154,13 +2289,13 @@ } }, "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -2520,9 +2655,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -2669,14 +2804,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -2704,9 +2839,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -2761,7 +2896,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 8a732cb14..ff2620523 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "husky": "^9.1.7", "lint-staged": "^15.5.0", "prettier": "^3.8.0", - "prettier-plugin-tailwindcss": "^0.7.0" + "prettier-plugin-tailwindcss": "^0.7.0", + "typescript": "^5.9.3" }, "lint-staged": { "*.{ts,tsx}": [