mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
Merge branch 'main' into fix/skill-evolution-gate
This commit is contained in:
commit
f7a1c28291
243 changed files with 22649 additions and 1373 deletions
|
|
@ -5,9 +5,9 @@ description: "Use when the user needs to run GitNexus CLI commands like analyze/
|
|||
|
||||
# GitNexus CLI Commands
|
||||
|
||||
Commands below use `node .gitnexus/run.cjs <command>` — the project-local runner `gitnexus analyze` drops next to the index. It auto-selects an available runner at call time (global `gitnexus`, else `pnpm dlx`, else `npx`), so no package-manager assumption and no global install is required.
|
||||
Commands below use `node .gitnexus/run.cjs <command>` — the project-local runner `gitnexus analyze` drops next to the index. It auto-selects an available runner at call time (global `gitnexus`, else `pnpm dlx`, else `bunx`, else `npx`), so no package-manager assumption and no global install is required — including on a bun-only machine, which has no npm, npx or pnpm at all.
|
||||
|
||||
> **Not analyzed yet, or `node .gitnexus/run.cjs` reports `Cannot find module`** (the gitignored runner is absent — e.g. a fresh clone or `git clean`)? (Re)generate it with `npx gitnexus analyze` from the project root. On **npm 11.x**, if `npx` crashes during install (`node.target is null`), install once with `npm i -g gitnexus` (then `gitnexus analyze`) or use `pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze`. See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939).
|
||||
> **Not analyzed yet, or `node .gitnexus/run.cjs` reports `Cannot find module`** (the gitignored runner is absent — e.g. a fresh clone or `git clean`)? (Re)generate it with `npx gitnexus analyze` from the project root, or `bunx gitnexus@latest analyze` on a bun-only machine. On **npm 11.x**, if `npx` crashes during install (`node.target is null`), install once with `npm i -g gitnexus` (then `gitnexus analyze`), or use `bunx gitnexus@latest analyze`, or `pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze`. See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939).
|
||||
|
||||
## Commands
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,14 @@ description: "Use when the user wants to know what will break if they change som
|
|||
| 5-15 symbols, 2-5 processes | MEDIUM |
|
||||
| >15 symbols or many processes | HIGH |
|
||||
| Critical path (auth, payments) | CRITICAL |
|
||||
| **Zero callers found** | **UNKNOWN** |
|
||||
|
||||
`UNKNOWN` is not a low rung on this scale — it means the walk could not answer.
|
||||
An empty caller set is equally consistent with "genuinely unused" and "the
|
||||
callers are not resolvable by the index" (plain-object property access, dynamic
|
||||
dispatch, cross-language calls), so few-callers ⇒ LOW does **not** apply. The
|
||||
result carries a `riskNote` saying so. Confirm with a text search before
|
||||
treating the symbol as safe to change or delete.
|
||||
|
||||
## Tools
|
||||
|
||||
|
|
|
|||
|
|
@ -148,13 +148,19 @@ finding is NOT proof of safety.
|
|||
|
||||
## Adding a source / sink / sanitizer
|
||||
|
||||
Edit the language model in `taint/typescript-model.ts` (registered via the
|
||||
explicit `registerBuiltinTaintModels` seam, keyed by `SupportedLanguages`). The
|
||||
spec is hashable data (no functions). A sanitizer's `neutralizes` lists the
|
||||
EXACT sink kinds it defends — never a blanket kill. Add a fixture + assert the
|
||||
finding (or its absence) in `test/unit/taint/` (real-source harness:
|
||||
`test/helpers/ts-cfg-harness.ts`); the end-to-end proof is
|
||||
`test/integration/cfg/`.
|
||||
Taint models cover four `SupportedLanguages` ids across three files:
|
||||
TypeScript and JavaScript use `taint/typescript-model.ts`, Python uses
|
||||
`taint/python-model.ts`, and Java uses `taint/java-model.ts`. Edit the model
|
||||
for the language you are targeting. The explicit
|
||||
`registerBuiltinTaintModels` seam in `typescript-model.ts` registers all four;
|
||||
it is not an import side effect.
|
||||
|
||||
The spec is hashable data (no functions). A sanitizer's `neutralizes` lists
|
||||
the EXACT sink kinds it defends — never a blanket kill. Add a fixture + assert
|
||||
the finding (or its absence) in `test/unit/taint/`. TypeScript and JavaScript
|
||||
use the real-source harness `test/helpers/ts-cfg-harness.ts`; Python and Java
|
||||
model matches are covered by `python-model-match.test.ts` and
|
||||
`java-model-match.test.ts`. The end-to-end proof is `test/integration/cfg/`.
|
||||
|
||||
## Validation checklist for any `--pdg` change
|
||||
|
||||
|
|
|
|||
11
.github/workflows/ci-tests.yml
vendored
11
.github/workflows/ci-tests.yml
vendored
|
|
@ -508,6 +508,17 @@ jobs:
|
|||
run: node --import tsx bench/cpp-qualified-ns/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Kotlin import-resolution identity + scaling guards
|
||||
# Build-free: asserts resolveKotlinImportTarget resolves an unchanged
|
||||
# file set (fingerprint, in both file-set iteration orders — every
|
||||
# tie-break in that resolver is expressed only through iteration order)
|
||||
# and that per-import cost stays independent of workspace size. The
|
||||
# pre-index implementation scores 3.737 on this corpus against 0.99 for
|
||||
# the index, so the gate separates them by a wide margin. Rationale and
|
||||
# history: see the header of bench/kotlin-import-target/measure.mjs.
|
||||
run: node --import tsx bench/kotlin-import-target/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Receiver-resolution drop guards
|
||||
# NOT build-free: this one runs the real pipeline, so it needs dist/
|
||||
# (the setup action above builds). ~2m15s.
|
||||
|
|
|
|||
4
.github/workflows/docker.yml
vendored
4
.github/workflows/docker.yml
vendored
|
|
@ -148,7 +148,7 @@ jobs:
|
|||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
|
@ -163,7 +163,7 @@ jobs:
|
|||
# `akonlabs/gitnexus` and `akonlabs/gitnexus-web` repos.
|
||||
- name: Log in to Docker Hub
|
||||
if: ${{ github.event_name != 'pull_request' && !inputs.dry_run }}
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
|
|
|||
2
.github/workflows/pr-labeler.yml
vendored
2
.github/workflows/pr-labeler.yml
vendored
|
|
@ -108,7 +108,7 @@ jobs:
|
|||
# Pinned to v7.2.0. Verify SHA via:
|
||||
# gh api repos/release-drafter/release-drafter/git/refs/tags/v7.2.0
|
||||
# v7 removed `disable-releaser`; use `dry-run: true` to only autolabel.
|
||||
- uses: release-drafter/release-drafter@eada3c96a64734dd381cfbda23511034e328ddb0 # v7.6.0
|
||||
- uses: release-drafter/release-drafter@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0
|
||||
with:
|
||||
config-name: release-drafter.yml
|
||||
dry-run: true
|
||||
|
|
|
|||
2
.github/workflows/scorecard.yml
vendored
2
.github/workflows/scorecard.yml
vendored
|
|
@ -38,7 +38,7 @@ jobs:
|
|||
persist-credentials: false
|
||||
|
||||
- name: Run Scorecard
|
||||
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
|
||||
uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
|
|
|
|||
|
|
@ -113,13 +113,14 @@ mirror. `gitnexus/test/unit/shipped-skills-sync.test.ts` guards the copies. Toke
|
|||
|
||||
This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 relationships, 918 execution flows). Use GitNexus graph tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
|
||||
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? Bootstrap with `npx`, `bunx`, or `pnpm dlx` — e.g. `bunx gitnexus@latest analyze` (npm 11 npx crash; #1939).
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing.** Use `impact({target: "symbolName", direction: "upstream"})` (MCP) or `node .gitnexus/run.cjs impact "symbolName" --direction upstream --repo .` (CLI fallback); report callers, processes, and risk. Never substitute grep for graph analysis. For unified PDG impact, add `mode: "pdg"` with optional `line: <N>` — it returns statement-level `affectedStatements` over CDG + REACHING_DEF and inter-procedural symbols in `interproceduralByDepth`/`byDepth`; no-layer/degraded PDG results are UNKNOWN-risk notes (`--pdg` layer). CLI equivalent: `node .gitnexus/run.cjs impact "symbolName" --direction upstream --mode pdg --line <N> --repo .`.
|
||||
- **MUST analyze graph changes before committing.** Use `detect_changes({scope: "all"})` (MCP) or `node .gitnexus/run.cjs detect-changes --scope all --repo .` (CLI fallback). For regression review: `detect_changes({scope: "compare", base_ref: "main"})` or `node .gitnexus/run.cjs detect-changes --scope compare --base-ref "main" --repo .`.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- **MUST treat `risk: UNKNOWN` as unresolved, not as low.** An empty caller set is not evidence the symbol is unused — it can also mean the callers are not resolvable by the index (plain-object property access, dynamic dispatch, cross-language calls). `impact` pairs `UNKNOWN` with a `riskNote` saying so. Confirm with a text search before treating the symbol as safe to change or delete; do not proceed on the strength of a zero.
|
||||
- When exploring unfamiliar code, use `query({search_query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
|
||||
- For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`).
|
||||
|
|
@ -128,7 +129,7 @@ This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 rela
|
|||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method before MCP/CLI impact analysis.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis, and never read `UNKNOWN` as an all-clear — it means the walk could not answer, which is the one verdict that requires confirming by other means.
|
||||
- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
|
||||
- NEVER commit before MCP/CLI graph change analysis.
|
||||
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ scan → structure → [springConfig, markdown, cobol] → parse → [routes, to
|
|||
| `markdown` | `markdown.ts` | `structure` | Section nodes, cross-link edges from .md/.mdx |
|
||||
| `cobol` | `cobol.ts` | `structure` | COBOL program/paragraph/section nodes (regex, no tree-sitter) |
|
||||
| `parse` | `parse.ts` + `parse-impl.ts` | `structure`, `markdown`, `cobol` | Symbol nodes, IMPORTS/CALLS/EXTENDS edges, extracted routes/tools/ORM queries |
|
||||
| `routes` | `routes.ts` | `parse` | Route nodes + HANDLES_ROUTE edges (Next.js, Expo, PHP, decorators) |
|
||||
| `routes` | `routes.ts` | `parse` | Route nodes + HANDLES_ROUTE edges (Next.js, Expo, PHP, decorators, and JS/TS dispatch guards — see below) |
|
||||
| `tools` | `tools.ts` | `parse` | Tool nodes + HANDLES_TOOL edges |
|
||||
| `orm` | `orm.ts` | `parse` | QUERIES edges (Prisma, Supabase) |
|
||||
| `crossFile` | `cross-file.ts` + `cross-file-impl.ts` | `parse`, `routes`, `tools`, `orm` | Cross-file type propagation in topological import order |
|
||||
|
|
@ -164,6 +164,48 @@ export const myPhase: PipelinePhase<MyPhaseOutput> = {
|
|||
};
|
||||
```
|
||||
|
||||
### Where routes come from
|
||||
|
||||
`route-extractors/` holds four independent ways a route can be discovered, all
|
||||
converging on the routes phase's `(method, url)` registry:
|
||||
|
||||
| Source | Shape | Examples |
|
||||
| --- | --- | --- |
|
||||
| Filesystem convention | path → URL, no parsing | Next.js `app/`, Expo, PHP |
|
||||
| Single-file framework route | `isRouteFile` + worker extraction | Laravel `routes/*.php` |
|
||||
| Cross-file framework route | `discoverRootRouteFiles` + `extractRoutes` | Django `urlpatterns` |
|
||||
| AST-level route in a normal file | `extractDecoratorRoutes` | Spring, FastAPI, NestJS, **JS/TS dispatch guards** |
|
||||
|
||||
The last row is the one whose name undersells it. A route is DECLARED by a
|
||||
decorator, but it can also be **inferred** from a raw `node:http` server's own
|
||||
dispatch — `if (req.method === 'GET' && pathname === '/api/x')` is a route with
|
||||
a path, a verb and a handler, and nothing else in the pipeline could see it.
|
||||
`route-extractors/dispatch-guard.ts` reads that shape; the transport, dedup and
|
||||
handler resolution are shared with decorator routes, and
|
||||
`ExtractedDecoratorRoute.source` carries the provenance difference through to
|
||||
the `HANDLES_ROUTE` edge.
|
||||
|
||||
That extractor is deliberately **precision-weighted**: `route_map` presents its
|
||||
output as fact, so a `startsWith` namespace test, a bare `pathname === '/'`
|
||||
without a verb, and any regex it cannot translate exactly are all dropped rather
|
||||
than guessed at. A missing route is a coverage limit; an invented one is a lie.
|
||||
|
||||
Two rules there need more than one comparison to decide, and are worth knowing
|
||||
about before changing either:
|
||||
|
||||
- **Same-file constant folding.** `` pathname === `${basePath}/rules` `` is
|
||||
common enough that refusing it loses whole route modules — and loses them
|
||||
invisibly, since a module with unfoldable paths and a module with no routes
|
||||
produce the same empty answer. Folding is same-file, string literals only, one
|
||||
alias hop, and refuses on ambiguity (a name declared twice with different
|
||||
values is dropped, never guessed).
|
||||
- **Whole-repo reconciliation** (`reconcileDispatchGuardRoutes`, applied in the
|
||||
routes phase). A split route table — one module listing every path it
|
||||
recognises so the dispatcher can 404 early, handlers in others — otherwise
|
||||
lists every route twice, once verb-less with the table as its "handler". It
|
||||
applies to dispatch-guard routes only: a framework route with no verb is
|
||||
method-agnostic *by declaration*, which is a fact, not a weaker observation.
|
||||
|
||||
---
|
||||
|
||||
## Semantic model
|
||||
|
|
@ -214,6 +256,9 @@ Language-agnostic scope-resolution resolver. This is the resolution path for eve
|
|||
│ emitReferencesViaLookup ── uses handledSites + deferred-site skip set
|
||||
│ emitPropertyDispatchCalls ── registration USES + conservative CALLS
|
||||
│ emitCallableValueFlow ── assigned/passed callable invocation CALLS
|
||||
│ emitImportedValueReferences ── cross-file value reads via finalized imports
|
||||
│ emitUniqueNamePropertyAccesses ── LAST-RESORT property reads by name,
|
||||
│ narrowed same-file → direct-import, refusing to choose otherwise
|
||||
│ emitImportEdges
|
||||
▼
|
||||
KnowledgeGraph (IMPORTS / CALLS / ACCESSES / INHERITS / USES)
|
||||
|
|
|
|||
|
|
@ -64,13 +64,14 @@ See the `<!-- gitnexus:start --> … <!-- gitnexus:end -->` block in **[AGENTS.m
|
|||
|
||||
This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 relationships, 918 execution flows). Use GitNexus graph tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
|
||||
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? Bootstrap with `npx`, `bunx`, or `pnpm dlx` — e.g. `bunx gitnexus@latest analyze` (npm 11 npx crash; #1939).
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing.** Use `impact({target: "symbolName", direction: "upstream"})` (MCP) or `node .gitnexus/run.cjs impact "symbolName" --direction upstream --repo .` (CLI fallback); report callers, processes, and risk. Never substitute grep for graph analysis. For unified PDG impact, add `mode: "pdg"` with optional `line: <N>` — it returns statement-level `affectedStatements` over CDG + REACHING_DEF and inter-procedural symbols in `interproceduralByDepth`/`byDepth`; no-layer/degraded PDG results are UNKNOWN-risk notes (`--pdg` layer). CLI equivalent: `node .gitnexus/run.cjs impact "symbolName" --direction upstream --mode pdg --line <N> --repo .`.
|
||||
- **MUST analyze graph changes before committing.** Use `detect_changes({scope: "all"})` (MCP) or `node .gitnexus/run.cjs detect-changes --scope all --repo .` (CLI fallback). For regression review: `detect_changes({scope: "compare", base_ref: "main"})` or `node .gitnexus/run.cjs detect-changes --scope compare --base-ref "main" --repo .`.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- **MUST treat `risk: UNKNOWN` as unresolved, not as low.** An empty caller set is not evidence the symbol is unused — it can also mean the callers are not resolvable by the index (plain-object property access, dynamic dispatch, cross-language calls). `impact` pairs `UNKNOWN` with a `riskNote` saying so. Confirm with a text search before treating the symbol as safe to change or delete; do not proceed on the strength of a zero.
|
||||
- When exploring unfamiliar code, use `query({search_query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
|
||||
- For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`).
|
||||
|
|
@ -79,7 +80,7 @@ This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 rela
|
|||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method before MCP/CLI impact analysis.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis, and never read `UNKNOWN` as an all-clear — it means the walk could not answer, which is the one verdict that requires confirming by other means.
|
||||
- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
|
||||
- NEVER commit before MCP/CLI graph change analysis.
|
||||
|
||||
|
|
|
|||
|
|
@ -125,5 +125,7 @@ ENV GITNEXUS_HOME=/data/gitnexus \
|
|||
|
||||
EXPOSE 4747
|
||||
|
||||
# Bind to 0.0.0.0 so the server is reachable from the host's mapped port.
|
||||
CMD ["node", "gitnexus/dist/cli/index.js", "serve", "--host", "0.0.0.0", "--port", "4747"]
|
||||
# Bind 0.0.0.0 for the host's mapped port, honoring an injected $PORT (Render
|
||||
# sets one). `sh -c` expands it; `exec` keeps the server PID 1 so SIGTERM still
|
||||
# reaches it. Platforms can rely on this instead of a dockerCommand override.
|
||||
CMD ["sh", "-c", "exec gitnexus serve --host 0.0.0.0 --port \"${PORT:-4747}\""]
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ Format: **Trigger → Instruction → Reason**. Append new Signs when the same m
|
|||
### Stale graph after edits
|
||||
|
||||
- **Trigger:** MCP warns index is behind `HEAD`, or search doesn't match latest commit.
|
||||
- **Do:** `npx gitnexus analyze` (plus `--embeddings` if used). Runs incrementally by default — the pipeline parses every file every run (cross-file resolution requires it), but tree-sitter dispatch is skipped for unchanged file chunks via the content-addressed cache, and only changed-file rows (plus their importers, transitively) are rewritten in LadybugDB. When the effective write set exceeds ~50% of the repo's files (minimum 50 files), the run transparently switches to the full wipe + bulk-COPY write plan and logs "switching to a full DB write" — expected behavior, not a bug, and file-level bookkeeping stays incremental.
|
||||
- **Do:** `npx gitnexus analyze` (plus `--embeddings` if used). Runs incrementally by default — the pipeline parses every file every run (cross-file resolution requires it), but tree-sitter dispatch is skipped for unchanged file chunks via the content-addressed cache, and only changed-file rows (plus their importers, transitively) are rewritten in LadybugDB. When the effective write set exceeds ~50% of the repo's files (minimum 50 files), the run transparently switches to the full wipe + bulk-COPY write plan and logs "switching to a full DB write" — expected behavior, not a bug, and file-level bookkeeping stays incremental. That same line also appears — regardless of write-set size, even for a one-file change — when a LadybugDB extension the existing index depends on cannot load on this machine (VECTOR, #2623; FTS, #2841), because a DB carrying those indexes refuses all row-level DML until the extension is loaded; run `gitnexus doctor` for live extension status and re-run with `GITNEXUS_LBUG_EXTENSION_INSTALL=auto` (with network access) to allow one bounded install attempt. The rebuild is one-shot: it clears the indexes, so the next run goes back to the incremental plan.
|
||||
- **Why:** Tools query LadybugDB from last analyze; git changes are invisible until re-indexed.
|
||||
|
||||
### Index seems corrupt or "incremental" is misbehaving
|
||||
|
|
@ -52,6 +52,12 @@ Format: **Trigger → Instruction → Reason**. Append new Signs when the same m
|
|||
- **Do:** Re-run plain `npx gitnexus analyze` — no `--embeddings` flag needed. A retained `embeddingCheckpoint` in the index metadata forces embedding generation for exactly the pending nodes regardless of flags, and clears once they succeed. `--drop-embeddings` abandons the pending nodes instead of retrying them; `--force` also discards the checkpoint (with a warning) and rebuilds without resuming it.
|
||||
- **Why:** A long analyze run against a flaky HTTP embedding endpoint tolerates bounded sub-batch failures instead of aborting the whole run: it deletes the affected nodes' embedding rows (so they hold zero rows, never a partial set) and records those nodes as pending in `embeddingCheckpoint`. `stats.embeddings` stays an honest, non-zero count of everything that did succeed, so this state never trips the "Embeddings vanished" Sign above — `embedding-checkpoint-pending` is the only reliable signal.
|
||||
|
||||
### Analyze reports INCOMPLETE with a collapsed graph write
|
||||
|
||||
- **Trigger:** `npx gitnexus status` reports `incompleteReasons: ["graph-write-collapsed"]`; the analyze summary printed `Repository indexed INCOMPLETELY` naming an expected and a persisted relationship count, and the CLI exited non-zero.
|
||||
- **Do:** Re-run `npx gitnexus analyze --force`. If it recurs, check free disk space on the volume holding `.gitnexus/`, confirm no second `analyze` is running against the same repo (both stage through `.gitnexus/csv`), then run `npx gitnexus doctor`.
|
||||
- **Why:** The run finished and wrote metadata, but far fewer relationships are readable back than the pipeline produced. Nothing throws: the DB holds rows and the metadata is valid, so every query answers with missing edges rather than an error — a confident empty answer, which is worse than a failure because it looks like a result. Unlike `incremental-in-progress` and `embedding-checkpoint-pending`, which describe a run that did what it said and left work for next time, this one means most of your edges are gone, so it is the one incomplete reason that also fails the exit code. The check compares in-memory totals (including rows streamed out of the heap) against the post-write count, refuses to answer when the count cannot be read, and is skipped on incremental runs where whole-scope counts are not comparable.
|
||||
|
||||
### MCP lists no repos
|
||||
|
||||
- **Trigger:** MCP stderr says no indexed repos.
|
||||
|
|
|
|||
13
MIGRATION.md
13
MIGRATION.md
|
|
@ -106,6 +106,19 @@ Running `npx gitnexus analyze` writes both `gitnexus.json` and `meta.json`
|
|||
with identical content. A pre-existing repo that only has `meta.json` gets
|
||||
`gitnexus.json` bootstrapped from it on the first run.
|
||||
|
||||
### Process ids are not stable across this release
|
||||
|
||||
`Process` ids are positional (`proc_<idx>_<entry>`), and this release changes
|
||||
both which execution flows are detected and the order they are selected in:
|
||||
tracing is depth-first, sibling branches follow source order, and selection
|
||||
round-robins across terminals so one flow cannot take every slot. A given
|
||||
`proc_7_handle` before the upgrade is not the same flow afterwards.
|
||||
|
||||
Nothing in GitNexus persists or joins on a raw process id across a re-index —
|
||||
the MCP resource keys by label — so this is one-time index churn rather than a
|
||||
broken consumer. If you have external tooling that stored a process id, re-
|
||||
resolve it by label after the next analyze.
|
||||
|
||||
### What about rollback?
|
||||
|
||||
Downgrading to an older GitNexus version is safe: `meta.json` is always
|
||||
|
|
|
|||
22
README.md
22
README.md
|
|
@ -80,6 +80,28 @@ That's it. `analyze` indexes the codebase, installs agent skills, registers Clau
|
|||
|
||||
</details>
|
||||
|
||||
### Deploy to Render
|
||||
|
||||
Deploy GitNexus in one click:
|
||||
|
||||
[](https://render.com/deploy?repo=https://github.com/abhigyanpatwari/GitNexus)
|
||||
|
||||
The Blueprint creates two services. `gitnexus-server` runs `gitnexus serve` as a private service: no public URL, reachable only over Render's private network, with a persistent disk for indexes and cloned repos. `gitnexus-web` is the public one. It serves the UI and reverse-proxies `/api/*` to the server, so the browser talks to a single origin.
|
||||
|
||||
At the Blueprint's defaults this runs about **$35/month**: $25 for the server's `standard` instance, $7 for the web service's `starter` instance, and $2.50 for the 10 GB disk. See [Render's pricing](https://render.com/pricing) for other plans.
|
||||
|
||||
The deploy generates an access token, and the UI asks for it on first use:
|
||||
|
||||
1. Open the `gitnexus-web` service in your [Render dashboard](https://dashboard.render.com/).
|
||||
2. Copy `GITNEXUS_SERVE_AUTH_TOKEN` from its **Environment** tab.
|
||||
3. Load the site and paste the token into the prompt (or the settings panel).
|
||||
|
||||
Every `/api/*` request carries that token as a header, and the proxy answers `401` without it. The browser keeps it in `sessionStorage`, so a new tab asks again. To rotate it, edit the environment variable and redeploy.
|
||||
|
||||
The proxy strips `Origin` before forwarding, so the server's CSRF guard does nothing for proxied traffic; it passes `Origin`-less requests through by design. The token is the only control on this deploy, not a second layer behind the guard. Anyone holding it can read every indexed repo. See [SECURITY.md](SECURITY.md#hosted-deploys-on-render).
|
||||
|
||||
Indexing is memory-bound. If `gitnexus-server` runs out of memory on a large repo, raise its `plan`, which sets available RAM: `standard` is 2 GB, `pro` is 4 GB. Raise `sizeGB` only if the disk fills with clones and indexes.
|
||||
|
||||
## Two Ways to Use GitNexus
|
||||
|
||||
| | **CLI + MCP** (recommended) | **Web UI** |
|
||||
|
|
|
|||
10
RUNBOOK.md
10
RUNBOOK.md
|
|
@ -66,6 +66,16 @@ npx gitnexus analyze
|
|||
|
||||
No `--embeddings` flag needed — a retained checkpoint forces embedding generation for the pending nodes regardless of flags, and clears once they succeed. `--drop-embeddings` abandons the pending nodes instead of retrying them; `--force` also discards the checkpoint (with a warning) and rebuilds without resuming it.
|
||||
|
||||
**Collapsed graph write (analyze exits NON-ZERO and says INCOMPLETE):** A run can finish writing metadata while only a fraction of the relationships it produced are readable back from the index — edges collapsing to a small share of what was built, or a `CodeRelation` table that never materialized (which reads as a persisted count of zero). Because the metadata IS written and the DB does hold rows, nothing looks broken: queries answer with missing edges rather than an error, which is a confident empty answer rather than a failure. `npx gitnexus status` reports `incompleteReasons: ["graph-write-collapsed"]`, the analyze summary prints `Repository indexed INCOMPLETELY` with the expected and persisted counts, and the CLI exits non-zero so automation is not told an unusable index is fine.
|
||||
|
||||
Recovery is a full rebuild:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze --force
|
||||
```
|
||||
|
||||
If it recurs, the cause is almost always environmental rather than a code defect: check free disk space on the volume holding `.gitnexus/`, make sure no second `analyze` is running against the same repo (both use `.gitnexus/csv` for staging), then run `npx gitnexus doctor`. The check compares in-memory relationship totals (including streamed rows) against what the DB hands back, and is deliberately skipped on incremental runs, where the two counts are not comparable.
|
||||
|
||||
**Large repos:** Analyze may skip or limit embedding work when node counts are very high; watch CLI output.
|
||||
|
||||
---
|
||||
|
|
|
|||
13
SECURITY.md
13
SECURITY.md
|
|
@ -51,6 +51,19 @@ If you fork GitNexus or self-host it, we recommend enabling the following in you
|
|||
- **Secret scanning** and **Push protection** — blocks pushes that introduce known secret patterns. Defense-in-depth on top of the in-CI Gitleaks scan documented below.
|
||||
- **Code scanning** — surfaces SARIF results from CodeQL, Trivy, Scorecard, and zizmor in one place.
|
||||
|
||||
### Hosted Deploys on Render
|
||||
|
||||
The `render.yaml` Blueprint (see the README's **Deploy to Render**) puts `gitnexus serve` on a **private service** with no public URL, and a public web service in front of it that reverse-proxies `/api/*`. What that does and does not protect:
|
||||
|
||||
- **The web service is public and its URL is discoverable.** `onrender.com` hostnames appear in certificate transparency logs. Treat the URL as known rather than secret.
|
||||
- **The generated `GITNEXUS_SERVE_AUTH_TOKEN` is the only access control.** The proxy rejects any `/api/*` request without it with a `401` before forwarding. Rotate it by editing the environment variable on the `gitnexus-web` service and redeploying.
|
||||
- **The CSRF guard is inert on this path.** The proxy strips `Origin` before forwarding, so the server's write-origin guard does nothing for proxied traffic — it passes `Origin`-less requests through by design. The token is not a second layer behind the guard.
|
||||
- **Anyone holding the token can read every indexed repo's source.** These routes carry no origin guard, and the first three carry no rate limiter either: `GET /api/repos`, `GET /api/graph`, `POST /api/query`, `GET /api/file`, `GET /api/grep`. Whoever has the token can also index and delete repositories.
|
||||
- **`POST /api/mcp` rides the same path.** `serve` mounts the MCP handler via `mountMCPEndpoints`, and `createStreamableHttpHandler` is called with no `authToken` — a **pre-existing** gap in `serve` itself, not something this deploy introduces. On Render it is closed only by the edge token and the private network. A `serve` bound directly to a public interface has no such cover.
|
||||
- **Rate limits bound cost, not access.** They cap what a token holder can spend; they do not decide who gets in.
|
||||
|
||||
Do not hand the URL out as a public demo. A token holder has read access to everything the deploy has indexed.
|
||||
|
||||
## Automated Scans Running in CI
|
||||
|
||||
This repository runs the following scans automatically. Findings appear under the repository's **Security → Code scanning** tab.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { timingSafeEqual } from 'node:crypto';
|
||||
import { writeSync } from 'node:fs';
|
||||
import { open } from 'node:fs/promises';
|
||||
import { createServer } from 'node:http';
|
||||
import { createServer, request as httpRequest } from 'node:http';
|
||||
import { request as httpsRequest } from 'node:https';
|
||||
import { extname, isAbsolute, normalize, relative, resolve, sep } from 'node:path';
|
||||
|
||||
const host = '0.0.0.0';
|
||||
|
|
@ -22,18 +25,430 @@ function jsonForScriptTag(obj) {
|
|||
.replace(/&/g, '\\u0026');
|
||||
}
|
||||
|
||||
const rawBackendUrl = process.env.GITNEXUS_BACKEND_URL ?? null;
|
||||
if (rawBackendUrl && !isValidUrl(rawBackendUrl)) {
|
||||
const safeRaw = rawBackendUrl.replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 200);
|
||||
console.warn(
|
||||
`[gitnexus-web] GITNEXUS_BACKEND_URL "${safeRaw}" is not a valid http/https URL -- ignoring.`,
|
||||
// Warnings echo operator input back, so strip control characters (log forging)
|
||||
// and cap the length first.
|
||||
function sanitizeForLog(value) {
|
||||
return (
|
||||
String(value)
|
||||
// The line-break strip is redundant with the range below, but CodeQL's
|
||||
// js/log-injection recognizes only this shape as a sanitizer: a global
|
||||
// replace of a literal \n with the empty string.
|
||||
.replace(/\n/g, '')
|
||||
.replace(/\r/g, '')
|
||||
.replace(/[\x00-\x1f\x7f]/g, ' ')
|
||||
.slice(0, 200)
|
||||
);
|
||||
}
|
||||
const backendUrl = rawBackendUrl && isValidUrl(rawBackendUrl) ? rawBackendUrl : null;
|
||||
|
||||
// console.error is asynchronous when stderr is a pipe, so pairing it with
|
||||
// process.exit can drop the one message explaining the refusal. writeSync isn't.
|
||||
function exitWithRefusal(message) {
|
||||
writeSync(2, `${message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// `value` if it's a usable http/https URL, else null + a warning naming `label`.
|
||||
// `rawForLog` lets a caller that normalized first echo back the operator's input.
|
||||
function validHttpUrl(label, value, rawForLog = value) {
|
||||
if (!value) return null;
|
||||
if (isValidUrl(value)) return value;
|
||||
const safeRaw = sanitizeForLog(rawForLog);
|
||||
console.warn(`[gitnexus-web] ${label} "${safeRaw}" is not a valid http/https URL -- ignoring.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Numeric env var. Every consumer below reads <= 0 as "disabled", so obeying a
|
||||
// typo like -1 would switch a timeout off silently. Warn and use the default.
|
||||
function numberFromEnv(label, fallback, min = 0) {
|
||||
const raw = process.env[label];
|
||||
if (raw === undefined || raw === '') return fallback;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n)) {
|
||||
console.warn(
|
||||
`[gitnexus-web] ${label} "${sanitizeForLog(raw)}" is not a number -- using ${fallback}.`,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
if (n < min) {
|
||||
console.warn(
|
||||
`[gitnexus-web] ${label} "${sanitizeForLog(raw)}" is below the minimum ${min} -- using ${fallback}.`,
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// Falls back to RENDER_EXTERNAL_URL so a Render web service hands the browser
|
||||
// its own public origin — same-origin API calls via the proxy below, no config.
|
||||
const backendUrlVar =
|
||||
process.env.GITNEXUS_BACKEND_URL !== undefined ? 'GITNEXUS_BACKEND_URL' : 'RENDER_EXTERNAL_URL';
|
||||
const rawBackendUrl = process.env.GITNEXUS_BACKEND_URL ?? process.env.RENDER_EXTERNAL_URL ?? null;
|
||||
const backendUrl = validHttpUrl(backendUrlVar, rawBackendUrl);
|
||||
const configScript = backendUrl
|
||||
? `<script>window.__GITNEXUS_CONFIG__=${jsonForScriptTag({ backendUrl })};</script>`
|
||||
: '';
|
||||
|
||||
// Optional same-origin reverse proxy for the API server. On a split deploy
|
||||
// (public web service, private API) the browser must reach the API without a
|
||||
// cross-origin request, since its CORS allowlist and write-route guard only
|
||||
// admit same-host origins. So the browser targets THIS origin and we forward
|
||||
// /api/* to GITNEXUS_UPSTREAM_URL. Unset → no proxy (docker-compose default).
|
||||
// A scheme-less host:port — what Render's `fromService: hostport` yields —
|
||||
// gets http:// prepended.
|
||||
const rawUpstream = process.env.GITNEXUS_UPSTREAM_URL;
|
||||
const rawUpstreamUrl = rawUpstream
|
||||
? /^https?:\/\//.test(rawUpstream)
|
||||
? rawUpstream
|
||||
: `http://${rawUpstream}`
|
||||
: null;
|
||||
const upstreamBase = validHttpUrl('GITNEXUS_UPSTREAM_URL', rawUpstreamUrl, rawUpstream);
|
||||
// The one origin this proxy will ever connect to (see proxyToUpstream).
|
||||
const upstreamOrigin = upstreamBase ? new URL(upstreamBase).origin : null;
|
||||
|
||||
// The Bearer token every /api/* request must carry. The private upstream has no
|
||||
// auth of its own and loses its Origin guard one hop below (see
|
||||
// proxyToUpstream), so the gate belongs here. The browser holds it — never
|
||||
// inject it next to `backendUrl`. Blank-is-absent follows resolveAuthToken
|
||||
// (gitnexus/src/mcp/http-transport.ts).
|
||||
const authToken = process.env.GITNEXUS_SERVE_AUTH_TOKEN?.trim() || null;
|
||||
|
||||
// Mirrors the non-loopback refusal in http-transport.ts (startMcpHttpServer),
|
||||
// relocated because the trust boundary is here: an unguarded `serve` behind a
|
||||
// private service is legitimate, an unguarded public proxy is not.
|
||||
if (upstreamBase && !authToken) {
|
||||
exitWithRefusal(
|
||||
'[gitnexus-web] Refusing to start: GITNEXUS_UPSTREAM_URL is set without ' +
|
||||
'GITNEXUS_SERVE_AUTH_TOKEN. The proxy would expose every indexed repo — ' +
|
||||
'index, read source, and delete — to anyone with this URL. Set a token, ' +
|
||||
'or unset GITNEXUS_UPSTREAM_URL to serve static assets only.',
|
||||
);
|
||||
}
|
||||
|
||||
// Rejected requests never reach the upstream limiter, so guesses are free. A
|
||||
// throttle would add per-address state to a stateless proxy and a lockout an
|
||||
// attacker can aim at a real user; a length floor makes guessing hopeless and
|
||||
// only ever rejects a hand-picked token.
|
||||
const MIN_AUTH_TOKEN_LENGTH = 32;
|
||||
if (authToken && authToken.length < MIN_AUTH_TOKEN_LENGTH) {
|
||||
exitWithRefusal(
|
||||
`[gitnexus-web] Refusing to start: GITNEXUS_SERVE_AUTH_TOKEN is shorter than ` +
|
||||
`${MIN_AUTH_TOKEN_LENGTH} characters. It is the only thing standing between the ` +
|
||||
'public internet and every indexed repo, and a failed guess is not rate-limited. ' +
|
||||
'Use a generated random value.',
|
||||
);
|
||||
}
|
||||
|
||||
// Whether an inbound X-Forwarded-For may be believed (see clientAddressFor).
|
||||
// Default off, so a wrong deployment fails toward over-restriction rather than
|
||||
// toward an address the caller picks. `true` is rejected as it is server-side
|
||||
// (resolveTrustProxy, which also takes hop counts and so rejects `yes`/`on`
|
||||
// too): it reads as "trust the whole chain".
|
||||
function resolveTrustXff(raw) {
|
||||
const value = raw?.trim();
|
||||
if (!value) return false;
|
||||
if (/^(1|yes|on)$/i.test(value)) return true;
|
||||
if (/^(0|no|off|false)$/i.test(value)) return false;
|
||||
console.warn(
|
||||
`[gitnexus-web] GITNEXUS_PROXY_TRUST_XFF "${sanitizeForLog(value)}" is not a recognized ` +
|
||||
'boolean -- ignoring the inbound X-Forwarded-For chain. Set 1 only when a load balancer ' +
|
||||
'that appends the real peer sits in front of this service.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const trustInboundXff = resolveTrustXff(process.env.GITNEXUS_PROXY_TRUST_XFF);
|
||||
|
||||
// Idle timeout for a proxied request → 504. Socket activity (SSE heartbeats)
|
||||
// resets it, so long-lived streams are unaffected. 0 disables.
|
||||
const proxyTimeoutMs = numberFromEnv('GITNEXUS_PROXY_TIMEOUT_MS', 120000);
|
||||
|
||||
// nginx's client_body_timeout equivalent: how long to wait for a replayable
|
||||
// client body before 400. Defaults to the idle timeout; 0 disables.
|
||||
const proxyClientBodyTimeoutMs = numberFromEnv(
|
||||
'GITNEXUS_PROXY_CLIENT_BODY_TIMEOUT_MS',
|
||||
proxyTimeoutMs,
|
||||
);
|
||||
|
||||
// Bounded connection-retry, to ride out the few-second window where a
|
||||
// single-instance upstream (private server + disk ⇒ no zero-downtime deploy)
|
||||
// is restarting. Attempts of 1 disables it, and body buffering with it.
|
||||
const proxyRetryAttempts = numberFromEnv('GITNEXUS_PROXY_RETRY_ATTEMPTS', 3, 1);
|
||||
const proxyRetryEnabled = proxyRetryAttempts > 1;
|
||||
const proxyRetryMaxBodyBytes = numberFromEnv('GITNEXUS_PROXY_RETRY_MAX_BODY_BYTES', 256 * 1024);
|
||||
// Never connected ⇒ the upstream got nothing ⇒ safe to replay any method.
|
||||
const preConnectRetryCodes = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN']);
|
||||
// Failed after connecting ⇒ the upstream may already be working on it, so
|
||||
// replay only idempotent methods (RFC 7231 §4.2.2) to avoid double-execution.
|
||||
const postConnectRetryCodes = new Set(['ECONNRESET', 'ETIMEDOUT']);
|
||||
const idempotentMethods = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE']);
|
||||
|
||||
// Buffer a request body, capped. Resolves null on overflow, client error, or
|
||||
// timeout — one "unreadable body" contract, which the caller maps to 400.
|
||||
// Listeners detach once settled so a later pipe of the same request is clean.
|
||||
function readBodyCapped(req, cap, timeoutMs) {
|
||||
return new Promise((resolvePromise) => {
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
let settled = false;
|
||||
let timer = null;
|
||||
const cleanup = () => {
|
||||
if (timer) clearTimeout(timer);
|
||||
req.removeListener('data', onData);
|
||||
req.removeListener('end', onEnd);
|
||||
req.removeListener('error', onError);
|
||||
};
|
||||
const finish = (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolvePromise(value);
|
||||
};
|
||||
const onData = (chunk) => {
|
||||
total += chunk.length;
|
||||
if (total > cap) {
|
||||
finish(null);
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
};
|
||||
const onEnd = () => finish(Buffer.concat(chunks));
|
||||
const onError = () => finish(null);
|
||||
req.on('data', onData);
|
||||
req.on('end', onEnd);
|
||||
req.on('error', onError);
|
||||
// Hard cap regardless of idle activity; Node's requestTimeout is the outer
|
||||
// backstop.
|
||||
if (timeoutMs > 0) {
|
||||
timer = setTimeout(() => {
|
||||
console.warn(`[gitnexus-web] client body read timed out after ${timeoutMs}ms`);
|
||||
finish(null);
|
||||
}, timeoutMs);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Constant-time Bearer check, mirroring createAuthMiddleware in
|
||||
// gitnexus/src/mcp/http-transport.ts — dummy comparison included, so an absent
|
||||
// or wrong-length header costs the same and the timing can't leak the length.
|
||||
// Duplicated because this file is plain ESM and can't import from gitnexus/src.
|
||||
function authorized(req) {
|
||||
if (!authToken) return true; // static-only: no proxy, nothing to gate
|
||||
const header = req.headers['authorization'];
|
||||
const expected = Buffer.from(`Bearer ${authToken}`);
|
||||
if (typeof header !== 'string') {
|
||||
timingSafeEqual(Buffer.alloc(expected.length), expected);
|
||||
return false;
|
||||
}
|
||||
const provided = Buffer.from(header);
|
||||
if (provided.length !== expected.length) {
|
||||
timingSafeEqual(Buffer.alloc(expected.length), expected);
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(provided, expected);
|
||||
}
|
||||
|
||||
// WWW-Authenticate names the scheme; the stable `code` is what the web client
|
||||
// dispatches on, not message text. The body must not distinguish "no token
|
||||
// configured" from "wrong token". `Connection: close` because we answer before
|
||||
// reading the body, which Node would otherwise drain (as with the 400 below).
|
||||
function sendUnauthorized(res) {
|
||||
const body = JSON.stringify({ error: 'unauthorized', code: 'unauthorized' });
|
||||
res.writeHead(401, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
'WWW-Authenticate': 'Bearer',
|
||||
Connection: 'close',
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
// Fail a proxied request. Once headers are sent the body is partially written
|
||||
// and can't be replaced, so the socket is all we can destroy.
|
||||
function failGateway(res, status, message) {
|
||||
if (res.headersSent) {
|
||||
res.destroy();
|
||||
} else {
|
||||
res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Hop-by-hop headers (RFC 7230 §6.1) describe one connection, so a proxy must
|
||||
// not forward them in either direction; Node sets its own per hop.
|
||||
const hopByHopHeaders = [
|
||||
'connection',
|
||||
'keep-alive',
|
||||
'proxy-authenticate',
|
||||
'proxy-authorization',
|
||||
'te',
|
||||
'trailer',
|
||||
'transfer-encoding',
|
||||
'upgrade',
|
||||
];
|
||||
|
||||
function stripHopByHopHeaders(headers) {
|
||||
// §6.1 also lets `Connection` name additional single-hop headers, which the
|
||||
// fixed list below can't cover. Node lowercases header keys on both the
|
||||
// server and client side, so a lowercased name indexes `headers` directly.
|
||||
for (const listed of String(headers.connection ?? '').split(',')) {
|
||||
const name = listed.trim().toLowerCase();
|
||||
if (name) delete headers[name];
|
||||
}
|
||||
for (const name of hopByHopHeaders) delete headers[name];
|
||||
return headers;
|
||||
}
|
||||
|
||||
// The client address this proxy vouches for upstream. The API keys its rate
|
||||
// limiter off req.ip, so forwarding a client-supplied X-Forwarded-For would let
|
||||
// anyone rotate a fake address per request. Which entry is real depends on a
|
||||
// deployment fact this process can't observe (is anything in front appending the
|
||||
// peer?), so the operator asserts it via GITNEXUS_PROXY_TRUST_XFF; until then we
|
||||
// forward the socket peer.
|
||||
function clientAddressFor(req) {
|
||||
if (!trustInboundXff) return req.socket.remoteAddress || null;
|
||||
const forwarded = String(req.headers['x-forwarded-for'] ?? '')
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
return forwarded || req.socket.remoteAddress || null;
|
||||
}
|
||||
|
||||
// Forward an `/api/*` request upstream, streaming both bodies (SSE / chunked
|
||||
// graph streams) untouched. Retries connect failures when the body is replayable.
|
||||
async function proxyToUpstream(req, res) {
|
||||
let upstream;
|
||||
try {
|
||||
upstream = new URL(req.url, upstreamBase);
|
||||
} catch {
|
||||
res.writeHead(400);
|
||||
res.end('Bad request');
|
||||
return;
|
||||
}
|
||||
// The `/api/` route guard keeps req.url host-relative, so resolution can't
|
||||
// leave upstreamBase. Asserting it here means the SSRF boundary doesn't rest
|
||||
// on that two-step argument: one legitimate destination, checked locally.
|
||||
if (upstream.origin !== upstreamOrigin) {
|
||||
console.error(`[gitnexus-web] refusing to proxy off-origin target ${upstream.origin}`);
|
||||
res.writeHead(400);
|
||||
res.end('Bad request');
|
||||
return;
|
||||
}
|
||||
const isHttps = upstream.protocol === 'https:';
|
||||
const requestFn = isHttps ? httpsRequest : httpRequest;
|
||||
const headers = stripHopByHopHeaders({ ...req.headers });
|
||||
// Terminate the browser origin: the API admits Origin-less requests as
|
||||
// trusted server-to-server calls. Nothing is lost — the browser only ever
|
||||
// talks to this same-origin web service.
|
||||
delete headers.origin;
|
||||
delete headers.referer;
|
||||
// The edge token is spent here. `serve` reads no Authorization header
|
||||
// (gitnexus/src/server/mcp-http.ts mounts /api/mcp unguarded), so forwarding
|
||||
// it would only copy a live credential into another service's logs. Pinned by
|
||||
// test.
|
||||
delete headers.authorization;
|
||||
headers.host = upstream.host;
|
||||
// Replace, never forward, the inbound chain (see clientAddressFor).
|
||||
const clientAddress = clientAddressFor(req);
|
||||
if (clientAddress) headers['x-forwarded-for'] = clientAddress;
|
||||
else delete headers['x-forwarded-for'];
|
||||
|
||||
// A retry replays the body, so buffer it up front — but only when small and
|
||||
// of known length. Larger/unknown bodies (multipart uploads) stream once with
|
||||
// no retry; an upload is never buffered.
|
||||
const method = (req.method || 'GET').toUpperCase();
|
||||
const isIdempotentMethod = idempotentMethods.has(method);
|
||||
// A request has a body iff it frames one (RFC 7230 §3.3.3). Keying off the
|
||||
// method sends a bodyless DELETE down the stream-once path and gives up a
|
||||
// replay that costs nothing.
|
||||
const hasBody =
|
||||
req.headers['content-length'] !== undefined || req.headers['transfer-encoding'] !== undefined;
|
||||
const len = Number(req.headers['content-length']);
|
||||
const bufferable =
|
||||
proxyRetryEnabled && Number.isFinite(len) && len >= 0 && len <= proxyRetryMaxBodyBytes;
|
||||
let bodyBuf = hasBody ? null : Buffer.alloc(0);
|
||||
if (hasBody && bufferable) {
|
||||
bodyBuf = await readBodyCapped(req, proxyRetryMaxBodyBytes, proxyClientBodyTimeoutMs);
|
||||
if (bodyBuf === null) {
|
||||
// Overflow, client error, and timeout all collapse to 400 (not 413/408).
|
||||
// `Connection: close` lets Node drop the socket after the 400 flushes,
|
||||
// rather than half-open draining a stalled upload until requestTimeout.
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(400, {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
Connection: 'close',
|
||||
});
|
||||
res.end('Bad request');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
// bodyBuf === null means "stream the live request once, no retry".
|
||||
const retryEligible = bodyBuf !== null;
|
||||
|
||||
const attempt = (n) => {
|
||||
let timedOut = false;
|
||||
const upstreamReq = requestFn(
|
||||
{
|
||||
protocol: upstream.protocol,
|
||||
hostname: upstream.hostname,
|
||||
port: upstream.port || (isHttps ? 443 : 80),
|
||||
method: req.method,
|
||||
path: upstream.pathname + upstream.search,
|
||||
headers,
|
||||
},
|
||||
(upstreamRes) => {
|
||||
// Pipe rather than buffer, so SSE / chunked streams reach the browser
|
||||
// incrementally. Node re-derives Transfer-Encoding for this hop.
|
||||
const responseHeaders = stripHopByHopHeaders({ ...upstreamRes.headers });
|
||||
res.writeHead(upstreamRes.statusCode || 502, responseHeaders);
|
||||
upstreamRes.on('error', () => res.destroy());
|
||||
upstreamRes.pipe(res);
|
||||
},
|
||||
);
|
||||
upstreamReq.on('error', (err) => {
|
||||
if (timedOut) return; // 504 already sent by the timeout handler below
|
||||
// Only before any response byte reaches the browser — once headers are
|
||||
// sent the body is partially written and can't be replayed.
|
||||
const retryableError =
|
||||
preConnectRetryCodes.has(err.code) ||
|
||||
(isIdempotentMethod && postConnectRetryCodes.has(err.code));
|
||||
if (retryEligible && !res.headersSent && n < proxyRetryAttempts && retryableError) {
|
||||
const delay = 250 * 2 ** (n - 1); // 250ms, 500ms, ...
|
||||
console.warn(
|
||||
`[gitnexus-web] upstream ${sanitizeForLog(err.code)}; retry ${n}/${proxyRetryAttempts - 1} in ${delay}ms`,
|
||||
);
|
||||
setTimeout(() => {
|
||||
// The client may have aborted during the backoff window; don't fire a
|
||||
// fresh upstream request nobody is waiting for anymore.
|
||||
if (res.writableEnded || res.destroyed) return;
|
||||
attempt(n + 1);
|
||||
}, delay);
|
||||
return;
|
||||
}
|
||||
console.error('[gitnexus-web] upstream proxy error:', sanitizeForLog(err.message));
|
||||
failGateway(res, 502, 'Bad gateway');
|
||||
});
|
||||
if (proxyTimeoutMs > 0) {
|
||||
upstreamReq.setTimeout(proxyTimeoutMs, () => {
|
||||
timedOut = true;
|
||||
console.error(`[gitnexus-web] upstream proxy timeout after ${proxyTimeoutMs}ms`);
|
||||
failGateway(res, 504, 'Gateway timeout');
|
||||
upstreamReq.destroy();
|
||||
});
|
||||
}
|
||||
if (bodyBuf !== null) {
|
||||
// Replayable body already buffered; write it fresh on each attempt.
|
||||
if (bodyBuf.length) upstreamReq.write(bodyBuf);
|
||||
upstreamReq.end();
|
||||
} else {
|
||||
// Non-retryable: stream the live request once.
|
||||
req.on('error', () => upstreamReq.destroy());
|
||||
req.pipe(upstreamReq);
|
||||
}
|
||||
};
|
||||
attempt(1);
|
||||
}
|
||||
|
||||
const contentTypes = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
|
|
@ -68,6 +483,23 @@ const spaFallback = resolve(root, 'index.html');
|
|||
const server = createServer(async (req, res) => {
|
||||
const urlPath = req.url?.split('?')[0] || '/';
|
||||
|
||||
// Same-origin API proxy; everything else falls through to the SPA below.
|
||||
if (upstreamBase && (urlPath === '/api' || urlPath.startsWith('/api/'))) {
|
||||
// Before body buffering and the upstream socket, so an unauthenticated
|
||||
// request costs nothing upstream. Static assets are never gated: the UI has
|
||||
// to load in order to prompt for the token.
|
||||
if (!authorized(req)) {
|
||||
sendUnauthorized(res);
|
||||
return;
|
||||
}
|
||||
// Fire-and-forget, so guard the boundary against unhandledRejection.
|
||||
proxyToUpstream(req, res).catch((err) => {
|
||||
console.error('[gitnexus-web] proxy handler crashed:', sanitizeForLog(err?.message ?? err));
|
||||
failGateway(res, 502, 'Bad gateway');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = decodeURIComponent(urlPath);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { mkdir, mkdtemp, rm, unlink, writeFile } from 'node:fs/promises';
|
||||
import { connect } from 'node:net';
|
||||
import http, { createServer } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
|
@ -263,3 +264,825 @@ it('does not inject config into static assets', async () => {
|
|||
assert.equal(res.body, 'body{}');
|
||||
});
|
||||
});
|
||||
// -- API reverse proxy (GITNEXUS_UPSTREAM_URL) -----------------------------
|
||||
|
||||
// Every proxy fixture below runs the server with this token: the proxy refuses
|
||||
// to start without one, and refuses one under 32 characters.
|
||||
const TEST_AUTH_TOKEN = 'proxy-test-token-0123456789abcdefghij';
|
||||
const TEST_BEARER = `Bearer ${TEST_AUTH_TOKEN}`;
|
||||
|
||||
// rawRequest never sends credentials; apiRequest does. In a file whose subject
|
||||
// is who gets let through, no test should pass because a helper quietly
|
||||
// authenticated for it.
|
||||
function rawRequest(port, path, { method = 'GET', headers = {}, body } = {}) {
|
||||
// Send an explicit Content-Length like a browser fetch() does — the proxy
|
||||
// only buffers (and so only retries) bodies of known length.
|
||||
const outHeaders = { ...headers };
|
||||
if (
|
||||
body !== undefined &&
|
||||
!Object.keys(outHeaders).some((h) => h.toLowerCase() === 'content-length')
|
||||
) {
|
||||
outHeaders['content-length'] = String(Buffer.byteLength(body));
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{ host: '127.0.0.1', port, path, method, headers: outHeaders },
|
||||
(res) => {
|
||||
let respBody = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk) => {
|
||||
respBody += chunk;
|
||||
});
|
||||
res.on('end', () =>
|
||||
resolve({ status: res.statusCode, headers: res.headers, body: respBody }),
|
||||
);
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
if (body !== undefined) req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// An authenticated /api/* call. An explicit `authorization` header wins, so the
|
||||
// auth tests can send a wrong one.
|
||||
function apiRequest(port, path, { headers = {}, ...rest } = {}) {
|
||||
const hasAuth = Object.keys(headers).some((h) => h.toLowerCase() === 'authorization');
|
||||
return rawRequest(port, path, {
|
||||
...rest,
|
||||
headers: hasAuth ? headers : { ...headers, authorization: TEST_BEARER },
|
||||
});
|
||||
}
|
||||
|
||||
const respondOk = (_req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end('{"ok":true}');
|
||||
};
|
||||
|
||||
// Every proxy test needs the same four parts: a dist/ to serve, a fake upstream,
|
||||
// a docker-server pointed at it, and teardown that leaks neither a process nor a
|
||||
// temp dir. They differ only in how the upstream misbehaves.
|
||||
//
|
||||
// upstream request handler, replaceable mid-test via `ctx.handler`;
|
||||
// null points the proxy at a port nothing ever listens on
|
||||
// listenAfterMs bind the upstream this late, so the first attempt(s) hit
|
||||
// ECONNREFUSED (a single-instance restart window)
|
||||
// schemeless drop http:// from GITNEXUS_UPSTREAM_URL, the way Render's
|
||||
// `fromService: { property: hostport }` yields it
|
||||
// env extra environment for docker-server.mjs
|
||||
//
|
||||
// `ctx` collects what the upstream saw (calls, last request, last body) plus the
|
||||
// proxy's stderr, so assertions read off one object.
|
||||
async function withProxy(
|
||||
{ upstream = respondOk, listenAfterMs = 0, schemeless = false, env = {} } = {},
|
||||
fn,
|
||||
) {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'gitnexus-proxy-'));
|
||||
await mkdir(join(dir, 'dist'), { recursive: true });
|
||||
await writeFile(join(dir, 'dist', 'index.html'), '<html><body>spa</body></html>');
|
||||
|
||||
const ctx = { calls: 0, received: null, body: null, stderr: '', handler: upstream };
|
||||
// Read the forwarded request to completion before handing it to the handler,
|
||||
// so no test has to repeat that plumbing to assert on headers or body.
|
||||
const server = upstream
|
||||
? createServer((req, res) => {
|
||||
let body = '';
|
||||
req.setEncoding('utf8');
|
||||
req.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on('end', () => {
|
||||
ctx.calls += 1;
|
||||
ctx.body = body;
|
||||
ctx.received = { method: req.method, url: req.url, headers: req.headers, body };
|
||||
ctx.handler(req, res);
|
||||
});
|
||||
})
|
||||
: null;
|
||||
|
||||
// A late (or never) bind needs its port reserved up front; otherwise let the
|
||||
// OS assign one at listen time.
|
||||
const upstreamPort =
|
||||
server && listenAfterMs === 0
|
||||
? await new Promise((r) => server.listen(0, '127.0.0.1', () => r(server.address().port)))
|
||||
: await getFreePort();
|
||||
const bindTimer =
|
||||
server && listenAfterMs > 0
|
||||
? setTimeout(() => server.listen(upstreamPort, '127.0.0.1'), listenAfterMs)
|
||||
: null;
|
||||
|
||||
const port = await getFreePort();
|
||||
const target = `127.0.0.1:${upstreamPort}`;
|
||||
const proc = spawnServerWithEnv(dir, port, {
|
||||
GITNEXUS_UPSTREAM_URL: schemeless ? target : `http://${target}`,
|
||||
GITNEXUS_SERVE_AUTH_TOKEN: TEST_AUTH_TOKEN,
|
||||
...env,
|
||||
});
|
||||
proc.stderr.setEncoding('utf8');
|
||||
proc.stderr.on('data', (chunk) => {
|
||||
ctx.stderr += chunk;
|
||||
});
|
||||
try {
|
||||
await waitForServer(port);
|
||||
await fn(port, ctx);
|
||||
} finally {
|
||||
if (bindTimer) clearTimeout(bindTimer);
|
||||
await killAndWait(proc);
|
||||
if (server?.listening) {
|
||||
server.closeAllConnections?.();
|
||||
await new Promise((r) => server.close(r));
|
||||
}
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
it('proxies /api/* requests to the upstream server', async () => {
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
const res = await apiRequest(port, '/api/info?x=1');
|
||||
assert.equal(res.status, 200);
|
||||
assert.match(res.body, /"ok":true/);
|
||||
assert.equal(ctx.received.url, '/api/info?x=1', 'path + query forwarded verbatim');
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards the request method and body to the upstream', async () => {
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
await apiRequest(port, '/api/query', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{"q":"hello"}',
|
||||
});
|
||||
assert.equal(ctx.received.method, 'POST');
|
||||
assert.equal(ctx.received.body, '{"q":"hello"}');
|
||||
});
|
||||
});
|
||||
|
||||
it('strips the browser Origin and Referer before forwarding to the API', async () => {
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
await apiRequest(port, '/api/info', {
|
||||
headers: { origin: 'https://gitnexus-web.onrender.com', referer: 'https://x/y' },
|
||||
});
|
||||
assert.equal(
|
||||
ctx.received.headers.origin,
|
||||
undefined,
|
||||
'Origin must be stripped so the API treats it as a trusted server-to-server call',
|
||||
);
|
||||
assert.equal(ctx.received.headers.referer, undefined, 'Referer must be stripped');
|
||||
});
|
||||
});
|
||||
|
||||
it('strips hop-by-hop headers before forwarding to the API', async () => {
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
await apiRequest(port, '/api/info', {
|
||||
headers: {
|
||||
'keep-alive': 'timeout=5',
|
||||
upgrade: 'h2c',
|
||||
'proxy-authorization': 'Basic abc',
|
||||
te: 'trailers',
|
||||
},
|
||||
});
|
||||
assert.equal(ctx.received.headers['keep-alive'], undefined);
|
||||
assert.equal(ctx.received.headers.upgrade, undefined);
|
||||
assert.equal(ctx.received.headers['proxy-authorization'], undefined);
|
||||
assert.equal(ctx.received.headers.te, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it('strips request headers that Connection names as single-hop', async () => {
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
// RFC 7230 §6.1 lets Connection name hop-by-hop headers beyond the
|
||||
// well-known eight, and those must not be forwarded either. Against a fixed
|
||||
// list alone, x-custom-hop reaches the upstream.
|
||||
await apiRequest(port, '/api/info', {
|
||||
headers: { connection: 'x-custom-hop', 'x-custom-hop': 'private' },
|
||||
});
|
||||
assert.equal(ctx.received.headers['x-custom-hop'], undefined);
|
||||
// Connection itself is always re-derived by Node for the upstream hop, so
|
||||
// assert the client's value didn't survive rather than that it's absent.
|
||||
assert.notEqual(ctx.received.headers.connection, 'x-custom-hop');
|
||||
});
|
||||
});
|
||||
|
||||
it('collapses a spoofed X-Forwarded-For chain to the load balancer entry when XFF is trusted', async () => {
|
||||
const env = { GITNEXUS_PROXY_TRUST_XFF: '1' };
|
||||
await withProxy({ env }, async (port, ctx) => {
|
||||
// With a load balancer in front, only the last entry is the LB's; the rest
|
||||
// is client-supplied and would otherwise let a caller fake req.ip and evade
|
||||
// the API's rate limits.
|
||||
await apiRequest(port, '/api/info', {
|
||||
headers: { 'x-forwarded-for': '10.0.0.1, 1.2.3.4, 203.0.113.9' },
|
||||
});
|
||||
assert.equal(ctx.received.headers['x-forwarded-for'], '203.0.113.9');
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores an inbound X-Forwarded-For chain when GITNEXUS_PROXY_TRUST_XFF is unset', async () => {
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
// With nothing in front of the proxy, the whole chain is the caller's to
|
||||
// write, so popping it would forward an address they chose.
|
||||
await apiRequest(port, '/api/info', {
|
||||
headers: { 'x-forwarded-for': '10.0.0.1, 1.2.3.4, 203.0.113.9' },
|
||||
});
|
||||
assert.match(ctx.received.headers['x-forwarded-for'], /127\.0\.0\.1$/);
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores an inbound X-Forwarded-For chain when GITNEXUS_PROXY_TRUST_XFF is off', async () => {
|
||||
const env = { GITNEXUS_PROXY_TRUST_XFF: 'off' };
|
||||
await withProxy({ env }, async (port, ctx) => {
|
||||
await apiRequest(port, '/api/info', {
|
||||
headers: { 'x-forwarded-for': '203.0.113.9' },
|
||||
});
|
||||
assert.match(ctx.received.headers['x-forwarded-for'], /127\.0\.0\.1$/);
|
||||
});
|
||||
});
|
||||
|
||||
it('warns and falls back to ignoring XFF when GITNEXUS_PROXY_TRUST_XFF is "true"', async () => {
|
||||
// Rejected for the same reason resolveTrustProxy rejects it server-side: it
|
||||
// reads as "trust everything", the configuration this knob exists to make
|
||||
// deliberate.
|
||||
const env = { GITNEXUS_PROXY_TRUST_XFF: 'true' };
|
||||
await withProxy({ env }, async (port, ctx) => {
|
||||
await apiRequest(port, '/api/info', {
|
||||
headers: { 'x-forwarded-for': '203.0.113.9' },
|
||||
});
|
||||
assert.match(ctx.received.headers['x-forwarded-for'], /127\.0\.0\.1$/);
|
||||
assert.match(
|
||||
ctx.stderr,
|
||||
/GITNEXUS_PROXY_TRUST_XFF "true" is not a recognized boolean/,
|
||||
'an unrecognized value must warn rather than fail silently',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards the socket peer, not the rotating header, on every authenticated request', async () => {
|
||||
// A caller rotating X-Forwarded-For per request earns a fresh limiter key
|
||||
// upstream unless this proxy overwrites it. Hitting the API server directly
|
||||
// would test its own trust-proxy handling instead of this hop.
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
const forwarded = [];
|
||||
for (const spoofed of ['1.1.1.1', '2.2.2.2', '3.3.3.3', '4.4.4.4']) {
|
||||
await apiRequest(port, '/api/query', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-forwarded-for': spoofed },
|
||||
body: '{"q":"hi"}',
|
||||
});
|
||||
forwarded.push(ctx.received.headers['x-forwarded-for']);
|
||||
}
|
||||
assert.equal(ctx.calls, 4);
|
||||
for (const address of forwarded) {
|
||||
assert.match(
|
||||
address,
|
||||
/127\.0\.0\.1$/,
|
||||
'every request must key off the socket peer, not the value the client rotated',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('sets X-Forwarded-For from the socket peer when the client sends none', async () => {
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
await apiRequest(port, '/api/info');
|
||||
assert.match(
|
||||
ctx.received.headers['x-forwarded-for'],
|
||||
/127\.0\.0\.1$/,
|
||||
'the API must always see a proxy-derived client address',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('strips hop-by-hop headers from the upstream response', async () => {
|
||||
const upstream = (_req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain', Trailer: 'X-Late' });
|
||||
res.end('ok');
|
||||
};
|
||||
await withProxy({ upstream }, async (port) => {
|
||||
const res = await apiRequest(port, '/api/info');
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.trailer, undefined, 'Trailer describes the upstream hop only');
|
||||
assert.equal(res.body, 'ok');
|
||||
});
|
||||
});
|
||||
|
||||
it('strips response headers that Connection names as single-hop', async () => {
|
||||
const upstream = (_req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/plain',
|
||||
Connection: 'x-upstream-hop',
|
||||
'x-upstream-hop': 'internal',
|
||||
});
|
||||
res.end('ok');
|
||||
};
|
||||
await withProxy({ upstream }, async (port) => {
|
||||
const res = await apiRequest(port, '/api/info');
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers['x-upstream-hop'], undefined, 'named on the upstream hop only');
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT proxy non-/api routes (still serves the SPA)', async () => {
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
const res = await rawRequest(port, '/some/app/route');
|
||||
assert.equal(res.status, 200);
|
||||
assert.match(res.body, /spa/);
|
||||
assert.equal(ctx.calls, 0, 'non-/api requests must not reach the upstream');
|
||||
});
|
||||
});
|
||||
|
||||
it('streams a chunked upstream response through to the client', async () => {
|
||||
const upstream = (_req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
||||
res.write('data: one\n\n');
|
||||
setTimeout(() => {
|
||||
res.write('data: two\n\n');
|
||||
res.end();
|
||||
}, 20);
|
||||
};
|
||||
await withProxy({ upstream }, async (port) => {
|
||||
const res = await apiRequest(port, '/api/stream');
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers['content-type'], 'text/event-stream');
|
||||
assert.match(res.body, /data: one/);
|
||||
assert.match(res.body, /data: two/);
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a scheme-less host:port upstream (Render fromService hostport)', async () => {
|
||||
await withProxy({ schemeless: true }, async (port, ctx) => {
|
||||
const res = await apiRequest(port, '/api/info');
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(ctx.received.url, '/api/info', 'scheme-less upstream should still be proxied');
|
||||
});
|
||||
});
|
||||
|
||||
it('serves RENDER_EXTERNAL_URL as the backend origin when GITNEXUS_BACKEND_URL is unset', async () => {
|
||||
await withInjectionServer(
|
||||
{ RENDER_EXTERNAL_URL: 'https://gitnexus-web.onrender.com' },
|
||||
async (port) => {
|
||||
const res = await rawGet(port, '/');
|
||||
assert.equal(res.status, 200);
|
||||
// Assert on the parsed value, not a substring of the page: a bare
|
||||
// includes() would also pass if the URL appeared in a comment.
|
||||
const injected = /window\.__GITNEXUS_CONFIG__=(\{.*?\});/.exec(res.body)?.[1];
|
||||
assert.ok(injected, 'Expected __GITNEXUS_CONFIG__ in response body');
|
||||
assert.equal(JSON.parse(injected).backendUrl, 'https://gitnexus-web.onrender.com');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 504 when the upstream does not respond within the timeout', async () => {
|
||||
// Upstream accepts the connection but never responds — an idle hang.
|
||||
const env = { GITNEXUS_PROXY_TIMEOUT_MS: '300' };
|
||||
await withProxy({ upstream: () => {}, env }, async (port) => {
|
||||
const res = await apiRequest(port, '/api/info');
|
||||
assert.equal(res.status, 504);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 502 when the upstream is unreachable', async () => {
|
||||
// Retry disabled so this fails fast (the unreachable-upstream contract).
|
||||
const env = { GITNEXUS_PROXY_RETRY_ATTEMPTS: '1' };
|
||||
await withProxy({ upstream: null, env }, async (port) => {
|
||||
const res = await apiRequest(port, '/api/info');
|
||||
assert.equal(res.status, 502);
|
||||
});
|
||||
});
|
||||
|
||||
// -- Connection-retry across an upstream restart window ---------------------
|
||||
//
|
||||
// `listenAfterMs: 400` binds the upstream late, so the first attempt hits
|
||||
// ECONNREFUSED and must be retried — a single-instance restart. The default 3
|
||||
// attempts (backoff 250ms, 500ms) span ~750ms, so a retry lands after the bind.
|
||||
|
||||
it('retries a connection-refused POST and succeeds once the upstream is up', async () => {
|
||||
await withProxy({ listenAfterMs: 400 }, async (port, ctx) => {
|
||||
const res = await apiRequest(port, '/api/analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{"repo":"x"}',
|
||||
});
|
||||
assert.equal(res.status, 200, 'first attempt should ride out the restart gap');
|
||||
assert.match(res.body, /"ok":true/);
|
||||
assert.equal(ctx.calls, 1, 'upstream must run the job exactly once (no double-execute)');
|
||||
assert.equal(ctx.body, '{"repo":"x"}', 'buffered body replayed intact');
|
||||
});
|
||||
});
|
||||
|
||||
it('retries a bodyless DELETE, which frames no body to replay', async () => {
|
||||
// Retry eligibility follows RFC 7230 §3.3.3 framing. A DELETE with neither
|
||||
// Content-Length nor Transfer-Encoding has nothing to buffer, so it replays
|
||||
// safely even though it isn't a GET.
|
||||
await withProxy({ listenAfterMs: 400 }, async (port, ctx) => {
|
||||
const res = await apiRequest(port, '/api/repo', { method: 'DELETE' });
|
||||
assert.equal(res.status, 200, 'a bodyless DELETE must ride out the restart gap');
|
||||
assert.equal(ctx.calls, 1);
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the default retry budget when the knob is out of range', async () => {
|
||||
// A negative attempt count is a typo. Obeying it would turn every restart
|
||||
// window into a 502, silently.
|
||||
const env = { GITNEXUS_PROXY_RETRY_ATTEMPTS: '-1' };
|
||||
await withProxy({ listenAfterMs: 400, env }, async (port, ctx) => {
|
||||
const res = await apiRequest(port, '/api/info');
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(ctx.calls, 1);
|
||||
});
|
||||
});
|
||||
|
||||
it('warns and keeps the default when a timeout knob is negative', async () => {
|
||||
const env = { GITNEXUS_PROXY_TIMEOUT_MS: '-1' };
|
||||
await withProxy({ upstream: null, env }, async (_port, ctx) => {
|
||||
// Every consumer reads <= 0 as "disabled", so an unvalidated -1 removes the
|
||||
// idle timeout and lets a proxied request hang forever.
|
||||
assert.match(
|
||||
ctx.stderr,
|
||||
/GITNEXUS_PROXY_TIMEOUT_MS "-1" is below the minimum 0 -- using 120000/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT retry after the client aborts during the backoff window', async () => {
|
||||
// The client aborts (~100ms) while a retry is pending, before the upstream
|
||||
// binds (~400ms). The backoff guard must cancel it — otherwise the retry
|
||||
// lands after the bind and runs a job nobody is waiting on.
|
||||
await withProxy({ listenAfterMs: 400 }, async (port, ctx) => {
|
||||
await new Promise((resolve) => {
|
||||
const req = http.request({
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path: '/api/analyze',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'content-length': '12',
|
||||
authorization: TEST_BEARER,
|
||||
},
|
||||
});
|
||||
req.on('error', () => {}); // aborting surfaces a local socket error; ignore
|
||||
req.write('{"repo":"x"}');
|
||||
req.end();
|
||||
// Abort after the first attempt has failed-and-scheduled (ECONNREFUSED is
|
||||
// near-instant) but well before the upstream binds at ~400ms.
|
||||
setTimeout(() => {
|
||||
req.destroy();
|
||||
resolve();
|
||||
}, 100);
|
||||
});
|
||||
// Wait past the upstream bind + full retry budget (~750ms) so a leaked retry
|
||||
// would already have landed.
|
||||
await new Promise((r) => setTimeout(r, 900));
|
||||
assert.equal(ctx.calls, 0, 'aborted request must not be retried against the upstream');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 502 after exhausting the retry budget when the upstream stays down', async () => {
|
||||
const env = { GITNEXUS_PROXY_RETRY_ATTEMPTS: '3' };
|
||||
await withProxy({ upstream: null, env }, async (port) => {
|
||||
const res = await apiRequest(port, '/api/analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{"repo":"x"}',
|
||||
});
|
||||
assert.equal(res.status, 502, 'genuinely-down upstream still returns 502 after the budget');
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT retry a POST that connects then resets before responding', async () => {
|
||||
// The upstream accepts the connection, reads the whole request, then dies
|
||||
// before sending any response byte — an instance that received the job and
|
||||
// crashed/restarted mid-flight. Because the reset arrives AFTER connecting and
|
||||
// POST is non-idempotent, replaying could run the job twice, so the proxy must
|
||||
// NOT retry: the upstream sees exactly one call and the browser gets 502.
|
||||
const upstream = (_req, res) => res.socket.destroy();
|
||||
const env = { GITNEXUS_PROXY_RETRY_ATTEMPTS: '3' };
|
||||
await withProxy({ upstream, env }, async (port, ctx) => {
|
||||
const res = await apiRequest(port, '/api/analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{"repo":"x"}',
|
||||
});
|
||||
assert.equal(res.status, 502, 'post-connection reset on a POST fails fast, no retry');
|
||||
// Give any (erroneous) retry a chance to fire before asserting.
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
assert.equal(
|
||||
ctx.calls,
|
||||
1,
|
||||
'non-idempotent POST must not be replayed after the upstream got it',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT retry after the upstream starts streaming, then drops mid-body', async () => {
|
||||
// Send headers + a partial body, then abruptly destroy the socket.
|
||||
const upstream = (_req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.write('{"partial":');
|
||||
setTimeout(() => res.socket.destroy(), 20);
|
||||
};
|
||||
const env = { GITNEXUS_PROXY_RETRY_ATTEMPTS: '3' };
|
||||
await withProxy({ upstream, env }, async (port, ctx) => {
|
||||
// Settle on end OR on the mid-body abort/error, so the dropped connection
|
||||
// can't hang the test. What matters is that the proxy did NOT replay the
|
||||
// request (no duplicate job): the upstream must see exactly 1 call.
|
||||
await new Promise((resolve) => {
|
||||
const req = http.request(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path: '/api/analyze',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'content-length': '12',
|
||||
authorization: TEST_BEARER,
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
res.on('data', () => {});
|
||||
res.on('end', resolve);
|
||||
res.on('aborted', resolve);
|
||||
res.on('error', resolve);
|
||||
},
|
||||
);
|
||||
req.on('error', resolve);
|
||||
req.write('{"repo":"x"}');
|
||||
req.end();
|
||||
});
|
||||
// Give any (erroneous) retry a chance to fire before asserting.
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
assert.equal(ctx.calls, 1, 'must not replay once the response body has started');
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT buffer or retry a body larger than the retry cap', async () => {
|
||||
// Tiny cap so a modest body exceeds it and is streamed, not buffered.
|
||||
const env = { GITNEXUS_PROXY_RETRY_MAX_BODY_BYTES: '16' };
|
||||
const bigBody = 'x'.repeat(1024);
|
||||
await withProxy({ env }, async (port, ctx) => {
|
||||
const res = await apiRequest(port, '/api/analyze/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/octet-stream' },
|
||||
body: bigBody,
|
||||
});
|
||||
assert.equal(res.status, 200, 'over-cap body is streamed straight through');
|
||||
assert.equal(ctx.body.length, bigBody.length, 'full body reaches upstream (not capped)');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 400 when the client declares a body but never finishes sending it', async () => {
|
||||
// A live upstream, so a failure to reach it can't be mistaken for the body
|
||||
// timeout. It must see zero requests: the proxy never connects because the
|
||||
// buffering read times out first. The dedicated knob is set (leaving the
|
||||
// upstream idle timeout at its default) to prove the two tune independently.
|
||||
const env = { GITNEXUS_PROXY_CLIENT_BODY_TIMEOUT_MS: '300' };
|
||||
await withProxy({ env }, async (port, ctx) => {
|
||||
// Raw socket (not http.request, which would auto-finish the body): send a
|
||||
// Content-Length: 100 request but only 10 bytes, then hold the socket open.
|
||||
// We never close our side — the proxy must close it for us once the body
|
||||
// read times out (via `Connection: close`), rather than holding the
|
||||
// half-open connection until the server requestTimeout reaps it.
|
||||
const { status, serverClosed, raw } = await new Promise((resolve) => {
|
||||
const sock = connect(port, '127.0.0.1', () => {
|
||||
sock.write(
|
||||
'POST /api/analyze HTTP/1.1\r\n' +
|
||||
'Host: 127.0.0.1\r\n' +
|
||||
'Content-Type: application/json\r\n' +
|
||||
`Authorization: ${TEST_BEARER}\r\n` +
|
||||
'Content-Length: 100\r\n' +
|
||||
'\r\n' +
|
||||
'x'.repeat(10), // fewer than 100 bytes, then stall
|
||||
);
|
||||
});
|
||||
let buf = '';
|
||||
let status = null;
|
||||
// Fail-safe: if the proxy never closes on its own, report serverClosed
|
||||
// false (so the assertion fails cleanly) instead of hanging the test.
|
||||
const guard = setTimeout(() => {
|
||||
sock.destroy();
|
||||
resolve({ status, serverClosed: false, raw: buf });
|
||||
}, 2000);
|
||||
sock.setEncoding('utf8');
|
||||
sock.on('data', (chunk) => {
|
||||
buf += chunk;
|
||||
if (status === null) {
|
||||
const m = buf.split('\r\n', 1)[0].match(/^HTTP\/\d\.\d (\d{3})/);
|
||||
if (m) status = Number(m[1]);
|
||||
}
|
||||
});
|
||||
// The server closing its side (Connection: close) ends our socket; treat
|
||||
// any teardown initiated by the server as "closed promptly".
|
||||
sock.on('error', () => {}); // a reset may precede 'close'; swallow it
|
||||
sock.on('close', () => {
|
||||
clearTimeout(guard);
|
||||
resolve({ status, serverClosed: true, raw: buf });
|
||||
});
|
||||
});
|
||||
assert.equal(status, 400, 'stalled body read must be bounded and return 400, not hang');
|
||||
assert.ok(
|
||||
serverClosed,
|
||||
'proxy must close the half-open connection promptly, not hold it until requestTimeout',
|
||||
);
|
||||
assert.match(
|
||||
raw.toLowerCase(),
|
||||
/connection: close/,
|
||||
'the 400 for a stalled body must advertise Connection: close',
|
||||
);
|
||||
assert.equal(ctx.calls, 0, 'proxy must not connect upstream when the body never arrives');
|
||||
});
|
||||
});
|
||||
|
||||
// -- Token gate at the public edge (GITNEXUS_SERVE_AUTH_TOKEN) --------------
|
||||
//
|
||||
// The proxy terminates the browser Origin, so the API's own write guard can't
|
||||
// see a cross-site request coming. The token replaces it, checked on the way in.
|
||||
|
||||
it('answers an /api/* request with no Authorization header with a well-formed 401', async () => {
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
const res = await rawRequest(port, '/api/health');
|
||||
assert.equal(res.status, 401);
|
||||
assert.equal(res.headers['www-authenticate'], 'Bearer');
|
||||
assert.match(res.headers['content-type'], /application\/json/);
|
||||
// The UI dispatches on the stable code, not on message text.
|
||||
assert.deepEqual(JSON.parse(res.body), { error: 'unauthorized', code: 'unauthorized' });
|
||||
assert.equal(ctx.calls, 0, 'an unauthenticated request must cost nothing upstream');
|
||||
});
|
||||
});
|
||||
|
||||
it('closes the connection on a rejected request rather than draining its body', async () => {
|
||||
// The 401 is answered before the body is read, so without Connection: close
|
||||
// Node drains up to 64KB of an unauthenticated upload to keep the socket
|
||||
// reusable. Same reasoning as the stalled-body 400 above.
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
const res = await rawRequest(port, '/api/analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ path: '/etc' }),
|
||||
});
|
||||
assert.equal(res.status, 401);
|
||||
assert.equal(res.headers.connection, 'close');
|
||||
assert.equal(ctx.calls, 0);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a wrong token of the same length', async () => {
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
const wrong = 'x'.repeat(TEST_AUTH_TOKEN.length);
|
||||
const res = await apiRequest(port, '/api/health', {
|
||||
headers: { authorization: `Bearer ${wrong}` },
|
||||
});
|
||||
assert.equal(res.status, 401);
|
||||
assert.equal(ctx.calls, 0);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a wrong token of a different length', async () => {
|
||||
// The unequal-length branch takes a different path through the comparison
|
||||
// (dummy compare, no timingSafeEqual on the real buffers) and still must 401.
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
const res = await apiRequest(port, '/api/health', {
|
||||
headers: { authorization: 'Bearer short' },
|
||||
});
|
||||
assert.equal(res.status, 401);
|
||||
assert.equal(ctx.calls, 0);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects the raw token without the Bearer prefix', async () => {
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
const res = await apiRequest(port, '/api/health', {
|
||||
headers: { authorization: TEST_AUTH_TOKEN },
|
||||
});
|
||||
assert.equal(res.status, 401);
|
||||
assert.equal(ctx.calls, 0);
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards an /api/* request that carries the correct token', async () => {
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
const res = await apiRequest(port, '/api/health', {
|
||||
headers: { authorization: TEST_BEARER },
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(ctx.calls, 1);
|
||||
});
|
||||
});
|
||||
|
||||
it('strips the Authorization header instead of forwarding the edge token', async () => {
|
||||
// The token is spent at this hop. `serve` reads no Authorization header, so
|
||||
// forwarding would only copy a live credential into another service's logs.
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
const res = await apiRequest(port, '/api/mcp', { method: 'POST', body: '{}' });
|
||||
assert.equal(res.status, 200, 'the request itself must still be proxied');
|
||||
assert.equal(ctx.received.headers.authorization, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it('never gates static assets behind the token', async () => {
|
||||
// The UI has to load before it can prompt for a token.
|
||||
await withProxy({}, async (port, ctx) => {
|
||||
for (const path of ['/', '/index.html', '/some/app/route']) {
|
||||
const res = await rawRequest(port, path);
|
||||
assert.equal(res.status, 200, `${path} must be served without a token`);
|
||||
assert.match(res.body, /spa/);
|
||||
}
|
||||
assert.equal(ctx.calls, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// Run docker-server.mjs to completion and report how it exited. Used for the
|
||||
// boot-time refusal, which never reaches a listening state.
|
||||
function runUntilExit(cwd, env) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(process.execPath, [serverScript], {
|
||||
cwd,
|
||||
env: { ...process.env, ...env },
|
||||
stdio: 'pipe',
|
||||
});
|
||||
let stderr = '';
|
||||
proc.stderr.setEncoding('utf8');
|
||||
proc.stderr.on('data', (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
proc.on('error', reject);
|
||||
proc.on('exit', (code) => resolve({ code, stderr }));
|
||||
// A server that starts instead of refusing never exits, so name that failure
|
||||
// here rather than letting it surface as a timeout or a null exit code.
|
||||
setTimeout(() => {
|
||||
proc.kill();
|
||||
reject(new Error('docker-server.mjs kept running; it was expected to refuse and exit'));
|
||||
}, 5000).unref();
|
||||
});
|
||||
}
|
||||
|
||||
async function withDistDir(fn) {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'gitnexus-boot-'));
|
||||
await mkdir(join(dir, 'dist'), { recursive: true });
|
||||
await writeFile(join(dir, 'dist', 'index.html'), '<html><body>spa</body></html>');
|
||||
try {
|
||||
await fn(dir);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
it('refuses to start when the proxy is enabled without a token', async () => {
|
||||
await withDistDir(async (dir) => {
|
||||
const port = await getFreePort();
|
||||
const { code, stderr } = await runUntilExit(dir, {
|
||||
PORT: String(port),
|
||||
GITNEXUS_UPSTREAM_URL: '127.0.0.1:4747',
|
||||
GITNEXUS_SERVE_AUTH_TOKEN: undefined,
|
||||
});
|
||||
assert.equal(code, 1, 'an unauthenticated public proxy must fail closed at boot');
|
||||
assert.match(stderr, /Refusing to start/);
|
||||
assert.match(stderr, /GITNEXUS_SERVE_AUTH_TOKEN/);
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses to start when the token is short enough to guess', async () => {
|
||||
// Nothing rate-limits a failed token, so a weak one is guessable at network
|
||||
// speed. The floor is what makes the missing limiter safe.
|
||||
await withDistDir(async (dir) => {
|
||||
const port = await getFreePort();
|
||||
const { code, stderr } = await runUntilExit(dir, {
|
||||
PORT: String(port),
|
||||
GITNEXUS_UPSTREAM_URL: '127.0.0.1:4747',
|
||||
GITNEXUS_SERVE_AUTH_TOKEN: 'hunter2',
|
||||
});
|
||||
assert.equal(code, 1);
|
||||
assert.match(stderr, /shorter than 32 characters/);
|
||||
assert.ok(!stderr.includes('hunter2'), 'the refusal must never echo the token');
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a whitespace-only token as absent rather than as a short one', async () => {
|
||||
// ' ' trims to empty, so this must hit the missing-token refusal, not the
|
||||
// length one.
|
||||
await withDistDir(async (dir) => {
|
||||
const port = await getFreePort();
|
||||
const { code, stderr } = await runUntilExit(dir, {
|
||||
PORT: String(port),
|
||||
GITNEXUS_UPSTREAM_URL: '127.0.0.1:4747',
|
||||
GITNEXUS_SERVE_AUTH_TOKEN: ' ',
|
||||
});
|
||||
assert.equal(code, 1);
|
||||
assert.match(stderr, /is set without GITNEXUS_SERVE_AUTH_TOKEN/);
|
||||
});
|
||||
});
|
||||
|
||||
it('starts normally with neither the proxy nor a token configured', async () => {
|
||||
// docker-compose's default: static assets only, nothing to gate, no refusal.
|
||||
await withDistDir(async (dir) => {
|
||||
const port = await getFreePort();
|
||||
const proc = spawnServerWithEnv(dir, port, { GITNEXUS_SERVE_AUTH_TOKEN: undefined });
|
||||
try {
|
||||
await waitForServer(port);
|
||||
const res = await rawRequest(port, '/');
|
||||
assert.equal(res.status, 200);
|
||||
assert.match(res.body, /spa/);
|
||||
} finally {
|
||||
await killAndWait(proc);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,473 @@
|
|||
# GitNexus Engineering Plan
|
||||
|
||||
> Task: Emit the missing `CALLS` edge for Python's unaliased multi-segment namespace import (`import pkg.db` + `pkg.db.session_scope()`), issue #2826.
|
||||
> Evidence verified at commit b2cd1c2ad637657125248c0dd2046de71ceea965; GitNexus index 13 commits behind HEAD, refresh skipped: every cited path is byte-identical between the index commit (1ef6447e) and the pinned commit — verified by blob-id comparison, so no graph claim here rests on drifted content. PDG layer absent from this index (`MATCH ()-[r:CodeRelation {type:'CDG'}]->() RETURN count(r)` → 0); `--pdg` upgrade skipped, source reads substitute at higher evidence strength.
|
||||
> Evidence provenance schema 2; global dirty digest 0912a3ee3219cb75c82aefbf9f010e8dbe313150d6553768fd55d22af87a135c; cited-path manifest 13 sorted entries; exact generated plan path excluded.
|
||||
|
||||
## 1. Objective
|
||||
|
||||
`import pkg.db` followed by `pkg.db.session_scope()` must emit a `CALLS` edge from the caller to `session_scope`, matching the three sibling import spellings that already resolve (`from pkg.db import session_scope`, `import pkg.db as pdb`, `from pkg import db`). Two same-package imports in one file (`import pkg.a` + `import pkg.b`) must not cross-resolve, and no shared file under `gitnexus/src/core/ingestion/` may name a language (AGENTS.md §42).
|
||||
|
||||
## 2. Current Behaviour
|
||||
|
||||
The failure is a **key/lookup mismatch inside one map**, not a missing resolution path.
|
||||
|
||||
For `import pkg.db`, `splitImportStmt` emits one match with `@import.source` = the whole `dotted_name` text `"pkg.db"` `[verified]` (`gitnexus/src/core/ingestion/languages/python/import-decomposer.ts:46-54`). `interpretPythonImport`'s `'plain'` arm then splits it `[verified]` (`gitnexus/src/core/ingestion/languages/python/interpret.ts:33-42`):
|
||||
|
||||
```ts
|
||||
case 'plain': {
|
||||
// `import numpy`
|
||||
if (sourceCap === undefined) return null;
|
||||
return {
|
||||
kind: 'namespace',
|
||||
localName: sourceCap.text.split('.')[0]!, // `import a.b.c` exposes `a`
|
||||
importedName: sourceCap.text,
|
||||
targetRaw: sourceCap.text,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
`finalizeImportEdges` carries both halves onto the edge: `localName` verbatim, and `targetExportedName = parsed.importedName` for `kind === 'namespace'` `[verified]` (`gitnexus-shared/src/scope-resolution/finalize-algorithm.ts:398-406, 434-447`). So the finalized `ImportEdge` is `{ localName: 'pkg', targetExportedName: 'pkg.db', targetFile: 'pkg/db.py', kind: 'namespace' }`.
|
||||
|
||||
`collectNamespaceTargets` keys **only on `localName`** `[verified]` (`gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts:44-57`), producing `{'pkg' → ['pkg/db.py']}`.
|
||||
|
||||
At the call site, Python's query binds the attribute's `object` field with a wildcard — `object: (_) @reference.receiver` `[verified]` (`gitnexus/src/core/ingestion/languages/python/query.ts:267-270`) — so for `pkg.db.session_scope()` the receiver node is the inner `attribute`, and `extractExplicitReceiver` takes its raw text `[verified]` (`gitnexus/src/core/ingestion/scope-extractor.ts:1235-1239`): `receiverName === 'pkg.db'`.
|
||||
|
||||
`emitReceiverBoundCalls` then walks its cases `[verified]` (`gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts:404-421, 546-655, 831-848`):
|
||||
|
||||
- **Case 0 (compound receiver)** fires because `receiverName.includes('.')` (line 563-567). It asks `resolveCompoundReceiverClass` for a **class**; `pkg.db` names a module, so it returns `undefined`, sets `compoundReceiverUnresolved = true`, and — critically — does **not** `handledSites.add`, so control falls through (lines 577, 622-655).
|
||||
- **Case 1 (namespace receiver)** runs `namespaceTargets.get('pkg.db')` (line 832). The map holds `'pkg'`. Miss.
|
||||
- **Case 1.5** needs `provider.resolveQualifiedReceiverMember`, implemented only by the C++ provider `[verified]` (`gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts:399-406`; `context` on that symbol shows one outgoing call to `resolveCppQualifiedNamespaceMember` and no other implementer). Python leaves it undefined, so the case is skipped.
|
||||
|
||||
No later case types a module receiver, so the site drops. Reproduced on both `origin/main` and PR #2810's head; PR #2810 changes Python receiver *typing* (`languages/python/receiver-binding.ts`) and does not touch this path `[verified]` by running the repro against both trees.
|
||||
|
||||
The three sibling spellings resolve because each binds a **single-segment** local name: `pdb` (alias arm), `session_scope` (named binding, not a receiver at all), and `db` (reclassified to `kind: 'namespace'` by #2770's `isNamespaceImport` hook, keying the map on `'db'`).
|
||||
|
||||
## 3. Relevant Architecture
|
||||
|
||||
`collectNamespaceTargets` is the shared, language-neutral bridge between finalized import edges and receiver resolution. Its contract note already states that `ImportEdge.kind === 'namespace'` is authoritative and that providers may reclassify into it — that reclassification hook (`isNamespaceImport`) is #2770's extension point `[verified]` (`gitnexus-shared/src/scope-resolution/finalize-algorithm.ts:99-107`).
|
||||
|
||||
Its output feeds three consumers, all per-file (`fileCompoundOpts`, `receiver-bound-calls.ts:405-406`):
|
||||
|
||||
1. `emitReceiverBoundCalls` Case 1 — namespace-receiver member calls (`receiver-bound-calls.ts:832`);
|
||||
2. `resolveConstructionExpressionClass` — namespace-qualified construction `pkg.db.Model()` (`compound-receiver.ts:245-260`);
|
||||
3. `resolveCompoundReceiverClass`'s namespace-qualified-constructor disambiguation `options.namespaceTargets?.has(objExpr)` (`compound-receiver.ts:759-766`).
|
||||
|
||||
AGENTS.md line 42 is the binding constraint: *"Shared code in `gitnexus/src/core/ingestion/` must not name languages — plug language behavior in via `LanguageProvider` / `ScopeResolver` hooks."* `[verified]`
|
||||
|
||||
## 4. GitNexus Findings
|
||||
|
||||
- `context({name: 'collectNamespaceTargets', repo: 'GitNexus'})` — `epistemic: "exact"`; incoming calls are exactly two: `emitReceiverBoundCalls` (`.../passes/receiver-bound-calls.ts`) and a test-local `build` in `test/unit/scope-resolution/python/python-module-namespace-construction.test.ts`. `[graph]` These are the d=1 dependents; the two `compound-receiver.ts` consumers reach the map by parameter rather than by call, so they do not appear here and were found by source grep `[verified]`.
|
||||
- `context({name: 'resolveQualifiedReceiverMember', repo: 'GitNexus'})` — resolves to a single definition at `languages/cpp/scope-resolver.ts:399`, `outgoing.calls: [resolveCppQualifiedNamespaceMember]`, no incoming. `[graph]` Confirms the Case-1.5 hook is C++-only, matching the issue reporter's read of the published bundle.
|
||||
- `cypher({statement: "MATCH ()-[r:CodeRelation {type: 'CDG'}]->() RETURN count(r)"})` — `| cdg_rows | 0 |`. `[graph]` The index carries no PDG layer; §5 is therefore empty by fact, not by omission.
|
||||
- Related tests located by directory listing `[verified]`: `test/fixtures/lang-resolution/` already holds `python-module-import`, `python-bare-import`, `python-plain-import-alias`, `python-multi-segment-ancestor-import`, `python-function-local-namespace-import`, `python-class-body-namespace-import`, and #2770's `python-from-module-alias`. `test/integration/resolvers/python.test.ts` is the convention-matching home for the new assertions (#2770 added its coverage there, +38 lines).
|
||||
|
||||
## 5. Statement-Level PDG Findings
|
||||
|
||||
Empty by fact: the current index has zero `CDG` rows, so no statement-level slice exists to build. A `--pdg` re-index was deliberately not run — it is the largest fixed cost available to this session, the analyzer holds no writer lock against a live MCP server (#2658), and every constraint the slice would supply (which case gates the namespace lookup, whether Case 0's failure falls through) was read directly from source at higher evidence strength in §2.
|
||||
|
||||
## 6. Proposed Changes
|
||||
|
||||
### 6.1 `collectNamespaceTargets` — also key on the dotted access path
|
||||
|
||||
- **File:** `gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts`
|
||||
- **Symbol:** `collectNamespaceTargets` (source-verified)
|
||||
- **Responsibility:** map every receiver spelling that names an imported module to that module's file(s).
|
||||
- **Change:** inside the existing edge loop, after recording `edge.localName`, also record `edge.targetExportedName` **when it contains a dot and its first dot-separated segment equals `edge.localName`**. Same array-dedupe as the existing key.
|
||||
- **Why this is language-neutral (AGENTS.md §42):** the condition names no language. It encodes one structural fact — *a namespace binding whose exported module name is a dotted path rooted at the local name is also reachable under that whole path.* Verified against every other namespace-emitting provider at the pinned commit `[verified]`:
|
||||
- TypeScript `import * as X from './y'` → `localName 'X'`, `importedName './y'`; first segment `''` ≠ `'X'` → no key (`languages/typescript/interpret.ts:77-81, 118-122`).
|
||||
- C# `using System.Collections.Generic` → `localName 'Generic'` (last segment), `importedName 'System.Collections.Generic'`; first segment `'System'` ≠ `'Generic'` → no key (`languages/csharp/interpret.ts:33-37, 62-66`).
|
||||
- Go / Rust / Ruby → `localName === importedName`, no dot → no key (`languages/{go,rust,ruby}/interpret.ts`).
|
||||
- Python `import pkg.db` → `'pkg' === 'pkg.db'.split('.')[0]` → key `'pkg.db'` added. This is the only provider the predicate admits today.
|
||||
- **Constraint:** additive only. The existing `localName` key must keep its current value and ordering so no currently-resolving site changes target.
|
||||
- **Two-package safety:** `import pkg.a` + `import pkg.b` in one file yields `{'pkg' → ['pkg/a.py','pkg/b.py'], 'pkg.a' → ['pkg/a.py'], 'pkg.b' → ['pkg/b.py']}`. Receiver `pkg.a` hits exactly one file; the ambiguous `'pkg'` bucket is only reachable by a receiver literally spelled `pkg`, which is unchanged from today. `[inferred]` — pinned by a test in §8.
|
||||
|
||||
### 6.2 `isNamespaceNameShadowed` — test the root segment, not the dotted path
|
||||
|
||||
- **File:** `gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts`
|
||||
- **Symbol:** `isNamespaceNameShadowed` (source-verified, lines 152-183) and its one call site at line 250.
|
||||
- **Defect this fix activates:** the guard walks the scope chain looking for a binding, type binding, lexical name, or owned def **named exactly `namespaceName`**. With 6.1 in place, `namespaceName` can be `'pkg.db'`, but Python binds only `pkg` — so a local `pkg = something` that genuinely shadows the import would fail to suppress the namespace interpretation, and the "verified namespace is authoritative" branch (line 249-259) would return a wrong class instead of declining.
|
||||
- **Change:** shadow-test the first dot-separated segment of `namespaceName` (identical behaviour for the single-segment names it sees today, since root === whole name).
|
||||
- **Not scope creep:** 6.1 is what first routes a dotted name into this guard; shipping 6.1 without it introduces the false positive.
|
||||
|
||||
### 6.3 No change required in `receiver-bound-calls.ts`
|
||||
|
||||
Case 1's lookup already uses the full dotted `receiverName` and Case 0's failure already falls through to it (`receiver-bound-calls.ts:577, 622-655, 832`) `[verified]`. Recorded here so the executor does not "fix" a path that is already correct.
|
||||
|
||||
## 7. Implementation Sequence
|
||||
|
||||
1. **Add the failing fixture and assertions first.** Create `gitnexus/test/fixtures/lang-resolution/python-dotted-namespace-import/` (files in §8) and a `describe` block in `gitnexus/test/integration/resolvers/python.test.ts` following the file's existing `writeFixtureRepo` + `mkdtempSync` convention. Confirm the dotted row fails and all three control rows pass. Delete the scratch `gitnexus/test/integration/resolvers/repro-2826-python-dotted-import.test.ts` in this step — its content is superseded by the fixture-backed tests.
|
||||
2. **Implement 6.1** in `namespace-targets.ts`, and update its header contract note to state that a namespace edge may be keyed both by its local name and by a dotted access path rooted at that name. Re-run the step-1 tests: the dotted row must flip to passing with the controls still green.
|
||||
3. **Implement 6.2** in `compound-receiver.ts` with the shadowing test from §8 (a local `pkg = Decoy()` must suppress, not misresolve).
|
||||
4. **Run the regression surface**: full resolver + scope-resolution integration suites, both packages' `tsc --noEmit`.
|
||||
5. **Regenerate recorded baselines once, last.** Run each `--check` gate; regenerate only the baselines that actually moved (`bench/receiver-resolution/baseline.json` is the expected one — this change adds resolved edges). Per plan-template §7, this is deliberately the final step so intermediate commits do not churn and re-drift the artifacts.
|
||||
|
||||
## 8. Test Strategy
|
||||
|
||||
**New fixture** `gitnexus/test/fixtures/lang-resolution/python-dotted-namespace-import/`:
|
||||
|
||||
| file | contents |
|
||||
| --- | --- |
|
||||
| `pkg/__init__.py` | empty |
|
||||
| `pkg/db.py` | `def session_scope(): ...` |
|
||||
| `pkg/cache.py` | `def session_scope(): ...` — the decoy that makes cross-resolution detectable |
|
||||
| `caller_dotted.py` | `import pkg.db` + `def uses_dotted(): return pkg.db.session_scope()` |
|
||||
| `caller_from.py`, `caller_alias.py`, `caller_frommod.py` | the three sibling controls from the issue |
|
||||
| `caller_two_pkgs.py` | `import pkg.db` **and** `import pkg.cache`, one function calling each |
|
||||
| `caller_deep.py` | `import pkg.sub.deep` + `pkg.sub.deep.f()` (3-segment) |
|
||||
| `caller_shadowed.py` | module-level `import pkg.db`, then a function with a local `pkg = Decoy()` before `pkg.db.session_scope()` |
|
||||
|
||||
**Scenarios** (input → action → expected):
|
||||
|
||||
1. `caller_dotted.py` → run pipeline → `CALLS` edge `uses_dotted` → `pkg/db.py:session_scope`, `reason: 'import-resolved'`. **This is the issue's acceptance row.**
|
||||
2. The three sibling callers → same run → all three still resolve to `pkg/db.py:session_scope`. Regression control: a run where the controls also broke would prove nothing about row 1.
|
||||
3. `caller_two_pkgs.py` → `pkg.db.session_scope()` resolves **only** to `pkg/db.py` and `pkg.cache.session_scope()` **only** to `pkg/cache.py`; assert the absence of the crossed pair explicitly, not just the presence of the right one.
|
||||
4. `caller_deep.py` → 3-segment receiver resolves — proves the predicate is not hard-coded to two segments.
|
||||
5. `caller_shadowed.py` → **no** edge from the shadowed function to `pkg/db.py` (6.2's guard). Fails loudly if 6.2 regresses.
|
||||
6. Cross-language non-regression: the existing TypeScript / C# / Go namespace-import resolver tests must stay green unchanged — that is the executable proof the new key is not minted for them.
|
||||
|
||||
**Tests to update:** `gitnexus/test/integration/resolvers/python.test.ts` (add the describe block). `gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts` is a direct `collectNamespaceTargets` caller — re-run it; extend it only if its expectations enumerate map keys exhaustively.
|
||||
|
||||
**Verification commands** (each verified to exist in `gitnexus/package.json` / `.github/workflows/ci-tests.yml` at the pinned commit):
|
||||
|
||||
```bash
|
||||
# from gitnexus/ — pretest:integration runs scripts/build.js, so the parse worker exists
|
||||
GITNEXUS_WORKER_READY_TIMEOUT_MS=60000 npm run test:integration -- test/integration/resolvers/python.test.ts
|
||||
GITNEXUS_WORKER_READY_TIMEOUT_MS=60000 npm run test:integration -- test/integration/resolvers
|
||||
npm run test:unit -- test/unit/scope-resolution
|
||||
npx tsc --noEmit # and the same in ../gitnexus-shared
|
||||
node --import tsx bench/receiver-resolution/measure.mjs --check
|
||||
node --import tsx bench/python-scope/measure.mjs --check
|
||||
node --import tsx bench/python-scope/import-target-fingerprint.mjs --check
|
||||
node --import tsx bench/scope-capture/measure.mjs --check
|
||||
```
|
||||
|
||||
`GITNEXUS_WORKER_READY_TIMEOUT_MS=60000` is required on this host: the default 5000 ms worker-ready deadline fails as a crash-loop here (observed while reproducing the issue), which is environmental, not a code fault.
|
||||
|
||||
## 9. Risk and Impact Analysis
|
||||
|
||||
Accounting for every direct (d=1) dependent of the changed map:
|
||||
|
||||
| d=1 dependent | risk | mitigation |
|
||||
| --- | --- | --- |
|
||||
| `emitReceiverBoundCalls` Case 1 (`receiver-bound-calls.ts:832`) | New keys make previously-dropped sites resolve. A wrong target would be a *new* false edge. | The predicate admits only Python's `import a.b` shape; each new key maps to exactly one file per import statement. §8 scenario 3 pins non-crossing. |
|
||||
| `resolveConstructionExpressionClass` (`compound-receiver.ts:245-260`) | `pkg.db.Model()` now takes the "verified namespace is authoritative" branch, which deliberately does **not** fall through on a miss or ambiguity — so a wrong key would convert a working heuristic resolution into a silent decline. | The branch requires `namespaceFiles.length > 0`, i.e. the import genuinely resolved. Ambiguity still returns `undefined` (`namespaceMatches.length === 1` guard). Shadowing is fixed by 6.2. |
|
||||
| `resolveCompoundReceiverClass` namespace-constructor disambiguation (`compound-receiver.ts:759-766`) | `namespaceTargets.has(objExpr)` now true for dotted namespaces, routing `pkg.db.Model(x).run()` into the construction interpretation. | Correct by intent — that branch exists precisely to make a namespace-qualified bare constructor safe. Behaviour change, so §8 should include a construction row if the fixture's cost is low. |
|
||||
| `test/unit/scope-resolution/python/python-module-namespace-construction.test.ts:build` | May assert exact map contents. | Re-run in step 4; extend rather than weaken if it enumerates keys. |
|
||||
| C++ provider | Case 1 is skipped entirely for C++ (`provider.resolveQualifiedReceiverMember !== undefined`), but the two `compound-receiver.ts` consumers are **not** provider-gated. | C++ `#include` does not produce a `kind: 'namespace'` edge with a dotted `targetExportedName` rooted at its local name; the predicate declines. Covered by the existing C++ suites plus `bench/cpp-qualified-ns/measure.mjs --check`. |
|
||||
|
||||
**Recorded-artifact risk:** `bench/receiver-resolution/measure.mjs --check` gates both a shape matrix and a drop-count arm; new resolved edges are expected to move the count arm and the gate fails on drift. Regenerating in step 5 only (per §7) keeps intermediate commits clean. `bench/python-scope/*` and `bench/scope-capture/*` fingerprint captures and import-target resolution — neither is touched by this change, so a movement there is a signal to stop and investigate, not to regenerate.
|
||||
|
||||
**Performance:** one extra `Map.set` per multi-segment namespace import per file; the loop is already O(module import edges). No new traversal.
|
||||
|
||||
**No schema/version impact:** this changes what the resolver produces, not how it is stored. Existing indexes need a re-analyze to show the new edges — matching the note PR #2810 carried for the same reason.
|
||||
|
||||
## 10. Files Expected to Change
|
||||
|
||||
| File | Symbols | Reason |
|
||||
| ---- | ------- | ------ |
|
||||
| `gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts` | `collectNamespaceTargets` | Add the dotted-access-path key (§6.1) and update the contract note |
|
||||
| `gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts` | `isNamespaceNameShadowed` | Shadow-test the root segment (§6.2) |
|
||||
| `gitnexus/test/integration/resolvers/python.test.ts` | new `describe` block | Issue acceptance row + controls + regression rows |
|
||||
| `gitnexus/test/fixtures/lang-resolution/python-dotted-namespace-import/**` | — | New fixture (§8) |
|
||||
| `gitnexus/test/integration/resolvers/repro-2826-python-dotted-import.test.ts` | — | Delete; superseded by the fixture-backed tests |
|
||||
| `gitnexus/bench/receiver-resolution/baseline.json` | — | Regenerate once, final step, only if `--check` moves |
|
||||
|
||||
## 11. Reusable Implementation Context
|
||||
|
||||
```yaml
|
||||
implementation_context:
|
||||
task_summary: >
|
||||
Python `import pkg.db` + `pkg.db.session_scope()` emits no CALLS edge (#2826).
|
||||
Root cause: collectNamespaceTargets keys its map only on ImportEdge.localName
|
||||
('pkg'), while the receiver text is the full dotted path ('pkg.db'). Fix by
|
||||
additionally keying on ImportEdge.targetExportedName when it is dotted and
|
||||
rooted at localName — a predicate no other provider satisfies — plus a
|
||||
root-segment fix to the shadow guard the new key first exposes.
|
||||
acceptance_criteria:
|
||||
- 'CALLS edge uses_dotted -> pkg/db.py:session_scope with reason import-resolved'
|
||||
- 'The three sibling spellings (from-import, alias, from-module-attr) still resolve'
|
||||
- 'import pkg.a + import pkg.b in one file do not cross-resolve'
|
||||
- 'A local binding shadowing the package root suppresses the namespace interpretation'
|
||||
- 'No shared file under gitnexus/src/core/ingestion/ names a language (AGENTS.md §42)'
|
||||
|
||||
evidence_provenance:
|
||||
schema_version: 2
|
||||
head_commit: 'b2cd1c2ad637657125248c0dd2046de71ceea965'
|
||||
generated_plan_path: 'docs/plans/2026-08-04-gitnexus-plan-python-dotted-namespace-receiver.md'
|
||||
global_dirty_digest:
|
||||
algorithm: 'sha256'
|
||||
canonicalization: 'gitnexus-evidence-provenance-v2 NUL-framed UTF-8 records'
|
||||
value: '0912a3ee3219cb75c82aefbf9f010e8dbe313150d6553768fd55d22af87a135c'
|
||||
cited_path_manifest:
|
||||
- path: '.github/workflows/ci-tests.yml'
|
||||
object_kind: { head: regular, index: regular, worktree: regular, untracked: absent }
|
||||
state: 'clean'
|
||||
rename_from: null
|
||||
rename_to: null
|
||||
head_digest: 'sha256:0f1fba71be1e2b026d1ca2d35934ffe197b26bd4d31d5e5025d1e797e89754ff'
|
||||
index_digest: 'sha256:0f1fba71be1e2b026d1ca2d35934ffe197b26bd4d31d5e5025d1e797e89754ff'
|
||||
worktree_digest: 'sha256:0f1fba71be1e2b026d1ca2d35934ffe197b26bd4d31d5e5025d1e797e89754ff'
|
||||
untracked_digest: 'absent'
|
||||
- path: 'AGENTS.md'
|
||||
object_kind: { head: regular, index: regular, worktree: regular, untracked: absent }
|
||||
state: 'clean'
|
||||
rename_from: null
|
||||
rename_to: null
|
||||
head_digest: 'sha256:797b9d58a9c3dbed5af048904b3d3ba55ba6a2256a442fd15d35eb8b568cd1dd'
|
||||
index_digest: 'sha256:797b9d58a9c3dbed5af048904b3d3ba55ba6a2256a442fd15d35eb8b568cd1dd'
|
||||
worktree_digest: 'sha256:797b9d58a9c3dbed5af048904b3d3ba55ba6a2256a442fd15d35eb8b568cd1dd'
|
||||
untracked_digest: 'absent'
|
||||
- path: 'gitnexus-shared/src/scope-resolution/finalize-algorithm.ts'
|
||||
object_kind: { head: regular, index: regular, worktree: regular, untracked: absent }
|
||||
state: 'clean'
|
||||
rename_from: null
|
||||
rename_to: null
|
||||
head_digest: 'sha256:9c3656484d8b5bd49394918446ab91c73db722e3fe2314fc08c9c284541c415b'
|
||||
index_digest: 'sha256:9c3656484d8b5bd49394918446ab91c73db722e3fe2314fc08c9c284541c415b'
|
||||
worktree_digest: 'sha256:9c3656484d8b5bd49394918446ab91c73db722e3fe2314fc08c9c284541c415b'
|
||||
untracked_digest: 'absent'
|
||||
- path: 'gitnexus-shared/src/scope-resolution/types.ts'
|
||||
object_kind: { head: regular, index: regular, worktree: regular, untracked: absent }
|
||||
state: 'clean'
|
||||
rename_from: null
|
||||
rename_to: null
|
||||
head_digest: 'sha256:d9b0e9e0d47c10a71392ad8d0de31b08327c6268915488f1153c04cdc39fbdfc'
|
||||
index_digest: 'sha256:d9b0e9e0d47c10a71392ad8d0de31b08327c6268915488f1153c04cdc39fbdfc'
|
||||
worktree_digest: 'sha256:d9b0e9e0d47c10a71392ad8d0de31b08327c6268915488f1153c04cdc39fbdfc'
|
||||
untracked_digest: 'absent'
|
||||
- path: 'gitnexus/src/core/ingestion/languages/python/import-decomposer.ts'
|
||||
object_kind: { head: regular, index: regular, worktree: regular, untracked: absent }
|
||||
state: 'clean'
|
||||
rename_from: null
|
||||
rename_to: null
|
||||
head_digest: 'sha256:97e28381e7d3f6040e5368d043d086ab2d3df24aad5e2bcb0c3da866a455a23e'
|
||||
index_digest: 'sha256:97e28381e7d3f6040e5368d043d086ab2d3df24aad5e2bcb0c3da866a455a23e'
|
||||
worktree_digest: 'sha256:97e28381e7d3f6040e5368d043d086ab2d3df24aad5e2bcb0c3da866a455a23e'
|
||||
untracked_digest: 'absent'
|
||||
- path: 'gitnexus/src/core/ingestion/languages/python/interpret.ts'
|
||||
object_kind: { head: regular, index: regular, worktree: regular, untracked: absent }
|
||||
state: 'clean'
|
||||
rename_from: null
|
||||
rename_to: null
|
||||
head_digest: 'sha256:65ca96b207b89a86f44772f8f8ff8030acf06774214ddee67ef031db3d770419'
|
||||
index_digest: 'sha256:65ca96b207b89a86f44772f8f8ff8030acf06774214ddee67ef031db3d770419'
|
||||
worktree_digest: 'sha256:65ca96b207b89a86f44772f8f8ff8030acf06774214ddee67ef031db3d770419'
|
||||
untracked_digest: 'absent'
|
||||
- path: 'gitnexus/src/core/ingestion/languages/python/query.ts'
|
||||
object_kind: { head: regular, index: regular, worktree: regular, untracked: absent }
|
||||
state: 'clean'
|
||||
rename_from: null
|
||||
rename_to: null
|
||||
head_digest: 'sha256:f9e145114aba978e34525c1ccb553ba37feea4152f882dc0e105dc8b21230d78'
|
||||
index_digest: 'sha256:f9e145114aba978e34525c1ccb553ba37feea4152f882dc0e105dc8b21230d78'
|
||||
worktree_digest: 'sha256:f9e145114aba978e34525c1ccb553ba37feea4152f882dc0e105dc8b21230d78'
|
||||
untracked_digest: 'absent'
|
||||
- path: 'gitnexus/src/core/ingestion/scope-extractor.ts'
|
||||
object_kind: { head: regular, index: regular, worktree: regular, untracked: absent }
|
||||
state: 'clean'
|
||||
rename_from: null
|
||||
rename_to: null
|
||||
head_digest: 'sha256:34089a212075f16d8c270240c64985b0a666864547ed414449a59747e4922d80'
|
||||
index_digest: 'sha256:34089a212075f16d8c270240c64985b0a666864547ed414449a59747e4922d80'
|
||||
worktree_digest: 'sha256:34089a212075f16d8c270240c64985b0a666864547ed414449a59747e4922d80'
|
||||
untracked_digest: 'absent'
|
||||
- path: 'gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts'
|
||||
object_kind: { head: regular, index: regular, worktree: regular, untracked: absent }
|
||||
state: 'clean'
|
||||
rename_from: null
|
||||
rename_to: null
|
||||
head_digest: 'sha256:88a083a625449187fe770e992c580ec84d70ddb9f395f54949c1b85a29838f97'
|
||||
index_digest: 'sha256:88a083a625449187fe770e992c580ec84d70ddb9f395f54949c1b85a29838f97'
|
||||
worktree_digest: 'sha256:88a083a625449187fe770e992c580ec84d70ddb9f395f54949c1b85a29838f97'
|
||||
untracked_digest: 'absent'
|
||||
- path: 'gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts'
|
||||
object_kind: { head: regular, index: regular, worktree: regular, untracked: absent }
|
||||
state: 'clean'
|
||||
rename_from: null
|
||||
rename_to: null
|
||||
head_digest: 'sha256:1873a19be4235b6882aab63422a0bc632192ac30407e60e7d5648aa70e5759c3'
|
||||
index_digest: 'sha256:1873a19be4235b6882aab63422a0bc632192ac30407e60e7d5648aa70e5759c3'
|
||||
worktree_digest: 'sha256:1873a19be4235b6882aab63422a0bc632192ac30407e60e7d5648aa70e5759c3'
|
||||
untracked_digest: 'absent'
|
||||
- path: 'gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts'
|
||||
object_kind: { head: regular, index: regular, worktree: regular, untracked: absent }
|
||||
state: 'clean'
|
||||
rename_from: null
|
||||
rename_to: null
|
||||
head_digest: 'sha256:54062a70276ec1761a94b6548499d265c91fd3b648422a8567a21e06d800e09d'
|
||||
index_digest: 'sha256:54062a70276ec1761a94b6548499d265c91fd3b648422a8567a21e06d800e09d'
|
||||
worktree_digest: 'sha256:54062a70276ec1761a94b6548499d265c91fd3b648422a8567a21e06d800e09d'
|
||||
untracked_digest: 'absent'
|
||||
- path: 'gitnexus/test/integration/resolvers/python.test.ts'
|
||||
object_kind: { head: regular, index: regular, worktree: regular, untracked: absent }
|
||||
state: 'clean'
|
||||
rename_from: null
|
||||
rename_to: null
|
||||
head_digest: 'sha256:4c0f55a923f51d736476b5bcb276d293637d90fecc10ab5e08624a4b541fe999'
|
||||
index_digest: 'sha256:4c0f55a923f51d736476b5bcb276d293637d90fecc10ab5e08624a4b541fe999'
|
||||
worktree_digest: 'sha256:4c0f55a923f51d736476b5bcb276d293637d90fecc10ab5e08624a4b541fe999'
|
||||
untracked_digest: 'absent'
|
||||
- path: 'gitnexus/test/integration/resolvers/repro-2826-python-dotted-import.test.ts'
|
||||
object_kind: { head: absent, index: absent, worktree: absent, untracked: regular }
|
||||
state: 'untracked'
|
||||
rename_from: null
|
||||
rename_to: null
|
||||
head_digest: 'absent'
|
||||
index_digest: 'absent'
|
||||
worktree_digest: 'absent'
|
||||
untracked_digest: 'sha256:6fe3a74a69db12a1a0aeceef2b32eb0c04d5e4880fc93b7b840348118e70078c'
|
||||
|
||||
primary_symbols:
|
||||
- symbol: 'collectNamespaceTargets'
|
||||
file: 'gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts'
|
||||
lines: '39-57'
|
||||
role: 'The defect site — builds the receiver-name → target-file map keyed only on localName'
|
||||
- symbol: 'interpretPythonImport'
|
||||
file: 'gitnexus/src/core/ingestion/languages/python/interpret.ts'
|
||||
lines: '33-42'
|
||||
role: 'Splits `import a.b` into localName "a" / importedName "a.b"; source of both halves'
|
||||
- symbol: 'emitReceiverBoundCalls'
|
||||
file: 'gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts'
|
||||
lines: '404-421, 546-655, 831-848'
|
||||
role: 'Case 0 declines on a module receiver and falls through; Case 1 does the failing map lookup'
|
||||
- symbol: 'isNamespaceNameShadowed'
|
||||
file: 'gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts'
|
||||
lines: '152-183'
|
||||
role: 'Shadow guard that must test the root segment once dotted keys exist'
|
||||
- symbol: 'finalizeImportEdges'
|
||||
file: 'gitnexus-shared/src/scope-resolution/finalize-algorithm.ts'
|
||||
lines: '398-406, 434-447'
|
||||
role: 'Carries importedName onto ImportEdge.targetExportedName for namespace edges'
|
||||
|
||||
related_symbols:
|
||||
- symbol: 'resolveQualifiedReceiverMember'
|
||||
relationship: 'ScopeResolver hook, C++-only implementer'
|
||||
relevance: 'Case 1.5 — deliberately NOT the fix path; implementing it for Python would duplicate what Case 1 already does'
|
||||
- symbol: 'resolveConstructionExpressionClass'
|
||||
relationship: 'consumes namespaceTargets by parameter'
|
||||
relevance: 'Second consumer of the map; gains correct pkg.db.Model() resolution'
|
||||
- symbol: 'resolveCompoundReceiverClass'
|
||||
relationship: 'consumes namespaceTargets by parameter (compound-receiver.ts:759-766)'
|
||||
relevance: 'Third consumer; has() now true for dotted namespaces'
|
||||
- symbol: 'isNamespaceImport'
|
||||
relationship: 'finalize hook added by #2770'
|
||||
relevance: 'Prior art — how the from-pkg-import-db sibling was made to resolve'
|
||||
- symbol: 'build'
|
||||
relationship: 'test-of collectNamespaceTargets'
|
||||
relevance: 'test/unit/scope-resolution/python/python-module-namespace-construction.test.ts — re-run after the change'
|
||||
|
||||
execution_path:
|
||||
- 'splitImportStatement emits one match per imported name; @import.source = full dotted_name text'
|
||||
- 'interpretPythonImport plain arm → ParsedImport{kind:namespace, localName:first-segment, importedName:full-dotted}'
|
||||
- 'finalizeImportEdges → ImportEdge{localName, targetExportedName=importedName, targetFile, kind:namespace}'
|
||||
- 'collectNamespaceTargets builds Map keyed on localName only ← DEFECT'
|
||||
- 'scope-extractor extractExplicitReceiver takes raw text of the attribute object → "pkg.db"'
|
||||
- 'emitReceiverBoundCalls Case 0 declines (module, not class), falls through without marking handled'
|
||||
- 'Case 1 map lookup on "pkg.db" misses; Case 1.5 skipped (no Python hook); site drops silently'
|
||||
|
||||
pdg_constraints: [] # index has zero CDG rows; no --pdg layer to slice
|
||||
|
||||
architectural_patterns:
|
||||
- pattern: 'Provider reclassification at finalize instead of shared-code special-casing'
|
||||
example_location: 'gitnexus-shared/src/scope-resolution/finalize-algorithm.ts:99-107 (isNamespaceImport, #2770)'
|
||||
usage_guidance: 'Considered and rejected here: the information needed is already on the finalized edge, so no new hook is warranted'
|
||||
- pattern: 'Verified namespace is authoritative — do not fall through to workspace-wide simple-name heuristics'
|
||||
example_location: 'gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts:245-259'
|
||||
usage_guidance: 'Because that branch declines rather than guessing, a wrong key costs a lost edge, not a wrong one — but the shadow guard must be right'
|
||||
- pattern: 'Fixture + assertions in test/integration/resolvers/python.test.ts'
|
||||
example_location: 'gitnexus/test/integration/resolvers/python.test.ts:562-600 (vendored-django guard)'
|
||||
usage_guidance: 'mkdtempSync + writeFixtureRepo + afterAll rmSync; assert both presence of the right edge and absence of the wrong one'
|
||||
|
||||
files_to_modify:
|
||||
- file: 'gitnexus/src/core/ingestion/scope-resolution/scope/namespace-targets.ts'
|
||||
symbols: ['collectNamespaceTargets']
|
||||
intended_change: 'Additionally key the map on edge.targetExportedName when it contains a dot and its first segment equals edge.localName; keep the existing localName key unchanged; update the header contract note'
|
||||
- file: 'gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts'
|
||||
symbols: ['isNamespaceNameShadowed']
|
||||
intended_change: 'Shadow-test the first dot-separated segment of namespaceName (no-op for single-segment names)'
|
||||
- file: 'gitnexus/test/integration/resolvers/python.test.ts'
|
||||
symbols: []
|
||||
intended_change: 'Add a describe block covering the six §8 scenarios'
|
||||
- file: 'gitnexus/test/fixtures/lang-resolution/python-dotted-namespace-import/'
|
||||
symbols: []
|
||||
intended_change: 'New fixture per the §8 table'
|
||||
- file: 'gitnexus/test/integration/resolvers/repro-2826-python-dotted-import.test.ts'
|
||||
symbols: []
|
||||
intended_change: 'Delete — superseded by the fixture-backed tests'
|
||||
|
||||
tests:
|
||||
- file: 'gitnexus/test/integration/resolvers/python.test.ts'
|
||||
scenarios:
|
||||
- 'import pkg.db + pkg.db.session_scope() → run pipeline → CALLS uses_dotted → pkg/db.py:session_scope, reason import-resolved'
|
||||
- 'three sibling spellings in the same repo → run pipeline → all still resolve to pkg/db.py:session_scope (control)'
|
||||
- 'import pkg.db AND import pkg.cache in one file, both defining session_scope → each call resolves only to its own module; assert the crossed pair is ABSENT'
|
||||
- 'import pkg.sub.deep + pkg.sub.deep.f() → 3-segment receiver resolves'
|
||||
- 'module-level import pkg.db shadowed by a function-local pkg = Decoy() → NO edge to pkg/db.py'
|
||||
- 'existing TypeScript/C#/Go namespace-import resolver tests → unchanged green (no key minted for them)'
|
||||
- file: 'gitnexus/test/unit/scope-resolution/python/python-module-namespace-construction.test.ts'
|
||||
scenarios:
|
||||
- 'Re-run unchanged; extend only if it enumerates map keys exhaustively'
|
||||
|
||||
verification_commands:
|
||||
- 'cd gitnexus && GITNEXUS_WORKER_READY_TIMEOUT_MS=60000 npm run test:integration -- test/integration/resolvers/python.test.ts'
|
||||
- 'cd gitnexus && GITNEXUS_WORKER_READY_TIMEOUT_MS=60000 npm run test:integration -- test/integration/resolvers'
|
||||
- 'cd gitnexus && npm run test:unit -- test/unit/scope-resolution'
|
||||
- 'cd gitnexus && npx tsc --noEmit'
|
||||
- 'cd gitnexus-shared && npx tsc --noEmit'
|
||||
- 'cd gitnexus && node --import tsx bench/receiver-resolution/measure.mjs --check'
|
||||
- 'cd gitnexus && node --import tsx bench/python-scope/measure.mjs --check'
|
||||
- 'cd gitnexus && node --import tsx bench/python-scope/import-target-fingerprint.mjs --check'
|
||||
- 'cd gitnexus && node --import tsx bench/scope-capture/measure.mjs --check'
|
||||
|
||||
risks:
|
||||
- 'New map keys reach three consumers, two of them by parameter rather than by call — the graph d=1 list alone under-reports them'
|
||||
- 'compound-receiver treats a verified namespace as authoritative and declines instead of falling through, so a bad key loses edges silently'
|
||||
- 'bench/receiver-resolution/baseline.json is expected to move; regenerate ONCE in the final step'
|
||||
- 'Default 5000 ms worker-ready timeout crash-loops on this host; export GITNEXUS_WORKER_READY_TIMEOUT_MS=60000'
|
||||
|
||||
assumptions:
|
||||
- 'Every non-Python provider fails the dotted-rooted-at-localName predicate. CHECK: grep "kind: .namespace." across gitnexus/src/core/ingestion/languages/*/interpret.ts and confirm localName is never the first segment of a dotted importedName. Verified at b2cd1c2ad for typescript, csharp, go, rust, ruby.'
|
||||
- 'python.test.ts is no longer gated behind REGISTRY_PRIMARY_PYTHON. CHECK: grep REGISTRY_PRIMARY in that file — zero hits at b2cd1c2ad, so it runs unconditionally.'
|
||||
- 'The GitNexus index is 13 commits behind but byte-identical on every cited path. CHECK: git rev-parse 1ef6447e:<path> vs b2cd1c2ad:<path>.'
|
||||
|
||||
open_questions:
|
||||
- 'Should the misleading localName-only key for a dotted import (pkg → pkg/db.py) be removed? It can produce a false positive today: pkg.helper() resolves into pkg/db.py if db.py happens to define helper. Deferred — a separate behaviour change needing its own regression pass.'
|
||||
- 'Python`s `import a.b.c` also makes `a.b` reachable. The proposed predicate keys only the exact imported path, so `a.b.f()` under `import a.b.c` alone stays unresolved. Deferred as a narrower follow-up.'
|
||||
- 'C# `using System.Collections.Generic` + `System.Collections.Generic.List` is the same class of gap and is deliberately NOT addressed here (its localName is the last segment, so the predicate declines). Worth its own issue.'
|
||||
|
||||
avoid:
|
||||
- 'Do not repeat full repository discovery'
|
||||
- 'Do not replace established patterns without evidence'
|
||||
- 'Do not implement resolveQualifiedReceiverMember for Python — Case 1 already does this job; a second path would double-resolve'
|
||||
- 'Do not change ImportEdge.localName for dotted imports (interpret.ts:38) — it is the deliberate `import a.b.c exposes a` semantics and other consumers depend on it'
|
||||
- 'Do not name a language in gitnexus/src/core/ingestion/ shared code (AGENTS.md §42)'
|
||||
- 'Do not regenerate bench baselines per step — only once, in the final step'
|
||||
- 'Do not weaken an existing test to accommodate the new keys; extend it instead'
|
||||
```
|
||||
|
||||
## 12. Assumptions and Open Questions
|
||||
|
||||
**Assumptions** (each re-checkable cheaply by the executor):
|
||||
|
||||
1. Every non-Python namespace-emitting provider fails the `dotted && first segment === localName` predicate. Verified at `b2cd1c2ad` for TypeScript, C#, Go, Rust and Ruby by reading each `interpret.ts`; JavaScript, Java and PHP emit no `kind: 'namespace'` import there. **Re-check:** grep `kind: 'namespace'` across `gitnexus/src/core/ingestion/languages/*/interpret.ts`.
|
||||
2. `python.test.ts` runs unconditionally — no `REGISTRY_PRIMARY_PYTHON` gate remains at the pinned commit (zero grep hits). An older parity-leg convention no longer applies.
|
||||
3. The index's 13-commit lag is harmless here because every cited path is byte-identical at the index commit and the pinned commit.
|
||||
|
||||
**Open questions / explicitly deferred:**
|
||||
|
||||
- **The bogus first-segment key.** For `import pkg.db`, the map still holds `'pkg' → ['pkg/db.py']`, so `pkg.helper()` would resolve into `pkg/db.py` if that file happens to define `helper` — a pre-existing false positive this plan does **not** fix. Removing it is a separate behaviour change with its own regression surface (`python-multi-segment-ancestor-import`, `python-bare-import`). Worth pinning the current behaviour in a test so it is visible rather than silent.
|
||||
- **`import a.b.c` also binds `a.b`.** Python makes intermediate packages reachable; the proposed predicate keys only the exact imported path, so `a.b.f()` under `import a.b.c` alone stays unresolved. Narrower follow-up.
|
||||
- **C# has the mirror-image gap.** `using System.Collections.Generic` + `System.Collections.Generic.List` fails the predicate because C# sets `localName` to the *last* segment. Deliberately out of scope; deserves its own issue.
|
||||
- **Construction coverage.** §8 does not currently include a `pkg.db.Model()` row. Add one if the fixture cost is trivial — that path (`compound-receiver.ts:245-260`) changes behaviour and is otherwise untested by this plan.
|
||||
|
||||
## 13. Definition of Done
|
||||
|
||||
1. `CALLS` edge `uses_dotted` → `pkg/db.py:session_scope` (`reason: 'import-resolved'`) is emitted, asserted by a fixture-backed test in `python.test.ts`.
|
||||
2. All three sibling control rows still resolve in the same run.
|
||||
3. `import pkg.a` + `import pkg.b` in one file resolve only to their own modules; the crossed pair is asserted **absent**.
|
||||
4. A 3-segment receiver resolves; a package root shadowed by a local binding does **not**.
|
||||
5. The scratch `repro-2826-python-dotted-import.test.ts` is deleted.
|
||||
6. No file under `gitnexus/src/core/ingestion/` names a language.
|
||||
7. `npm run test:integration -- test/integration/resolvers` and `npm run test:unit -- test/unit/scope-resolution` pass; `tsc --noEmit` clean in both packages.
|
||||
8. Every bench `--check` in §8 passes, with `bench/receiver-resolution/baseline.json` regenerated exactly once in the final commit if and only if it moved — and any movement in `python-scope`/`scope-capture` investigated rather than regenerated.
|
||||
|
|
@ -5,9 +5,19 @@
|
|||
* 1. Global `gitnexus` on PATH (best — no install step)
|
||||
* 2. npm 11+ with pnpm on PATH → `pnpm --allow-build=… dlx` (avoids the npx
|
||||
* arborist crash *and* pnpm 10+ ignored-build-script failures, #1939)
|
||||
* 3. npm < 11 with npm on PATH → `npx` (works; simpler than pnpm dlx)
|
||||
* 4. pnpm-only → `pnpm --allow-build=… dlx`
|
||||
* 5. Last resort → `npx` (warned on npm 11+ from analyze.ts)
|
||||
* 3. npm 11+ without pnpm but with bunx → `bunx` (dodges the same crash)
|
||||
* 4. npm < 11 with npm on PATH → `npx` (works; simpler than pnpm dlx)
|
||||
* 5. pnpm-only → `pnpm --allow-build=… dlx`
|
||||
* 6. bun-only → `bunx`
|
||||
* 7. Last resort → `npx` (warned on npm 11+ from analyze.ts)
|
||||
*
|
||||
* The bun branches exist because a Node toolchain is no longer implied: on a
|
||||
* bun-only machine npm, npx and pnpm are all absent, so every rung above
|
||||
* resolved to `npx` and the emitted command could not run at all. `bunx` is
|
||||
* bun's install-free one-shot runner and needs no allow-build equivalent — bun
|
||||
* skips lifecycle scripts unconditionally for a `bunx` fetch, which the native
|
||||
* loader recovers from directly (see core/lbug/native-check.ts). Both bun rungs
|
||||
* gate on `bunx` actually running, not merely existing on PATH — see `hasBun`.
|
||||
*
|
||||
* The `--allow-build` flags MUST precede the `dlx` token. pnpm < 10.14 keeps
|
||||
* `dlx` in its argv escape list, so flags placed *after* `dlx` are parsed as
|
||||
|
|
@ -42,7 +52,12 @@ const PNPM_ALLOW_BUILD_EMBEDDINGS = ['onnxruntime-node'];
|
|||
// hook first runs `git rev-parse --git-common-dir` (~2s) and `git rev-parse HEAD`
|
||||
// (~3s); the pnpm path then adds up to two 1s `--version` probes (npm, pnpm), so
|
||||
// the worst case is ~7s — within budget. A healthy `--version` returns in well
|
||||
// under a second, so the realistic cost is far lower.
|
||||
// under a second, so the realistic cost is far lower. The bun rungs add at most
|
||||
// one more 1s probe (`bunx --version`), reached only when pnpm is unusable and
|
||||
// npm is 11+ or unreadable, for a ~8s theoretical cap. That cap needs an absent
|
||||
// pnpm to burn its full second, which only Windows can do (`shell: true` spawns
|
||||
// cmd.exe); on POSIX an absent pnpm ENOENTs in ~1ms, so the real ceiling is
|
||||
// unmoved.
|
||||
const PROBE_TIMEOUT_MS = 1000;
|
||||
|
||||
/**
|
||||
|
|
@ -104,9 +119,17 @@ function resolveOnPath(
|
|||
return weakHit;
|
||||
}
|
||||
|
||||
// One spawn of `<command> --version` → { major, minor } (each null when
|
||||
// One spawn of `<command> --version` → { ran, major, minor } (versions null when
|
||||
// unreadable). Version injection happens at the resolver seam (getNpmMajorVersion
|
||||
// / formatPnpmAllowBuildArgs), so this stays a pure real-process probe.
|
||||
//
|
||||
// `ran` is liveness, kept separate from the version because a PATH hit proves a
|
||||
// file exists, not that it works, and the two answers differ: a banner-printing
|
||||
// or oddly-versioned tool is alive with `major: null`, while a stale shim left by
|
||||
// a partial uninstall is neither. Only the bun rung consults `ran` today (see
|
||||
// hasBun) — it is the one runner with no version to read, so a dedicated probe is
|
||||
// its only liveness signal; pnpm gets the same evidence for free from the version
|
||||
// spawn it must make anyway, and deliberately forgives an unreadable one (#1939).
|
||||
function probeVersion(command) {
|
||||
try {
|
||||
const output = execFileSync(command, ['--version'], {
|
||||
|
|
@ -131,11 +154,13 @@ function probeVersion(command) {
|
|||
.find((l) => /^v?\d+\.\d+/.test(l));
|
||||
const match = versionLine ? versionLine.match(/^v?(\d+)\.(\d+)/) : null;
|
||||
return {
|
||||
ran: true,
|
||||
major: match ? Number(match[1]) : null,
|
||||
minor: match ? Number(match[2]) : null,
|
||||
};
|
||||
} catch {
|
||||
return { major: null, minor: null };
|
||||
// Spawn failure, non-zero exit, or the timeout — the command did not run.
|
||||
return { ran: false, major: null, minor: null };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -176,14 +201,14 @@ function formatDocumentationDlxCommand(gitnexusArgs, options = {}) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Resolve `gitnexus` | `pnpm` | `npx`. `GITNEXUS_INVOCATION` forces a mode
|
||||
* (test/escape hatch). `probe` is injectable so the preference order can be
|
||||
* Resolve `gitnexus` | `pnpm` | `bun` | `npx`. `GITNEXUS_INVOCATION` forces a
|
||||
* mode (test/escape hatch). `probe` is injectable so the preference order can be
|
||||
* unit-tested without spawning; it defaults to the real PATH probe. `deps` can
|
||||
* inject `{ npmMajor, pnpmMajor }` for tests.
|
||||
* inject `{ npmMajor, pnpmMajor, bunPresent, bunRuns }` for tests.
|
||||
*/
|
||||
function resolveInvocationMode(probe = resolveOnPath, deps = {}) {
|
||||
const forced = process.env.GITNEXUS_INVOCATION?.trim().toLowerCase();
|
||||
if (forced === 'gitnexus' || forced === 'pnpm' || forced === 'npx') {
|
||||
if (forced === 'gitnexus' || forced === 'pnpm' || forced === 'npx' || forced === 'bun') {
|
||||
return forced;
|
||||
}
|
||||
if (probe('gitnexus', true)) return 'gitnexus';
|
||||
|
|
@ -202,12 +227,33 @@ function resolveInvocationMode(probe = resolveOnPath, deps = {}) {
|
|||
? deps.pnpmMajor !== null
|
||||
: Boolean(probe('pnpm'));
|
||||
|
||||
// bun usability is resolved lazily: only the two branches below can select it,
|
||||
// so a machine with pnpm, or with npm < 11, never pays the PATH scan or the
|
||||
// spawn. `bunx` (not `bun`) is probed because `bunx` is what the resolved
|
||||
// command actually runs. Two gates, `&&`-ordered cheapest first: a spawn-free
|
||||
// PATH scan, then liveness — a PATH hit alone would route a present-but-broken
|
||||
// shim to a command that can only fail at execution time.
|
||||
let bunCache;
|
||||
const hasBun = () => {
|
||||
if (bunCache === undefined) {
|
||||
const present = 'bunPresent' in deps ? Boolean(deps.bunPresent) : Boolean(probe('bunx'));
|
||||
bunCache = present && ('bunRuns' in deps ? Boolean(deps.bunRuns) : probeVersion('bunx').ran);
|
||||
}
|
||||
return bunCache;
|
||||
};
|
||||
|
||||
// npm 11+ npx install crash (#1939) — prefer pnpm dlx when available.
|
||||
if (hasPnpm && npmMajor !== null && npmMajor >= 11) return 'pnpm';
|
||||
// Same crash, no pnpm to fall back on: bunx is install-free and unaffected.
|
||||
if (npmMajor !== null && npmMajor >= 11 && hasBun()) return 'bun';
|
||||
// npm 10 and earlier: npx works; prefer it over pnpm dlx when npm is present.
|
||||
if (npmMajor !== null && npmMajor < 11) return 'npx';
|
||||
// npm absent or unreadable — use pnpm if present (with allow-build flags).
|
||||
if (hasPnpm) return 'pnpm';
|
||||
// Neither npm nor pnpm — bunx is the only install-free runner left. Without
|
||||
// this rung a bun-only machine fell through to `npx`, which is not installed
|
||||
// there, so the emitted command failed with "npx: command not found".
|
||||
if (hasBun()) return 'bun';
|
||||
|
||||
return 'npx';
|
||||
}
|
||||
|
|
@ -218,6 +264,17 @@ function formatPnpmDlxCommand(gitnexusArgs, options = {}, deps = {}) {
|
|||
return `pnpm ${prefix}dlx ${NPX_REF} ${gitnexusArgs}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* bun's install-free one-shot runner. Deliberately flag-free: bun has no
|
||||
* per-invocation `--allow-build` equivalent (`--trust` is a `bun add`/`bun
|
||||
* install` flag that writes trustedDependencies into a project package.json,
|
||||
* which a one-shot `bunx` has nowhere to put), so the skipped lifecycle copy is
|
||||
* recovered by the native loader instead of by the invocation.
|
||||
*/
|
||||
function formatBunxCommand(gitnexusArgs) {
|
||||
return `bunx ${NPX_REF} ${gitnexusArgs}`;
|
||||
}
|
||||
|
||||
function formatAnalyzeCommand(options = {}, deps = {}) {
|
||||
const suffix = options.embeddings ? ' --embeddings' : '';
|
||||
// Keep the stale-index hook budget tight by querying each tool at most once.
|
||||
|
|
@ -238,7 +295,8 @@ function formatAnalyzeCommand(options = {}, deps = {}) {
|
|||
const forced = process.env.GITNEXUS_INVOCATION?.trim().toLowerCase();
|
||||
// pnpm is only consulted when no non-pnpm mode is already certain: forced
|
||||
// gitnexus/npx never use pnpm, and a present global gitnexus wins outright.
|
||||
const mightUsePnpm = forced === 'pnpm' || (forced !== 'gitnexus' && forced !== 'npx');
|
||||
const mightUsePnpm =
|
||||
forced === 'pnpm' || (forced !== 'gitnexus' && forced !== 'npx' && forced !== 'bun');
|
||||
if (mightUsePnpm && (forced === 'pnpm' || !probe('gitnexus', true))) {
|
||||
const { major, minor } = probeVersion('pnpm');
|
||||
// Carry presence separately from version: when the version probe fails
|
||||
|
|
@ -252,6 +310,7 @@ function formatAnalyzeCommand(options = {}, deps = {}) {
|
|||
const mode = resolveInvocationMode(probe, resolved);
|
||||
if (mode === 'gitnexus') return `gitnexus analyze${suffix}`;
|
||||
if (mode === 'pnpm') return `${formatPnpmDlxCommand(`analyze${suffix}`, options, resolved)}`;
|
||||
if (mode === 'bun') return formatBunxCommand(`analyze${suffix}`);
|
||||
return `npx ${NPX_REF} analyze${suffix}`;
|
||||
}
|
||||
|
||||
|
|
@ -268,6 +327,7 @@ function buildRunnerArgv(mode, gitnexusArgs, deps = {}) {
|
|||
(a) => a === '--embeddings' || a.startsWith('--embeddings='),
|
||||
);
|
||||
if (mode === 'gitnexus') return { program: 'gitnexus', args: [...gitnexusArgs] };
|
||||
if (mode === 'bun') return { program: 'bunx', args: [NPX_REF, ...gitnexusArgs] };
|
||||
if (mode === 'pnpm') {
|
||||
return {
|
||||
program: 'pnpm',
|
||||
|
|
@ -279,6 +339,7 @@ function buildRunnerArgv(mode, gitnexusArgs, deps = {}) {
|
|||
|
||||
module.exports = {
|
||||
formatAnalyzeCommand,
|
||||
formatBunxCommand,
|
||||
formatDocumentationDlxCommand,
|
||||
formatPnpmAllowBuildArgs,
|
||||
formatPnpmDlxCommand,
|
||||
|
|
@ -291,7 +352,7 @@ module.exports = {
|
|||
};
|
||||
|
||||
// Direct-exec entrypoint (#1945): `node run.cjs <gitnexus args…>` resolves the
|
||||
// best available runner (global `gitnexus` → `pnpm dlx` → `npx`) at call time and
|
||||
// best available runner (global `gitnexus` → `pnpm dlx` → `bunx` → `npx`) at call time and
|
||||
// runs it, inheriting stdio and propagating the child's exit code. This lets the
|
||||
// committed skills and generated AGENTS.md/CLAUDE.md reference ONE stable,
|
||||
// CLI-neutral command without baking in a package-manager assumption. `gitnexus
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ description: "Use when the user needs to run GitNexus CLI commands like analyze/
|
|||
|
||||
# GitNexus CLI Commands
|
||||
|
||||
Commands below use `node .gitnexus/run.cjs <command>` — the project-local runner `gitnexus analyze` drops next to the index. It auto-selects an available runner at call time (global `gitnexus`, else `pnpm dlx`, else `npx`), so no package-manager assumption and no global install is required.
|
||||
Commands below use `node .gitnexus/run.cjs <command>` — the project-local runner `gitnexus analyze` drops next to the index. It auto-selects an available runner at call time (global `gitnexus`, else `pnpm dlx`, else `bunx`, else `npx`), so no package-manager assumption and no global install is required — including on a bun-only machine, which has no npm, npx or pnpm at all.
|
||||
|
||||
> **Not analyzed yet, or `node .gitnexus/run.cjs` reports `Cannot find module`** (the gitignored runner is absent — e.g. a fresh clone or `git clean`)? (Re)generate it with `npx gitnexus analyze` from the project root. On **npm 11.x**, if `npx` crashes during install (`node.target is null`), install once with `npm i -g gitnexus` (then `gitnexus analyze`) or use `pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze`. See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939).
|
||||
> **Not analyzed yet, or `node .gitnexus/run.cjs` reports `Cannot find module`** (the gitignored runner is absent — e.g. a fresh clone or `git clean`)? (Re)generate it with `npx gitnexus analyze` from the project root, or `bunx gitnexus@latest analyze` on a bun-only machine. On **npm 11.x**, if `npx` crashes during install (`node.target is null`), install once with `npm i -g gitnexus` (then `gitnexus analyze`), or use `bunx gitnexus@latest analyze`, or `pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze`. See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939).
|
||||
|
||||
## Commands
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,14 @@ description: "Use when the user wants to know what will break if they change som
|
|||
| 5-15 symbols, 2-5 processes | MEDIUM |
|
||||
| >15 symbols or many processes | HIGH |
|
||||
| Critical path (auth, payments) | CRITICAL |
|
||||
| **Zero callers found** | **UNKNOWN** |
|
||||
|
||||
`UNKNOWN` is not a low rung on this scale — it means the walk could not answer.
|
||||
An empty caller set is equally consistent with "genuinely unused" and "the
|
||||
callers are not resolvable by the index" (plain-object property access, dynamic
|
||||
dispatch, cross-language calls), so few-callers ⇒ LOW does **not** apply. The
|
||||
result carries a `riskNote` saying so. Confirm with a text search before
|
||||
treating the symbol as safe to change or delete.
|
||||
|
||||
## Tools
|
||||
|
||||
|
|
|
|||
|
|
@ -148,13 +148,19 @@ finding is NOT proof of safety.
|
|||
|
||||
## Adding a source / sink / sanitizer
|
||||
|
||||
Edit the language model in `taint/typescript-model.ts` (registered via the
|
||||
explicit `registerBuiltinTaintModels` seam, keyed by `SupportedLanguages`). The
|
||||
spec is hashable data (no functions). A sanitizer's `neutralizes` lists the
|
||||
EXACT sink kinds it defends — never a blanket kill. Add a fixture + assert the
|
||||
finding (or its absence) in `test/unit/taint/` (real-source harness:
|
||||
`test/helpers/ts-cfg-harness.ts`); the end-to-end proof is
|
||||
`test/integration/cfg/`.
|
||||
Taint models cover four `SupportedLanguages` ids across three files:
|
||||
TypeScript and JavaScript use `taint/typescript-model.ts`, Python uses
|
||||
`taint/python-model.ts`, and Java uses `taint/java-model.ts`. Edit the model
|
||||
for the language you are targeting. The explicit
|
||||
`registerBuiltinTaintModels` seam in `typescript-model.ts` registers all four;
|
||||
it is not an import side effect.
|
||||
|
||||
The spec is hashable data (no functions). A sanitizer's `neutralizes` lists
|
||||
the EXACT sink kinds it defends — never a blanket kill. Add a fixture + assert
|
||||
the finding (or its absence) in `test/unit/taint/`. TypeScript and JavaScript
|
||||
use the real-source harness `test/helpers/ts-cfg-harness.ts`; Python and Java
|
||||
model matches are covered by `python-model-match.test.ts` and
|
||||
`java-model-match.test.ts`. The end-to-end proof is `test/integration/cfg/`.
|
||||
|
||||
## Validation checklist for any `--pdg` change
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,14 @@ description: Analyze blast radius before making code changes
|
|||
| 5-15 symbols, 2-5 processes | MEDIUM |
|
||||
| >15 symbols or many processes | HIGH |
|
||||
| Critical path (auth, payments) | CRITICAL |
|
||||
| **Zero callers found** | **UNKNOWN** |
|
||||
|
||||
`UNKNOWN` is not a low rung on this scale — it means the walk could not answer.
|
||||
An empty caller set is equally consistent with "genuinely unused" and "the
|
||||
callers are not resolvable by the index" (plain-object property access, dynamic
|
||||
dispatch, cross-language calls), so few-callers ⇒ LOW does **not** apply. The
|
||||
result carries a `riskNote` saying so. Confirm with a text search before
|
||||
treating the symbol as safe to change or delete.
|
||||
|
||||
## Tools
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,11 @@ export type { PipelinePhase, PipelineProgress } from './pipeline.js';
|
|||
|
||||
// ─── Scope-based resolution — RFC #909 (Ring 1 #910) ────────────────────────
|
||||
// Data model (RFC §2)
|
||||
export type { ParameterTypeClass, SymbolDefinition } from './scope-resolution/symbol-definition.js';
|
||||
export type {
|
||||
ParameterTypeClass,
|
||||
SymbolDefinition,
|
||||
TypeParameter,
|
||||
} from './scope-resolution/symbol-definition.js';
|
||||
export type {
|
||||
ScopeId,
|
||||
DefId,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,38 @@ export interface ParameterTypeClass {
|
|||
templateArguments?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One declared generic/template TYPE PARAMETER — `T` in `class Box<T extends
|
||||
* Repo>`, `template <class T> struct Vec`, `interface Repo<T>`.
|
||||
*
|
||||
* NOT the same axis as `SymbolDefinition.templateArguments`, and conflating the
|
||||
* two is the defect this shape exists to end. `templateArguments` records the
|
||||
* arguments a declaration was written AGAINST (`template <> struct Vec<bool>` →
|
||||
* `['bool']`); `typeParameters` records the parameters it was written IN TERMS
|
||||
* OF. A declaration can carry both — a C++ partial specialization
|
||||
* `template <class T> struct Vec<T*>` has `templateArguments: ['T*']` AND
|
||||
* `typeParameters: [{name: 'T'}]` — and that pairing is precisely what tells a
|
||||
* partial specialization apart from the full specialization `template <> struct
|
||||
* Vec<T*>`, which carries the identical `templateArguments` and NO parameters.
|
||||
*/
|
||||
export interface TypeParameter {
|
||||
/** The parameter's declared name, exactly as written (`T`, `Ts`, `TKey`). */
|
||||
name: string;
|
||||
/**
|
||||
* The declared upper bound / constraint, verbatim and un-split, when the
|
||||
* declaration states one inline: `T extends Repo` → `Repo`, `T : Repo` →
|
||||
* `Repo`, `T extends Repo & Closeable` → `Repo & Closeable`.
|
||||
*
|
||||
* VERBATIM because the intersection/compound spellings differ per language
|
||||
* and a shared consumer that wants the first bound can take the first token
|
||||
* itself, while one that wants to round-trip the source cannot recover what a
|
||||
* split threw away. Absent when the parameter is unbounded, and absent when
|
||||
* the bound is declared OUT OF LINE (C# `where T : IRepo`, Kotlin/Rust
|
||||
* `where` clauses) — see `parseTypeParameterList`.
|
||||
*/
|
||||
bound?: string;
|
||||
}
|
||||
|
||||
export interface SymbolDefinition {
|
||||
nodeId: string;
|
||||
filePath: string;
|
||||
|
|
@ -48,6 +80,18 @@ export interface SymbolDefinition {
|
|||
declaredType?: string;
|
||||
/** Generic/template specialization arguments for class-like symbols (e.g. ['User'], ['T*']). */
|
||||
templateArguments?: string[];
|
||||
/**
|
||||
* Declared generic/template TYPE PARAMETERS, in DECLARATION ORDER — see
|
||||
* {@link TypeParameter} for how this differs from `templateArguments`.
|
||||
*
|
||||
* ORDER IS LOAD-BEARING: substitution is positional (`Repo<User>` binds the
|
||||
* FIRST parameter), so a set or a name-keyed map would discard exactly the
|
||||
* information this carries. Absent for a non-generic declaration and for every
|
||||
* language whose captures do not populate it, so a reader MUST treat absence
|
||||
* as "unknown", never as "not generic" — the two are indistinguishable here
|
||||
* and only the first is safe to act on.
|
||||
*/
|
||||
typeParameters?: TypeParameter[];
|
||||
/** Per-language constraint payload for template / generic overloads
|
||||
* (e.g. C++ `enable_if_t<P, T>` predicate trees, C++20 `requires` clauses).
|
||||
* Opaque to shared code — the producing language adapter owns the shape
|
||||
|
|
|
|||
271
gitnexus-web/package-lock.json
generated
271
gitnexus-web/package-lock.json
generated
|
|
@ -15,10 +15,10 @@
|
|||
"@langchain/ollama": "^1.3.0",
|
||||
"@langchain/openai": "^1.5.3",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"axios": "^1.18.1",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.4.12",
|
||||
"dompurify": "^3.4.13",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-indices": "^0.17.0",
|
||||
|
|
@ -31,24 +31,24 @@
|
|||
"langchain": "^1.4.6",
|
||||
"lru-cache": "^11.5.2",
|
||||
"lucide-react": "^1.23.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"mermaid": "^11.16.1",
|
||||
"mnemonist": "^0.40.4",
|
||||
"pandemonium": "^2.4.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-i18next": "^17.0.10",
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-zoom-pan-pinch": "^4.0.3",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sigma": "^3.0.3",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"uuid": "^14.0.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^8.0.4",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@playwright/test": "^1.62.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
|
|
@ -65,7 +65,7 @@
|
|||
"typescript": "^5.4.5",
|
||||
"vite": "^8.1.5",
|
||||
"vitest": "^4.1.10",
|
||||
"wait-on": "^9.0.10"
|
||||
"wait-on": "^9.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
|
|
@ -289,9 +289,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@braintree/sanitize-url": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.1.tgz",
|
||||
"integrity": "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==",
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz",
|
||||
"integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@bramus/specificity": {
|
||||
|
|
@ -1330,12 +1330,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@mermaid-js/parser": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz",
|
||||
"integrity": "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==",
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.0.tgz",
|
||||
"integrity": "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@chevrotain/types": "~11.1.1"
|
||||
"@chevrotain/types": "~11.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
|
|
@ -1404,19 +1404,19 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
|
||||
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
|
||||
"integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.61.1"
|
||||
"playwright": "1.62.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@renovatebot/pep440": {
|
||||
|
|
@ -1741,47 +1741,47 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz",
|
||||
"integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
|
||||
"integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"enhanced-resolve": "5.21.6",
|
||||
"enhanced-resolve": "^5.24.1",
|
||||
"jiti": "^2.7.0",
|
||||
"lightningcss": "1.32.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"source-map-js": "^1.2.1",
|
||||
"tailwindcss": "4.3.2"
|
||||
"tailwindcss": "4.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz",
|
||||
"integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
|
||||
"integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tailwindcss/oxide-android-arm64": "4.3.2",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.3.2",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.3.2",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.3.2",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.2",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.3.2",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.3.2",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.3.2",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.3.2",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.2",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.3.2"
|
||||
"@tailwindcss/oxide-android-arm64": "4.3.3",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.3.3",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.3.3",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.3.3",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.3.3",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz",
|
||||
"integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
|
||||
"integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1795,9 +1795,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz",
|
||||
"integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
|
||||
"integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1811,9 +1811,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz",
|
||||
"integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
|
||||
"integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1827,9 +1827,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz",
|
||||
"integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
|
||||
"integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1843,9 +1843,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz",
|
||||
"integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
|
||||
"integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -1859,9 +1859,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz",
|
||||
"integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
|
||||
"integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1878,9 +1878,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz",
|
||||
"integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
|
||||
"integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1897,9 +1897,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz",
|
||||
"integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
|
||||
"integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1916,9 +1916,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz",
|
||||
"integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
|
||||
"integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1935,9 +1935,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz",
|
||||
"integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
|
||||
"integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
|
||||
"bundleDependencies": [
|
||||
"@napi-rs/wasm-runtime",
|
||||
"@emnapi/core",
|
||||
|
|
@ -2024,9 +2024,9 @@
|
|||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz",
|
||||
"integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
||||
"integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -2040,9 +2040,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz",
|
||||
"integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
|
||||
"integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -2056,14 +2056,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/vite": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz",
|
||||
"integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz",
|
||||
"integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tailwindcss/node": "4.3.2",
|
||||
"@tailwindcss/oxide": "4.3.2",
|
||||
"tailwindcss": "4.3.2"
|
||||
"@tailwindcss/node": "4.3.3",
|
||||
"@tailwindcss/oxide": "4.3.3",
|
||||
"tailwindcss": "4.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
|
|
@ -3458,9 +3458,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cytoscape": {
|
||||
"version": "3.33.1",
|
||||
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz",
|
||||
"integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==",
|
||||
"version": "3.34.0",
|
||||
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.34.0.tgz",
|
||||
"integrity": "sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
|
|
@ -4009,9 +4009,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.19",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
||||
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
|
||||
"version": "1.11.21",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
|
||||
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
|
|
@ -4109,9 +4109,9 @@
|
|||
"peer": true
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.12",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
|
||||
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
|
||||
"version": "3.4.13",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
|
||||
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
|
|
@ -4173,9 +4173,9 @@
|
|||
"license": "ISC"
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.21.6",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
|
||||
"integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==",
|
||||
"version": "5.24.5",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz",
|
||||
"integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
|
|
@ -4899,12 +4899,12 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
|
||||
"integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"void-elements": "3.1.0"
|
||||
"funding": {
|
||||
"url": "https://locize.com"
|
||||
}
|
||||
},
|
||||
"node_modules/html-url-attributes": {
|
||||
|
|
@ -5333,9 +5333,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.16.27",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.27.tgz",
|
||||
"integrity": "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw==",
|
||||
"version": "0.16.47",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
|
||||
"integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==",
|
||||
"funding": [
|
||||
"https://opencollective.com/katex",
|
||||
"https://github.com/sponsors/katex"
|
||||
|
|
@ -6126,26 +6126,26 @@
|
|||
}
|
||||
},
|
||||
"node_modules/mermaid": {
|
||||
"version": "11.15.0",
|
||||
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.15.0.tgz",
|
||||
"integrity": "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==",
|
||||
"version": "11.16.1",
|
||||
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.1.tgz",
|
||||
"integrity": "sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "^7.1.1",
|
||||
"@braintree/sanitize-url": "^7.1.2",
|
||||
"@iconify/utils": "^3.0.2",
|
||||
"@mermaid-js/parser": "^1.1.1",
|
||||
"@mermaid-js/parser": "^1.2.0",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@upsetjs/venn.js": "^2.0.0",
|
||||
"cytoscape": "^3.33.1",
|
||||
"cytoscape": "^3.33.3",
|
||||
"cytoscape-cose-bilkent": "^4.1.0",
|
||||
"cytoscape-fcose": "^2.2.0",
|
||||
"d3": "^7.9.0",
|
||||
"d3-sankey": "^0.12.3",
|
||||
"dagre-d3-es": "7.0.14",
|
||||
"dayjs": "^1.11.19",
|
||||
"dompurify": "^3.3.1",
|
||||
"dayjs": "^1.11.20",
|
||||
"dompurify": "^3.3.3",
|
||||
"es-toolkit": "^1.45.1",
|
||||
"katex": "^0.16.25",
|
||||
"katex": "^0.16.45",
|
||||
"khroma": "^2.1.0",
|
||||
"marked": "^16.3.0",
|
||||
"roughjs": "^4.6.6",
|
||||
|
|
@ -7211,35 +7211,35 @@
|
|||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
|
||||
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.61.1"
|
||||
"playwright-core": "1.62.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
|
||||
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
|
|
@ -7414,13 +7414,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.10",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz",
|
||||
"integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==",
|
||||
"version": "17.0.11",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz",
|
||||
"integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"html-parse-stringify": "^3.0.1",
|
||||
"html-parse-stringify": "^4.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
|
@ -7933,9 +7933,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz",
|
||||
"integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
|
||||
"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
|
|
@ -8527,15 +8527,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
|
|
@ -8550,14 +8541,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/wait-on": {
|
||||
"version": "9.0.10",
|
||||
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.10.tgz",
|
||||
"integrity": "sha512-rCoJEhvMr0X6alHmwc9abbrA5ZrLZFKpFQVKPNFwl2h7DapXOGdmimIHDtLOWhT4PjhZhxFEtZoQgEXbkDWdZw==",
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.1.0.tgz",
|
||||
"integrity": "sha512-PymrLXHLBM1Ju/Xspb2ADUhbPSMvbnuNvy/mN2hWtpbJ3da0h3Ky1LqwKPG5QSVR57liyO0iUpfipYl/s5qNvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.16.0",
|
||||
"joi": "^18.2.1",
|
||||
"axios": "^1.18.1",
|
||||
"joi": "^18.2.3",
|
||||
"lodash": "^4.18.1",
|
||||
"minimist": "^1.2.8",
|
||||
"rxjs": "^7.8.2"
|
||||
|
|
|
|||
|
|
@ -25,10 +25,10 @@
|
|||
"@langchain/ollama": "^1.3.0",
|
||||
"@langchain/openai": "^1.5.3",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"axios": "^1.18.1",
|
||||
"d3": "^7.9.0",
|
||||
"dompurify": "^3.4.12",
|
||||
"dompurify": "^3.4.13",
|
||||
"gitnexus-shared": "file:../gitnexus-shared",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-indices": "^0.17.0",
|
||||
|
|
@ -41,24 +41,24 @@
|
|||
"langchain": "^1.4.6",
|
||||
"lru-cache": "^11.5.2",
|
||||
"lucide-react": "^1.23.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"mermaid": "^11.16.1",
|
||||
"mnemonist": "^0.40.4",
|
||||
"pandemonium": "^2.4.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-i18next": "^17.0.10",
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-zoom-pan-pinch": "^4.0.3",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sigma": "^3.0.3",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"uuid": "^14.0.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^8.0.4",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@playwright/test": "^1.62.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
|
|
@ -75,7 +75,7 @@
|
|||
"typescript": "^5.4.5",
|
||||
"vite": "^8.1.5",
|
||||
"vitest": "^4.1.10",
|
||||
"wait-on": "^9.0.10"
|
||||
"wait-on": "^9.1.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@vercel/static-config": {
|
||||
|
|
|
|||
67
gitnexus-web/src/components/AccessTokenPrompt.tsx
Normal file
67
gitnexus-web/src/components/AccessTokenPrompt.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { useState } from 'react';
|
||||
import { Key } from '@/lib/lucide-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getAuthToken, setAuthToken } from '../services/backend-client';
|
||||
import { SecretInput } from './settings/SecretInput';
|
||||
|
||||
interface AccessTokenPromptProps {
|
||||
/** Called after the token is stored, so the caller can re-probe immediately. */
|
||||
onSubmit?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown instead of the "start a server" guide when the backend answers 401:
|
||||
* the deploy is up, it just needs the access token its operator generated.
|
||||
*
|
||||
* The token is held in sessionStorage for this browser session only — see
|
||||
* AUTH_TOKEN_STORAGE_KEY. Nothing here logs it or puts it in a URL.
|
||||
*/
|
||||
export const AccessTokenPrompt = ({ onSubmit }: AccessTokenPromptProps) => {
|
||||
const { t } = useTranslation('settings');
|
||||
const [token, setToken] = useState(getAuthToken);
|
||||
|
||||
const handleSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setAuthToken(token);
|
||||
onSubmit?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="animate-fade-in rounded-3xl border border-border-default bg-surface p-7"
|
||||
>
|
||||
<div className="mb-5 text-center">
|
||||
<div className="mb-3 inline-flex h-10 w-10 items-center justify-center rounded-xl bg-accent/20">
|
||||
<Key className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
<h2 className="text-lg leading-snug font-semibold text-text-primary">
|
||||
{t('accessToken.title')}
|
||||
</h2>
|
||||
<p className="mx-auto mt-1.5 max-w-sm text-sm leading-relaxed text-text-secondary">
|
||||
{t('accessToken.promptHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SecretInput
|
||||
value={token}
|
||||
onChange={setToken}
|
||||
label={t('accessToken.label')}
|
||||
placeholder={t('accessToken.placeholder')}
|
||||
revealLabel={t('accessToken.reveal')}
|
||||
hideLabel={t('accessToken.hide')}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="mt-4 w-full cursor-pointer rounded-xl bg-accent px-4 py-3 text-sm font-medium text-white shadow-glow-soft transition-all hover:bg-accent/90 hover:shadow-glow"
|
||||
>
|
||||
{t('accessToken.connect')}
|
||||
</button>
|
||||
|
||||
<p className="mt-4 border-t border-border-subtle pt-4 text-center text-xs leading-relaxed text-text-muted">
|
||||
{t('accessToken.sessionNote')}
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
|
@ -7,6 +7,7 @@ import {
|
|||
type BackendRepo,
|
||||
} from '../services/backend-client';
|
||||
import { useBackend } from '../hooks/useBackend';
|
||||
import { AccessTokenPrompt } from './AccessTokenPrompt';
|
||||
import { OnboardingGuide } from './OnboardingGuide';
|
||||
import { AnalyzeOnboarding } from './AnalyzeOnboarding';
|
||||
import { RepoLanding } from './RepoLanding';
|
||||
|
|
@ -147,6 +148,7 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => {
|
|||
const {
|
||||
isConnected,
|
||||
isProbing,
|
||||
isUnauthorized,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
isPolling,
|
||||
|
|
@ -310,8 +312,20 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* The backend is up but gated — asking for a token is the only useful
|
||||
thing to show. The "run gitnexus serve" guide would be wrong advice. */}
|
||||
{isUnauthorized && !isConnected && (
|
||||
<AccessTokenPrompt
|
||||
onSubmit={() => {
|
||||
// The polling chain is already running while disconnected; it
|
||||
// picks up the new token on its next tick and auto-connects.
|
||||
if (!isPolling) startPolling();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Crossfade between phases */}
|
||||
{displayPhase && (
|
||||
{!isUnauthorized && displayPhase && (
|
||||
<Crossfade activeKey={displayPhase}>
|
||||
{displayPhase === 'onboarding' && <OnboardingGuide isPolling={isPolling} />}
|
||||
{displayPhase === 'analyze' && <AnalyzeOnboarding onComplete={connectToRepo} />}
|
||||
|
|
|
|||
|
|
@ -20,9 +20,11 @@ import {
|
|||
getAvailableModels,
|
||||
fetchOpenRouterModels,
|
||||
} from '../core/llm/settings-service';
|
||||
import { getAuthToken, setAuthToken } from '../services/backend-client';
|
||||
import type { LLMSettings, LLMProvider } from '../core/llm/types';
|
||||
import { DEFAULT_OLLAMA_BASE_URL } from '../config/ui-constants';
|
||||
import { ProviderConfigCard } from './settings/ProviderConfigCard';
|
||||
import { SecretInput } from './settings/SecretInput';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface SettingsPanelProps {
|
||||
|
|
@ -253,6 +255,8 @@ export const SettingsPanel = ({
|
|||
const { t } = useTranslation(['common', 'settings']);
|
||||
const [settings, setSettings] = useState<LLMSettings>(loadSettings);
|
||||
const [showApiKey, setShowApiKey] = useState<Record<string, boolean>>({});
|
||||
/** Deploy access token. Stored outside LLM settings, persisted on Save. */
|
||||
const [authToken, setAuthTokenState] = useState(getAuthToken);
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saved' | 'error'>('idle');
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
// Ollama connection state
|
||||
|
|
@ -275,6 +279,7 @@ export const SettingsPanel = ({
|
|||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSettings(loadSettings());
|
||||
setAuthTokenState(getAuthToken());
|
||||
setSaveStatus('idle');
|
||||
setOllamaError(null);
|
||||
}
|
||||
|
|
@ -315,6 +320,10 @@ export const SettingsPanel = ({
|
|||
const handleSave = () => {
|
||||
try {
|
||||
saveSettings(settings);
|
||||
// The token persists on Save with everything else, not per keystroke: it
|
||||
// is the only affordance this panel gives for "committed", and a
|
||||
// half-typed token would otherwise ride the next probe.
|
||||
setAuthToken(authToken);
|
||||
setSaveStatus('saved');
|
||||
onSettingsSaved?.();
|
||||
if (saveTimerRef.current) {
|
||||
|
|
@ -372,6 +381,25 @@ export const SettingsPanel = ({
|
|||
|
||||
{/* Content */}
|
||||
<div className="flex-1 space-y-6 overflow-y-auto p-6">
|
||||
{/* Deploy access token. Rendered unconditionally, unlike the Local
|
||||
Server block below, which only appears when a caller passes the
|
||||
backend-URL props. An empty token is a valid state — a local
|
||||
`gitnexus serve` or `docker compose` deploy has no gate. */}
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium text-text-secondary">
|
||||
{t('settings:accessToken.label')}
|
||||
</label>
|
||||
<SecretInput
|
||||
value={authToken}
|
||||
onChange={setAuthTokenState}
|
||||
label={t('settings:accessToken.label')}
|
||||
placeholder={t('settings:accessToken.placeholder')}
|
||||
revealLabel={t('settings:accessToken.reveal')}
|
||||
hideLabel={t('settings:accessToken.hide')}
|
||||
/>
|
||||
<p className="text-xs text-text-muted">{t('settings:accessToken.hint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Local Server */}
|
||||
{backendUrl !== undefined && onBackendUrlChange && (
|
||||
<div className="space-y-3">
|
||||
|
|
|
|||
56
gitnexus-web/src/components/settings/SecretInput.tsx
Normal file
56
gitnexus-web/src/components/settings/SecretInput.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { useState } from 'react';
|
||||
import { Eye, EyeOff } from '@/lib/lucide-icons';
|
||||
|
||||
interface SecretInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
/** Accessible name for the field — the visible `<label>` is the caller's. */
|
||||
label: string;
|
||||
/** Accessible name for the toggle in each of its two states. */
|
||||
revealLabel: string;
|
||||
hideLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Masked text field with a reveal toggle, for secrets the user pastes in:
|
||||
* deploy access tokens, provider API keys. `type="password"` until the user
|
||||
* asks otherwise, and the value is never logged or put in a URL by anything
|
||||
* here.
|
||||
*
|
||||
* Reveal state lives inside the component, so a caller that unmounts the field
|
||||
* (the settings panel returns `null` when closed) reopens masked.
|
||||
*/
|
||||
export const SecretInput = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
label,
|
||||
revealLabel,
|
||||
hideLabel,
|
||||
}: SecretInputProps) => {
|
||||
const [isRevealed, setIsRevealed] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
type={isRevealed ? 'text' : 'password'}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-label={label}
|
||||
placeholder={placeholder}
|
||||
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 pr-11 font-mono text-sm text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsRevealed((prev) => !prev)}
|
||||
aria-label={isRevealed ? hideLabel : revealLabel}
|
||||
className="absolute top-1/2 right-3 -translate-y-1/2 text-text-muted transition-colors hover:text-text-primary"
|
||||
>
|
||||
{isRevealed ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -8,6 +8,21 @@ export const DEFAULT_BACKEND_URL =
|
|||
export const DEFAULT_OLLAMA_BASE_URL = 'http://localhost:11434';
|
||||
export const DEFAULT_OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
|
||||
|
||||
/**
|
||||
* sessionStorage key for the deploy access token sent as
|
||||
* `Authorization: Bearer <token>` on every `/api/*` request.
|
||||
*
|
||||
* sessionStorage, not localStorage: `core/llm/settings-service.ts` already
|
||||
* migrated provider API keys off localStorage and deletes the legacy copy. A
|
||||
* deploy token is the same class of secret, so it gets the same one-session
|
||||
* lifetime.
|
||||
*
|
||||
* Never a cookie: the browser attaches cookies to cross-site requests and
|
||||
* forwards them blind, and the public edge strips `Origin` before proxying, so
|
||||
* no CSRF backstop is left to catch it. A header is not sent automatically.
|
||||
*/
|
||||
export const AUTH_TOKEN_STORAGE_KEY = 'gitnexus-auth-token';
|
||||
|
||||
/**
|
||||
* Default node-count above which the WebUI connects in chat-only mode (skips
|
||||
* the full graph download). Grounded in sigma.js/graphology prior art: ~10K
|
||||
|
|
|
|||
|
|
@ -1233,7 +1233,20 @@ MATCH (n:Function {id: emb.nodeId}) RETURN n`,
|
|||
}
|
||||
}
|
||||
|
||||
return `No ${direction} dependencies found for "${target}" (types: ${activeRelTypes.join(', ')}). This code appears to be ${direction === 'upstream' ? 'unused (not called by anything)' : 'self-contained (no outgoing dependencies)'}.${multipleMatchWarning}`;
|
||||
// An empty UPSTREAM walk is not evidence of disuse — it is the absence
|
||||
// of evidence. The symbol may be reached only through a reference class
|
||||
// the index does not record (a property access on a plain object, a
|
||||
// dynamic dispatch, a call from a language whose resolver is weaker
|
||||
// here). The Node/MCP path reports `risk: UNKNOWN` with a `riskNote`
|
||||
// for exactly this case; this surface answers in prose rather than an
|
||||
// enum, so it carries the same MEANING rather than the same field —
|
||||
// saying "appears to be unused" here is the identical false certainty.
|
||||
//
|
||||
// Downstream keeps its wording: no outgoing dependencies really does
|
||||
// describe the symbol itself, not a claim about the rest of the repo.
|
||||
return direction === 'upstream'
|
||||
? `No ${direction} dependencies found for "${target}" (types: ${activeRelTypes.join(', ')}). This does NOT establish the symbol is unused — an empty caller set can also mean the callers are not resolvable by the index (plain-object property access, dynamic dispatch, cross-language calls). Confirm with a text search before treating it as dead code.${multipleMatchWarning}`
|
||||
: `No ${direction} dependencies found for "${target}" (types: ${activeRelTypes.join(', ')}). This code appears to be self-contained (no outgoing dependencies).${multipleMatchWarning}`;
|
||||
}
|
||||
|
||||
const depth1 = byDepth.get(1) || [];
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import {
|
|||
readFile as backendReadFile,
|
||||
startEmbeddings as backendStartEmbeddings,
|
||||
streamEmbeddingProgress,
|
||||
probeBackend,
|
||||
probeBackendStatus,
|
||||
// Aliased: switchRepo declares a local `let repoIdentity` that would shadow
|
||||
// a plain named import of this helper.
|
||||
repoIdentity as repoIdentityOf,
|
||||
|
|
@ -516,7 +516,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
}, []);
|
||||
|
||||
const isDatabaseReady = useCallback(async (): Promise<boolean> => {
|
||||
return probeBackend();
|
||||
return (await probeBackendStatus()) === 'ok';
|
||||
}, []);
|
||||
|
||||
// Embedding methods — now trigger server-side via /api/embed
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { probeBackend, setBackendUrl as setServiceUrl } from '../services/backend-client';
|
||||
import { probeBackendStatus, setBackendUrl as setServiceUrl } from '../services/backend-client';
|
||||
import { DEFAULT_BACKEND_URL } from '../config/ui-constants';
|
||||
|
||||
// ── localStorage keys ────────────────────────────────────────────────────────
|
||||
|
|
@ -13,6 +13,12 @@ export interface UseBackendResult {
|
|||
isConnected: boolean;
|
||||
/** Currently checking connection */
|
||||
isProbing: boolean;
|
||||
/**
|
||||
* The last probe got a 401 from the public edge's token gate. The deploy is
|
||||
* reachable; it just needs an access token. Use it to prompt for one instead
|
||||
* of telling the user to start a server that is already running.
|
||||
*/
|
||||
isUnauthorized: boolean;
|
||||
/** Current backend URL */
|
||||
backendUrl: string;
|
||||
/** Start polling for server availability (setTimeout chain, visibility-aware) */
|
||||
|
|
@ -36,6 +42,7 @@ export function useBackend(): UseBackendResult {
|
|||
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isProbing, setIsProbing] = useState(false);
|
||||
const [isUnauthorized, setIsUnauthorized] = useState(false);
|
||||
|
||||
// Race-condition guard: monotonically increasing probe ID
|
||||
const probeIdRef = useRef(0);
|
||||
|
|
@ -47,13 +54,15 @@ export function useBackend(): UseBackendResult {
|
|||
setIsProbing(true);
|
||||
|
||||
try {
|
||||
const ok = await probeBackend();
|
||||
const status = await probeBackendStatus();
|
||||
if (id !== probeIdRef.current) return false;
|
||||
setIsConnected(ok);
|
||||
return ok;
|
||||
setIsConnected(status === 'ok');
|
||||
setIsUnauthorized(status === 'unauthorized');
|
||||
return status === 'ok';
|
||||
} catch {
|
||||
if (id === probeIdRef.current) {
|
||||
setIsConnected(false);
|
||||
setIsUnauthorized(false);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
|
|
@ -147,6 +156,7 @@ export function useBackend(): UseBackendResult {
|
|||
return {
|
||||
isConnected,
|
||||
isProbing,
|
||||
isUnauthorized,
|
||||
backendUrl,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ export function formatBackendError(error: unknown, t: TFunction): string {
|
|||
return t('errors:backend.rateLimited', { seconds, defaultValue: fallback });
|
||||
case 'not_found':
|
||||
return t('errors:backend.notFound', { defaultValue: fallback });
|
||||
case 'unauthorized':
|
||||
return t('errors:backend.unauthorized', { defaultValue: fallback });
|
||||
case 'origin_blocked':
|
||||
return t('errors:backend.originBlocked', { defaultValue: fallback });
|
||||
case 'client':
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"timeout": "The server took too long to respond. Try again in a moment.",
|
||||
"rateLimited": "Too many requests. Try again in {{seconds}}s.",
|
||||
"notFound": "The requested repository or resource was not found.",
|
||||
"unauthorized": "This GitNexus deploy requires an access token. Find it in your Render dashboard under the gitnexus-web service's GITNEXUS_SERVE_AUTH_TOKEN environment variable, then paste it into Settings.",
|
||||
"originBlocked": "This action isn't available from the hosted UI. Open GitNexus from the server's own address (e.g. http://localhost:4747) to continue.",
|
||||
"client": "Request failed: {{message}}",
|
||||
"server": "Server error: {{message}}"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,17 @@
|
|||
"connected": "Connected",
|
||||
"notConnected": "Not connected",
|
||||
"runServeHint": "Run `gitnexus serve` to connect the web UI to a local backend.",
|
||||
"accessToken": {
|
||||
"label": "Access Token",
|
||||
"placeholder": "Paste your deploy access token",
|
||||
"hint": "Required only for a gated deploy. Find it in your Render dashboard under the gitnexus-web service's GITNEXUS_SERVE_AUTH_TOKEN environment variable. Leave empty for a local server.",
|
||||
"title": "This deploy requires an access token",
|
||||
"promptHint": "The GitNexus server is running but gated. Find the token in your Render dashboard under the gitnexus-web service's GITNEXUS_SERVE_AUTH_TOKEN environment variable.",
|
||||
"connect": "Connect",
|
||||
"reveal": "Show access token",
|
||||
"hide": "Hide access token",
|
||||
"sessionNote": "Stored for this browser session only. You will re-enter it in a new tab or after closing the browser."
|
||||
},
|
||||
"provider": "Provider",
|
||||
"apiKey": "API Key",
|
||||
"learnMore": "Learn more",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"timeout": "服务器响应超时,请稍后重试。",
|
||||
"rateLimited": "请求过于频繁,请在 {{seconds}} 秒后重试。",
|
||||
"notFound": "未找到请求的仓库或资源。",
|
||||
"unauthorized": "GitNexus 部署需要访问令牌。请在 Render 控制台的 gitnexus-web 服务的 GITNEXUS_SERVE_AUTH_TOKEN 环境变量中查看,然后粘贴到「设置」中。",
|
||||
"originBlocked": "此操作无法从托管界面执行。请通过服务器自身地址(例如 http://localhost:4747)打开 GitNexus 后再继续。",
|
||||
"client": "请求失败:{{message}}",
|
||||
"server": "服务器错误:{{message}}"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,17 @@
|
|||
"connected": "已连接",
|
||||
"notConnected": "未连接",
|
||||
"runServeHint": "运行 `gitnexus serve` 将 Web UI 连接到本地后端。",
|
||||
"accessToken": {
|
||||
"label": "访问令牌",
|
||||
"placeholder": "粘贴部署访问令牌",
|
||||
"hint": "仅在启用访问控制的部署中需要。可在 Render 控制台的 gitnexus-web 服务的 GITNEXUS_SERVE_AUTH_TOKEN 环境变量中找到。本地服务器请留空。",
|
||||
"title": "此部署需要访问令牌",
|
||||
"promptHint": "GitNexus 服务器正在运行,但已启用访问控制。请在 Render 控制台的 gitnexus-web 服务的 GITNEXUS_SERVE_AUTH_TOKEN 环境变量中查看该令牌。",
|
||||
"connect": "连接",
|
||||
"reveal": "显示访问令牌",
|
||||
"hide": "隐藏访问令牌",
|
||||
"sessionNote": "仅在当前浏览器会话中保存。新标签页或重新打开浏览器后需要重新输入。"
|
||||
},
|
||||
"provider": "提供商",
|
||||
"apiKey": "API Key",
|
||||
"learnMore": "了解更多",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@
|
|||
|
||||
import type { GraphNode, GraphRelationship } from 'gitnexus-shared';
|
||||
import { CircuitOpenError, ResilientFetchExhaustedError, resilientFetch } from 'gitnexus-shared';
|
||||
import { LARGE_GRAPH_NODE_THRESHOLD, LARGE_GRAPH_EDGE_THRESHOLD } from '../config/ui-constants';
|
||||
import {
|
||||
AUTH_TOKEN_STORAGE_KEY,
|
||||
LARGE_GRAPH_NODE_THRESHOLD,
|
||||
LARGE_GRAPH_EDGE_THRESHOLD,
|
||||
} from '../config/ui-constants';
|
||||
import { decideSkipGraph } from '../lib/graph-load-decision';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
|
@ -92,7 +96,12 @@ export class BackendError extends Error {
|
|||
// The write-route same-host Origin guard rejected this request (HTTP 403
|
||||
// with `{ code: 'origin_not_allowed' }`). Distinct from a generic `client`
|
||||
// 403 so the UI can show actionable "open the local UI" guidance.
|
||||
| 'origin_blocked',
|
||||
| 'origin_blocked'
|
||||
// The public edge rejected this request for a missing or wrong deploy
|
||||
// access token (HTTP 401 with `{ code: 'unauthorized' }`). Distinct from a
|
||||
// generic `client` 4xx so the UI can prompt for the token instead of
|
||||
// showing a raw error.
|
||||
| 'unauthorized',
|
||||
/**
|
||||
* Milliseconds until the caller should retry. Populated for rate-limited
|
||||
* responses (HTTP 429) from the server's `Retry-After` header. `undefined`
|
||||
|
|
@ -129,32 +138,73 @@ export interface SSEHandlers<T = unknown> {
|
|||
onMessage?: (data: T) => void;
|
||||
onComplete?: (data: T) => void;
|
||||
onError?: (error: string) => void;
|
||||
/** Fires on every successful (re)connection, once the stream is readable. */
|
||||
onOpen?: () => void;
|
||||
/**
|
||||
* Fires each time a reconnect is scheduled after a drop. Callers that want
|
||||
* "notify once per outage" dedupe on their side, resetting in `onOpen`.
|
||||
*/
|
||||
onReconnecting?: () => void;
|
||||
}
|
||||
|
||||
export interface SSEOptions {
|
||||
/** Reconnect attempts after a drop. `Infinity` for an indefinite stream. Default 3. */
|
||||
maxRetries?: number;
|
||||
/** First backoff delay; doubles per attempt. Default 1000ms. */
|
||||
baseDelayMs?: number;
|
||||
/** Upper bound on the doubling backoff. Default unbounded. */
|
||||
capDelayMs?: number;
|
||||
/**
|
||||
* Reconnect on a non-OK HTTP response as well as on a network drop. Off by
|
||||
* default: a job-progress stream that 4xx's is a real, terminal error the
|
||||
* caller has to see. A long-lived liveness stream turns it on, so a 401 from
|
||||
* the edge's token gate resolves itself once a token is entered.
|
||||
*/
|
||||
retryOnHttpError?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic SSE stream consumer using fetch + ReadableStream.
|
||||
* Returns an AbortController to cancel the stream.
|
||||
* Automatically reconnects on network drops (up to 3 retries with backoff).
|
||||
* Automatically reconnects on network drops (up to `maxRetries` with backoff).
|
||||
*
|
||||
* fetch-based rather than `EventSource` because `EventSource` cannot send
|
||||
* custom headers, and every `/api/*` request needs the `Authorization` header
|
||||
* to clear the public edge's token gate.
|
||||
*/
|
||||
export function streamSSE<T = unknown>(url: string, handlers: SSEHandlers<T>): AbortController {
|
||||
export function streamSSE<T = unknown>(
|
||||
url: string,
|
||||
handlers: SSEHandlers<T>,
|
||||
options: SSEOptions = {},
|
||||
): AbortController {
|
||||
const controller = new AbortController();
|
||||
const MAX_RETRIES = 3;
|
||||
const BASE_DELAY_MS = 1_000;
|
||||
const maxRetries = options.maxRetries ?? 3;
|
||||
const baseDelayMs = options.baseDelayMs ?? 1_000;
|
||||
const capDelayMs = options.capDelayMs ?? Infinity;
|
||||
|
||||
let lastEventId = '';
|
||||
|
||||
/** Schedule the next attempt. Returns false when the budget is spent. */
|
||||
const scheduleRetry = (retryCount: number): boolean => {
|
||||
if (controller.signal.aborted || retryCount >= maxRetries) return false;
|
||||
handlers.onReconnecting?.();
|
||||
setTimeout(() => connect(retryCount + 1), Math.min(baseDelayMs * 2 ** retryCount, capDelayMs));
|
||||
return true;
|
||||
};
|
||||
|
||||
const connect = (retryCount: number) => {
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
const headers = withAuthHeader(new Headers());
|
||||
if (lastEventId) {
|
||||
headers['Last-Event-ID'] = lastEventId;
|
||||
headers.set('Last-Event-ID', lastEventId);
|
||||
}
|
||||
|
||||
const response = await fetch(url, { signal: controller.signal, headers });
|
||||
if (!response.ok) {
|
||||
if (options.retryOnHttpError && scheduleRetry(retryCount)) return;
|
||||
handlers.onError?.(`Server returned ${response.status}`);
|
||||
return;
|
||||
}
|
||||
|
|
@ -167,6 +217,7 @@ export function streamSSE<T = unknown>(url: string, handlers: SSEHandlers<T>): A
|
|||
|
||||
// Reset retry count on successful connection
|
||||
retryCount = 0;
|
||||
handlers.onOpen?.();
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
|
@ -213,15 +264,11 @@ export function streamSSE<T = unknown>(url: string, handlers: SSEHandlers<T>): A
|
|||
}
|
||||
|
||||
// Stream ended without terminal event — try to reconnect
|
||||
if (!controller.signal.aborted && retryCount < MAX_RETRIES) {
|
||||
setTimeout(() => connect(retryCount + 1), BASE_DELAY_MS * 2 ** retryCount);
|
||||
}
|
||||
scheduleRetry(retryCount);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||
// Network error — attempt reconnect with backoff
|
||||
if (!controller.signal.aborted && retryCount < MAX_RETRIES) {
|
||||
setTimeout(() => connect(retryCount + 1), BASE_DELAY_MS * 2 ** retryCount);
|
||||
} else {
|
||||
if (!scheduleRetry(retryCount)) {
|
||||
handlers.onError?.(err instanceof Error ? err.message : 'Stream error');
|
||||
}
|
||||
}
|
||||
|
|
@ -288,6 +335,67 @@ export function normalizeServerUrl(input: string): string {
|
|||
return url;
|
||||
}
|
||||
|
||||
// ── Access token ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Deploy access token, sent as `Authorization: Bearer <token>` on every
|
||||
* `/api/*` request. `''` when the deploy has no gate, which is a valid state;
|
||||
* `null` means "not yet read from storage". See AUTH_TOKEN_STORAGE_KEY for why
|
||||
* sessionStorage and why a header rather than a cookie.
|
||||
*/
|
||||
let _authToken: string | null = null;
|
||||
|
||||
const readStoredAuthToken = (): string => {
|
||||
try {
|
||||
if (typeof sessionStorage === 'undefined') return '';
|
||||
return sessionStorage.getItem(AUTH_TOKEN_STORAGE_KEY) ?? '';
|
||||
} catch {
|
||||
// Storage can throw in private browsing modes — treat as no token.
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
/** The current access token, or `''` when the deploy is ungated. */
|
||||
export const getAuthToken = (): string => {
|
||||
if (_authToken === null) {
|
||||
_authToken = readStoredAuthToken();
|
||||
}
|
||||
return _authToken;
|
||||
};
|
||||
|
||||
/**
|
||||
* Store the access token for this browser session. A whitespace-only token
|
||||
* clears it, which is how the header is disabled for an ungated local backend.
|
||||
*/
|
||||
export const setAuthToken = (token: string): void => {
|
||||
const trimmed = token.trim();
|
||||
_authToken = trimmed;
|
||||
try {
|
||||
if (typeof sessionStorage === 'undefined') return;
|
||||
if (trimmed) {
|
||||
sessionStorage.setItem(AUTH_TOKEN_STORAGE_KEY, trimmed);
|
||||
} else {
|
||||
sessionStorage.removeItem(AUTH_TOKEN_STORAGE_KEY);
|
||||
}
|
||||
} catch (error) {
|
||||
// Persist failure is non-fatal: the in-memory token still authorizes this
|
||||
// tab's requests. Log the failure, never the token.
|
||||
console.warn('Failed to persist the GitNexus access token to sessionStorage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Add `Authorization` to a header set, in place. With no token the header is
|
||||
* omitted rather than sent empty: an empty credential is malformed, not absent.
|
||||
*/
|
||||
const withAuthHeader = (headers: Headers): Headers => {
|
||||
const token = getAuthToken();
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
|
||||
// ── Internal Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
|
|
@ -323,6 +431,12 @@ const fetchWithTimeout = async (
|
|||
const externalSignal = init.signal;
|
||||
const signal = externalSignal ? AbortSignal.any([timeoutSignal, externalSignal]) : timeoutSignal;
|
||||
|
||||
// Single chokepoint for the deploy access token — every REST call routes
|
||||
// through here. `Headers` rather than an object spread because callers pass
|
||||
// their own `headers` (e.g. `Content-Type: application/json`) and a spread
|
||||
// would drop one side or the other depending on ordering.
|
||||
const headers = withAuthHeader(new Headers(init.headers));
|
||||
|
||||
const method = (init.method ?? 'GET').toUpperCase();
|
||||
const isIdempotent = IDEMPOTENT_METHODS.has(method);
|
||||
const maxAttempts = isIdempotent || forceRetry ? 2 : 1;
|
||||
|
|
@ -348,7 +462,7 @@ const fetchWithTimeout = async (
|
|||
// single-attempt to avoid duplicate side effects.
|
||||
const response = await resilientFetch(
|
||||
url,
|
||||
{ ...init, signal },
|
||||
{ ...init, headers, signal },
|
||||
{
|
||||
breakerKey,
|
||||
retry: { maxAttempts, baseDelayMs: 250, capDelayMs: 1500 },
|
||||
|
|
@ -412,13 +526,17 @@ const assertOk = async (response: Response): Promise<void> => {
|
|||
? 'not_found'
|
||||
: response.status === 429
|
||||
? 'rate_limited'
|
||||
: // The write-route Origin guard returns 403 with this discriminator;
|
||||
// surface it as a distinct code so the UI can give actionable guidance.
|
||||
bodyCode === 'origin_not_allowed'
|
||||
? 'origin_blocked'
|
||||
: response.status >= 400 && response.status < 500
|
||||
? 'client'
|
||||
: 'server';
|
||||
: // The public edge's token gate returns 401 with this discriminator;
|
||||
// surface it as a distinct code so the UI can prompt for the token.
|
||||
bodyCode === 'unauthorized'
|
||||
? 'unauthorized'
|
||||
: // The write-route Origin guard returns 403 with this discriminator;
|
||||
// surface it as a distinct code so the UI can give actionable guidance.
|
||||
bodyCode === 'origin_not_allowed'
|
||||
? 'origin_blocked'
|
||||
: response.status >= 400 && response.status < 500
|
||||
? 'client'
|
||||
: 'server';
|
||||
|
||||
// Retry-After is the standard HTTP signal for when the client may try again.
|
||||
// express-rate-limit emits it on 429 with seconds (integer) or HTTP-date.
|
||||
|
|
@ -460,6 +578,8 @@ export const fetchServerInfo = async (): Promise<ServerInfo> => {
|
|||
return response.json() as Promise<ServerInfo>;
|
||||
};
|
||||
|
||||
const HEARTBEAT_MAX_BACKOFF_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Connect an SSE heartbeat to the backend. Retries indefinitely with capped
|
||||
* exponential backoff so transient hiccups don't reset the UI.
|
||||
|
|
@ -468,53 +588,42 @@ export const fetchServerInfo = async (): Promise<ServerInfo> => {
|
|||
* - `onReconnecting` fires on the first retry after a drop — use it to show
|
||||
* a "reconnecting" banner while keeping the current view intact.
|
||||
*
|
||||
* Returns a cleanup function that tears down the EventSource and timers.
|
||||
* Runs on `streamSSE` rather than `EventSource`: `EventSource` cannot send
|
||||
* custom headers, so it can't clear the edge's token gate, and the heartbeat
|
||||
* would 401 forever on a gated deploy. `streamSSE` reconnects on a non-OK
|
||||
* response here (`retryOnHttpError`), so a 401 recovers on its own once the
|
||||
* user enters a token instead of needing a page reload.
|
||||
*
|
||||
* Returns a cleanup function that aborts the stream and its pending retry.
|
||||
*/
|
||||
export const connectHeartbeat = (
|
||||
onConnect: () => void,
|
||||
onReconnecting: () => void,
|
||||
): (() => void) => {
|
||||
let closed = false;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let es: EventSource | null = null;
|
||||
let attempt = 0;
|
||||
/** Whether we've already fired onReconnecting for the current drop. */
|
||||
let notifiedReconnecting = false;
|
||||
const MAX_BACKOFF_MS = 15_000;
|
||||
|
||||
const connect = () => {
|
||||
if (closed) return;
|
||||
es = new EventSource(`${_backendUrl}/api/heartbeat`);
|
||||
es.onopen = () => {
|
||||
if (!closed) {
|
||||
attempt = 0;
|
||||
const controller = streamSSE(
|
||||
`${_backendUrl}/api/heartbeat`,
|
||||
{
|
||||
onOpen: () => {
|
||||
notifiedReconnecting = false;
|
||||
onConnect();
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
es?.close();
|
||||
es = null;
|
||||
if (closed) return;
|
||||
|
||||
if (!notifiedReconnecting) {
|
||||
},
|
||||
onReconnecting: () => {
|
||||
if (notifiedReconnecting) return;
|
||||
notifiedReconnecting = true;
|
||||
onReconnecting();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
maxRetries: Infinity,
|
||||
capDelayMs: HEARTBEAT_MAX_BACKOFF_MS,
|
||||
retryOnHttpError: true,
|
||||
},
|
||||
);
|
||||
|
||||
const delay = Math.min(1_000 * Math.pow(2, attempt), MAX_BACKOFF_MS);
|
||||
attempt++;
|
||||
retryTimer = setTimeout(connect, delay);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
closed = true;
|
||||
es?.close();
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
};
|
||||
return () => controller.abort();
|
||||
};
|
||||
|
||||
/** Delete a repo's index and unregister it. */
|
||||
|
|
@ -528,13 +637,29 @@ export const deleteRepo = async (repoName: string): Promise<void> => {
|
|||
await assertOk(response);
|
||||
};
|
||||
|
||||
/** Probe the backend. Returns true if reachable. */
|
||||
export const probeBackend = async (): Promise<boolean> => {
|
||||
/**
|
||||
* Outcome of a backend probe. A single value rather than a pair of booleans,
|
||||
* so "reachable and gated at the same time" cannot be represented:
|
||||
* - `ok` — answered 200, reachable and authorized.
|
||||
* - `unauthorized` — the public edge rejected the probe for a missing or wrong
|
||||
* access token (401). The deploy is up; the fix is to enter a token, not to
|
||||
* start a server.
|
||||
* - `unreachable` — no usable answer: a transport failure, a timeout, or any
|
||||
* other status.
|
||||
*/
|
||||
export type BackendProbeStatus = 'ok' | 'unauthorized' | 'unreachable';
|
||||
|
||||
/**
|
||||
* Probe the backend, distinguishing "not there" from "there but gated".
|
||||
* Never throws — a probe failure is a state, not an error.
|
||||
*/
|
||||
export const probeBackendStatus = async (): Promise<BackendProbeStatus> => {
|
||||
try {
|
||||
const response = await fetchWithTimeout(`${_backendUrl}/api/repos`, {}, PROBE_TIMEOUT_MS);
|
||||
return response.status === 200;
|
||||
if (response.status === 200) return 'ok';
|
||||
return response.status === 401 ? 'unauthorized' : 'unreachable';
|
||||
} catch {
|
||||
return false;
|
||||
return 'unreachable';
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
60
gitnexus-web/test/unit/access-token-prompt.test.tsx
Normal file
60
gitnexus-web/test/unit/access-token-prompt.test.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* The onboarding half of the edge token gate: a 401 has to read as "enter a
|
||||
* token", not "start a server", and the token the user enters has to reach
|
||||
* sessionStorage — and only sessionStorage.
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AccessTokenPrompt } from '../../src/components/AccessTokenPrompt';
|
||||
import { i18nReady } from '../../src/i18n';
|
||||
import { AUTH_TOKEN_STORAGE_KEY } from '../../src/config/ui-constants';
|
||||
import { getAuthToken, setAuthToken } from '../../src/services/backend-client';
|
||||
|
||||
const TOKEN = 'deploy-token-abc123';
|
||||
|
||||
describe('AccessTokenPrompt', () => {
|
||||
beforeEach(async () => {
|
||||
await i18nReady;
|
||||
setAuthToken('');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setAuthToken('');
|
||||
});
|
||||
|
||||
it('stores an entered token in sessionStorage, never localStorage', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
render(<AccessTokenPrompt onSubmit={onSubmit} />);
|
||||
|
||||
const input = screen.getByLabelText('Access Token');
|
||||
// Masked by default — the token is never rendered in plain text unasked.
|
||||
expect(input).toHaveAttribute('type', 'password');
|
||||
|
||||
await user.type(input, TOKEN);
|
||||
await user.click(screen.getByRole('button', { name: 'Connect' }));
|
||||
|
||||
expect(getAuthToken()).toBe(TOKEN);
|
||||
expect(sessionStorage.getItem(AUTH_TOKEN_STORAGE_KEY)).toBe(TOKEN);
|
||||
expect(localStorage.getItem(AUTH_TOKEN_STORAGE_KEY)).toBeNull();
|
||||
expect(onSubmit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('reveals and re-masks the token on request', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AccessTokenPrompt />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Show access token' }));
|
||||
expect(screen.getByLabelText('Access Token')).toHaveAttribute('type', 'text');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Hide access token' }));
|
||||
expect(screen.getByLabelText('Access Token')).toHaveAttribute('type', 'password');
|
||||
});
|
||||
|
||||
it('points at the Render environment variable that holds the token', () => {
|
||||
render(<AccessTokenPrompt />);
|
||||
expect(screen.getByText(/GITNEXUS_SERVE_AUTH_TOKEN/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
239
gitnexus-web/test/unit/backend-client-auth.test.ts
Normal file
239
gitnexus-web/test/unit/backend-client-auth.test.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
/**
|
||||
* Deploy access token plumbing in backend-client.
|
||||
*
|
||||
* The public edge (`docker-server.mjs`) gates every `/api/*` request behind
|
||||
* `Authorization: Bearer <token>` and answers 401 with `{ code: 'unauthorized' }`
|
||||
* otherwise. These tests pin the three things that make the browser half work:
|
||||
* the header reaches every request path, an absent token sends no header at all,
|
||||
* and the token never lands anywhere but sessionStorage.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { __resetBreakerRegistry__ } from 'gitnexus-shared/test-helpers';
|
||||
import {
|
||||
BackendError,
|
||||
fetchRepos,
|
||||
getAuthToken,
|
||||
probeBackendStatus,
|
||||
runQuery,
|
||||
setAuthToken,
|
||||
setBackendUrl,
|
||||
streamSSE,
|
||||
} from '../../src/services/backend-client';
|
||||
import { AUTH_TOKEN_STORAGE_KEY } from '../../src/config/ui-constants';
|
||||
|
||||
const BASE = 'http://localhost:4747';
|
||||
const TOKEN = 'deploy-token-abc123';
|
||||
|
||||
/** Headers of the nth fetch call, normalized to a `Headers` instance. */
|
||||
const headersOf = (fetchMock: ReturnType<typeof vi.fn>, call = 0): Headers =>
|
||||
new Headers((fetchMock.mock.calls[call]?.[1] as RequestInit | undefined)?.headers);
|
||||
|
||||
const jsonOk = (body: unknown) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
describe('backend-client access token', () => {
|
||||
beforeEach(() => {
|
||||
__resetBreakerRegistry__();
|
||||
setBackendUrl(BASE);
|
||||
setAuthToken('');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setAuthToken('');
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('sends Authorization: Bearer <token> when a token is set', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonOk([]));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
setAuthToken(TOKEN);
|
||||
|
||||
await fetchRepos();
|
||||
|
||||
expect(headersOf(fetchMock).get('Authorization')).toBe(`Bearer ${TOKEN}`);
|
||||
});
|
||||
|
||||
it('sends no Authorization header at all when no token is set', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonOk([]));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await fetchRepos();
|
||||
|
||||
// Absent, not empty — an empty credential is malformed, not missing.
|
||||
expect(headersOf(fetchMock).has('Authorization')).toBe(false);
|
||||
});
|
||||
|
||||
it('trims the token and treats a whitespace-only token as absent', async () => {
|
||||
const fetchMock = vi.fn(async () => jsonOk([]));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
setAuthToken(` ${TOKEN} `);
|
||||
await fetchRepos();
|
||||
expect(headersOf(fetchMock).get('Authorization')).toBe(`Bearer ${TOKEN}`);
|
||||
|
||||
setAuthToken(' ');
|
||||
await fetchRepos();
|
||||
expect(headersOf(fetchMock, 1).has('Authorization')).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves a caller's own headers alongside Authorization", async () => {
|
||||
const fetchMock = vi.fn(async () => jsonOk({ result: [] }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
setAuthToken(TOKEN);
|
||||
|
||||
// runQuery passes `Content-Type: application/json` of its own — the
|
||||
// `Headers` merge has to keep both, which an object spread would not.
|
||||
await runQuery('MATCH (n) RETURN n');
|
||||
|
||||
const headers = headersOf(fetchMock);
|
||||
expect(headers.get('Authorization')).toBe(`Bearer ${TOKEN}`);
|
||||
expect(headers.get('Content-Type')).toBe('application/json');
|
||||
});
|
||||
|
||||
it('surfaces a 401 with code "unauthorized" as BackendError.code === "unauthorized"', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ error: 'unauthorized', code: 'unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json', 'WWW-Authenticate': 'Bearer' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const error = await fetchRepos().catch((e: unknown) => e);
|
||||
expect(error).toBeInstanceOf(BackendError);
|
||||
expect((error as BackendError).code).toBe('unauthorized');
|
||||
expect((error as BackendError).status).toBe(401);
|
||||
});
|
||||
|
||||
it('keeps a 401 without the discriminator as a generic client error', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response('nope', { status: 401 })),
|
||||
);
|
||||
|
||||
const error = await fetchRepos().catch((e: unknown) => e);
|
||||
expect((error as BackendError).code).toBe('client');
|
||||
});
|
||||
|
||||
it('stores the token in sessionStorage and never in localStorage', () => {
|
||||
setAuthToken(TOKEN);
|
||||
|
||||
expect(sessionStorage.getItem(AUTH_TOKEN_STORAGE_KEY)).toBe(TOKEN);
|
||||
expect(localStorage.getItem(AUTH_TOKEN_STORAGE_KEY)).toBeNull();
|
||||
expect(getAuthToken()).toBe(TOKEN);
|
||||
|
||||
setAuthToken('');
|
||||
expect(sessionStorage.getItem(AUTH_TOKEN_STORAGE_KEY)).toBeNull();
|
||||
expect(getAuthToken()).toBe('');
|
||||
});
|
||||
|
||||
describe('probeBackendStatus', () => {
|
||||
it('reports a 401 as unauthorized rather than plain unreachability', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response('', { status: 401 })),
|
||||
);
|
||||
|
||||
await expect(probeBackendStatus()).resolves.toBe('unauthorized');
|
||||
});
|
||||
|
||||
it('reports a genuinely absent backend as unreachable, not gated', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
throw new TypeError('fetch failed');
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(probeBackendStatus()).resolves.toBe('unreachable');
|
||||
});
|
||||
|
||||
it('reports a 200 as ok', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => jsonOk([])),
|
||||
);
|
||||
|
||||
await expect(probeBackendStatus()).resolves.toBe('ok');
|
||||
});
|
||||
});
|
||||
|
||||
describe('streamSSE', () => {
|
||||
/** A response body that emits `chunks` then closes the stream. */
|
||||
const sseResponse = (chunks: string[]) =>
|
||||
new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
const encoder = new TextEncoder();
|
||||
for (const chunk of chunks) c.enqueue(encoder.encode(chunk));
|
||||
c.close();
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||
);
|
||||
|
||||
it('sends the token, and keeps it alongside Last-Event-ID on reconnect', async () => {
|
||||
// First connection ends after one identified event, so the retry carries
|
||||
// `Last-Event-ID`. Both headers must be present on that second attempt.
|
||||
const fetchMock = vi.fn(async () => sseResponse(['id: 42\ndata: {"percent":10}\n\n']));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
setAuthToken(TOKEN);
|
||||
|
||||
const controller = streamSSE(`${BASE}/api/analyze/j1/progress`, {}, { baseDelayMs: 0 });
|
||||
await vi.waitFor(() => expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(2));
|
||||
controller.abort();
|
||||
|
||||
expect(headersOf(fetchMock).get('Authorization')).toBe(`Bearer ${TOKEN}`);
|
||||
expect(headersOf(fetchMock).has('Last-Event-ID')).toBe(false);
|
||||
|
||||
const retryHeaders = headersOf(fetchMock, 1);
|
||||
expect(retryHeaders.get('Authorization')).toBe(`Bearer ${TOKEN}`);
|
||||
expect(retryHeaders.get('Last-Event-ID')).toBe('42');
|
||||
});
|
||||
|
||||
it('sends no Authorization header when no token is set', async () => {
|
||||
const fetchMock = vi.fn(async () => sseResponse(['data: {"percent":10}\n\n']));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const controller = streamSSE(`${BASE}/api/analyze/j1/progress`, {}, { maxRetries: 0 });
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||
controller.abort();
|
||||
|
||||
expect(headersOf(fetchMock).has('Authorization')).toBe(false);
|
||||
});
|
||||
|
||||
it('reports a non-OK response as an error and does not retry by default', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response('nope', { status: 401 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const onError = vi.fn();
|
||||
|
||||
streamSSE(`${BASE}/api/analyze/j1/progress`, { onError }, { baseDelayMs: 0 });
|
||||
await vi.waitFor(() => expect(onError).toHaveBeenCalledWith('Server returned 401'));
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('retries a non-OK response when retryOnHttpError is set', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response('nope', { status: 401 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const onError = vi.fn();
|
||||
|
||||
const controller = streamSSE(
|
||||
`${BASE}/api/heartbeat`,
|
||||
{ onError },
|
||||
{ baseDelayMs: 0, maxRetries: 2, retryOnHttpError: true },
|
||||
);
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3));
|
||||
controller.abort();
|
||||
|
||||
// Budget spent → the caller finally hears about it.
|
||||
expect(onError).toHaveBeenCalledWith('Server returned 401');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,150 +1,223 @@
|
|||
/**
|
||||
* `connectHeartbeat` runs on `streamSSE` (fetch + ReadableStream), not
|
||||
* `EventSource`, because `EventSource` cannot send custom headers and every
|
||||
* `/api/*` request needs `Authorization: Bearer <token>` to clear the public
|
||||
* edge's token gate.
|
||||
*
|
||||
* These tests pin the behavior `EventSource` used to provide for free —
|
||||
* indefinite reconnect with capped backoff, one "reconnecting" notification per
|
||||
* outage, teardown on cleanup — plus the two things the migration exists for:
|
||||
* the token header, and a 401 that recovers instead of giving up.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { connectHeartbeat } from '../../src/services/backend-client';
|
||||
import { connectHeartbeat, setAuthToken } from '../../src/services/backend-client';
|
||||
|
||||
// Mock EventSource to simulate SSE behavior
|
||||
class MockEventSource {
|
||||
onopen: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
closed = false;
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
/** A live fake SSE connection, closable from the test. */
|
||||
interface FakeConnection {
|
||||
/** End the stream cleanly — the client sees a drop and reconnects. */
|
||||
drop: () => void;
|
||||
}
|
||||
|
||||
let lastEventSource: MockEventSource | null = null;
|
||||
let connections: FakeConnection[] = [];
|
||||
/** HTTP statuses to answer with, in order. Exhausted → 200. */
|
||||
let statusQueue: number[] = [];
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
/** Let pending promises settle without advancing the clock. */
|
||||
const flush = () => vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
beforeEach(() => {
|
||||
lastEventSource = null;
|
||||
// vitest 4 enforces that mock implementations used with `new` must have a
|
||||
// [[Construct]] slot. Arrow functions don't, so we use a regular function
|
||||
// declaration here. The production code calls `new EventSource(...)`.
|
||||
vi.stubGlobal(
|
||||
'EventSource',
|
||||
vi.fn().mockImplementation(function () {
|
||||
lastEventSource = new MockEventSource();
|
||||
return lastEventSource;
|
||||
}),
|
||||
);
|
||||
connections = [];
|
||||
statusQueue = [];
|
||||
setAuthToken('');
|
||||
|
||||
fetchMock = vi.fn(async () => {
|
||||
const status = statusQueue.shift() ?? 200;
|
||||
if (status !== 200) return new Response('nope', { status });
|
||||
|
||||
let streamController!: ReadableStreamDefaultController<Uint8Array>;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
streamController = c;
|
||||
// The server's initial ":ok" comment — proves comments are tolerated.
|
||||
c.enqueue(new TextEncoder().encode(':ok\n\n'));
|
||||
},
|
||||
});
|
||||
connections.push({ drop: () => streamController.close() });
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
});
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
setAuthToken('');
|
||||
});
|
||||
|
||||
describe('connectHeartbeat', () => {
|
||||
it('calls onConnect when EventSource opens', () => {
|
||||
const onConnect = vi.fn();
|
||||
const onReconnecting = vi.fn();
|
||||
connectHeartbeat(onConnect, onReconnecting);
|
||||
|
||||
lastEventSource!.onopen!();
|
||||
expect(onConnect).toHaveBeenCalledOnce();
|
||||
expect(onReconnecting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onReconnecting on first error, then retries', () => {
|
||||
const onConnect = vi.fn();
|
||||
const onReconnecting = vi.fn();
|
||||
connectHeartbeat(onConnect, onReconnecting);
|
||||
|
||||
// Simulate connection drop
|
||||
lastEventSource!.onerror!();
|
||||
|
||||
expect(onReconnecting).toHaveBeenCalledOnce();
|
||||
expect(lastEventSource!.closed).toBe(true);
|
||||
|
||||
// Advance past first retry delay (1s)
|
||||
vi.advanceTimersByTime(1_000);
|
||||
|
||||
// A new EventSource should have been created
|
||||
expect(EventSource).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('fires onReconnecting only once per disconnect', () => {
|
||||
const onConnect = vi.fn();
|
||||
const onReconnecting = vi.fn();
|
||||
connectHeartbeat(onConnect, onReconnecting);
|
||||
|
||||
// First error
|
||||
lastEventSource!.onerror!();
|
||||
expect(onReconnecting).toHaveBeenCalledOnce();
|
||||
|
||||
// Second retry fires error again
|
||||
vi.advanceTimersByTime(1_000);
|
||||
lastEventSource!.onerror!();
|
||||
expect(onReconnecting).toHaveBeenCalledOnce(); // still 1
|
||||
|
||||
// Third retry fires error
|
||||
vi.advanceTimersByTime(2_000);
|
||||
lastEventSource!.onerror!();
|
||||
expect(onReconnecting).toHaveBeenCalledOnce(); // still 1
|
||||
});
|
||||
|
||||
it('retries indefinitely instead of giving up after 3 attempts', () => {
|
||||
const onConnect = vi.fn();
|
||||
const onReconnecting = vi.fn();
|
||||
connectHeartbeat(onConnect, onReconnecting);
|
||||
|
||||
// Simulate 10 consecutive failures — should never stop retrying
|
||||
for (let i = 0; i < 10; i++) {
|
||||
lastEventSource!.onerror!();
|
||||
// Advance past the max backoff (15s) to ensure the next retry fires
|
||||
vi.advanceTimersByTime(16_000);
|
||||
}
|
||||
|
||||
// Should have created 11 EventSources (1 initial + 10 retries)
|
||||
expect(EventSource).toHaveBeenCalledTimes(11);
|
||||
});
|
||||
|
||||
it('resets reconnecting state when connection recovers', () => {
|
||||
const onConnect = vi.fn();
|
||||
const onReconnecting = vi.fn();
|
||||
connectHeartbeat(onConnect, onReconnecting);
|
||||
|
||||
// Drop
|
||||
lastEventSource!.onerror!();
|
||||
expect(onReconnecting).toHaveBeenCalledOnce();
|
||||
|
||||
// Retry succeeds
|
||||
vi.advanceTimersByTime(1_000);
|
||||
lastEventSource!.onopen!();
|
||||
expect(onConnect).toHaveBeenCalledOnce();
|
||||
|
||||
// Drop again — should fire onReconnecting again (reset after recovery)
|
||||
lastEventSource!.onerror!();
|
||||
expect(onReconnecting).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('caps backoff at 15 seconds', () => {
|
||||
const onConnect = vi.fn();
|
||||
const onReconnecting = vi.fn();
|
||||
connectHeartbeat(onConnect, onReconnecting);
|
||||
|
||||
// Fail many times to push backoff past the cap
|
||||
for (let i = 0; i < 6; i++) {
|
||||
lastEventSource!.onerror!();
|
||||
// The delay for attempt i is min(1000 * 2^i, 15000)
|
||||
// i=0: 1s, i=1: 2s, i=2: 4s, i=3: 8s, i=4: 15s (capped), i=5: 15s (capped)
|
||||
vi.advanceTimersByTime(16_000);
|
||||
}
|
||||
|
||||
// All retries should have fired — 7 EventSources total
|
||||
expect(EventSource).toHaveBeenCalledTimes(7);
|
||||
});
|
||||
|
||||
it('stops retrying when cleanup is called', () => {
|
||||
it('calls onConnect once the stream is readable', async () => {
|
||||
const onConnect = vi.fn();
|
||||
const onReconnecting = vi.fn();
|
||||
const cleanup = connectHeartbeat(onConnect, onReconnecting);
|
||||
|
||||
lastEventSource!.onerror!();
|
||||
await flush();
|
||||
|
||||
expect(onConnect).toHaveBeenCalledOnce();
|
||||
expect(onReconnecting).not.toHaveBeenCalled();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('sends the access token as an Authorization header', async () => {
|
||||
setAuthToken('deploy-token-abc123');
|
||||
const cleanup = connectHeartbeat(vi.fn(), vi.fn());
|
||||
|
||||
await flush();
|
||||
|
||||
const headers = new Headers((fetchMock.mock.calls[0][1] as RequestInit).headers);
|
||||
expect(headers.get('Authorization')).toBe('Bearer deploy-token-abc123');
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('sends no Authorization header on an ungated deploy', async () => {
|
||||
const cleanup = connectHeartbeat(vi.fn(), vi.fn());
|
||||
|
||||
await flush();
|
||||
|
||||
const headers = new Headers((fetchMock.mock.calls[0][1] as RequestInit).headers);
|
||||
expect(headers.has('Authorization')).toBe(false);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('calls onReconnecting on first drop, then retries', async () => {
|
||||
const onConnect = vi.fn();
|
||||
const onReconnecting = vi.fn();
|
||||
const cleanup = connectHeartbeat(onConnect, onReconnecting);
|
||||
await flush();
|
||||
|
||||
connections[0].drop();
|
||||
await flush();
|
||||
|
||||
expect(onReconnecting).toHaveBeenCalledOnce();
|
||||
|
||||
// Advance past the first retry delay (1s)
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('fires onReconnecting only once per outage', async () => {
|
||||
const onReconnecting = vi.fn();
|
||||
const cleanup = connectHeartbeat(vi.fn(), onReconnecting);
|
||||
await flush();
|
||||
|
||||
// Every reconnect attempt answers 401 — the stream never reopens, so the
|
||||
// banner must not re-fire on each attempt.
|
||||
statusQueue = [401, 401, 401];
|
||||
connections[0].drop();
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
expect(fetchMock.mock.calls.length).toBeGreaterThan(2);
|
||||
expect(onReconnecting).toHaveBeenCalledOnce();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('retries indefinitely instead of giving up after 3 attempts', async () => {
|
||||
const cleanup = connectHeartbeat(vi.fn(), vi.fn());
|
||||
await flush();
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
connections[i].drop();
|
||||
// Advance past the max backoff (15s) so the next attempt always fires
|
||||
await vi.advanceTimersByTimeAsync(16_000);
|
||||
}
|
||||
|
||||
// 1 initial connection + 10 reconnects
|
||||
expect(fetchMock).toHaveBeenCalledTimes(11);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('reconnects after a 401 so a token entered later recovers the stream', async () => {
|
||||
const onConnect = vi.fn();
|
||||
const onReconnecting = vi.fn();
|
||||
const cleanup = connectHeartbeat(onConnect, onReconnecting);
|
||||
await flush();
|
||||
expect(onConnect).toHaveBeenCalledOnce();
|
||||
|
||||
// The gate starts rejecting (token cleared / never entered)…
|
||||
statusQueue = [401, 401];
|
||||
connections[0].drop();
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(onConnect).toHaveBeenCalledOnce();
|
||||
expect(onReconnecting).toHaveBeenCalledOnce();
|
||||
|
||||
// …and once a valid token is in place the next attempt succeeds on its own.
|
||||
await vi.advanceTimersByTimeAsync(16_000);
|
||||
expect(onConnect).toHaveBeenCalledTimes(2);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('resets reconnecting state when the connection recovers', async () => {
|
||||
const onConnect = vi.fn();
|
||||
const onReconnecting = vi.fn();
|
||||
const cleanup = connectHeartbeat(onConnect, onReconnecting);
|
||||
await flush();
|
||||
|
||||
connections[0].drop();
|
||||
await flush();
|
||||
expect(onReconnecting).toHaveBeenCalledOnce();
|
||||
|
||||
// Retry succeeds
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(onConnect).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Drop again — a fresh outage notifies again
|
||||
connections[1].drop();
|
||||
await flush();
|
||||
expect(onReconnecting).toHaveBeenCalledTimes(2);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('caps backoff at 15 seconds', async () => {
|
||||
const cleanup = connectHeartbeat(vi.fn(), vi.fn());
|
||||
await flush();
|
||||
|
||||
// Every attempt 401s, so nothing reopens and the retry counter keeps
|
||||
// climbing — the doubling backoff would reach 16s on the 5th retry.
|
||||
statusQueue = Array.from({ length: 10 }, () => 401);
|
||||
connections[0].drop();
|
||||
await flush();
|
||||
|
||||
// Walk the uncapped part of the schedule exactly: 1s, 2s, 4s, 8s.
|
||||
for (const delay of [1_000, 2_000, 4_000, 8_000]) {
|
||||
await vi.advanceTimersByTimeAsync(delay);
|
||||
}
|
||||
expect(fetchMock).toHaveBeenCalledTimes(5);
|
||||
|
||||
// The next delay doubles to 16s, so the cap is what makes this retry fire
|
||||
// at 15s. Not a millisecond sooner, and not at 16s.
|
||||
await vi.advanceTimersByTimeAsync(14_999);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(5);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(6);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('stops retrying when cleanup is called', async () => {
|
||||
const cleanup = connectHeartbeat(vi.fn(), vi.fn());
|
||||
await flush();
|
||||
|
||||
connections[0].drop();
|
||||
await flush();
|
||||
cleanup();
|
||||
|
||||
// Advance time — no new EventSource should be created
|
||||
vi.advanceTimersByTime(30_000);
|
||||
expect(EventSource).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
49
gitnexus-web/test/unit/settings-panel-token.test.tsx
Normal file
49
gitnexus-web/test/unit/settings-panel-token.test.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* The settings panel's access-token field persists on Save, with every other
|
||||
* field, rather than on each keystroke. Save is the only "committed" affordance
|
||||
* the panel has, and a half-typed token would otherwise ride the next probe to
|
||||
* the backend.
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SettingsPanel } from '../../src/components/SettingsPanel';
|
||||
import { i18nReady } from '../../src/i18n';
|
||||
import { getAuthToken, setAuthToken } from '../../src/services/backend-client';
|
||||
|
||||
const TOKEN = 'deploy-token-abc123';
|
||||
|
||||
describe('SettingsPanel access token', () => {
|
||||
beforeEach(async () => {
|
||||
await i18nReady;
|
||||
setAuthToken('');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setAuthToken('');
|
||||
});
|
||||
|
||||
it('holds a typed token locally until Save', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsPanel isOpen onClose={vi.fn()} />);
|
||||
|
||||
await user.type(screen.getByLabelText('Access Token'), TOKEN);
|
||||
expect(getAuthToken()).toBe('');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save Settings' }));
|
||||
expect(getAuthToken()).toBe(TOKEN);
|
||||
});
|
||||
|
||||
it('clears a stored token when the field is emptied and saved', async () => {
|
||||
setAuthToken(TOKEN);
|
||||
const user = userEvent.setup();
|
||||
render(<SettingsPanel isOpen onClose={vi.fn()} />);
|
||||
|
||||
await user.clear(screen.getByLabelText('Access Token'));
|
||||
await user.click(screen.getByRole('button', { name: 'Save Settings' }));
|
||||
|
||||
// An empty token is a valid state — an ungated local backend needs no header.
|
||||
expect(getAuthToken()).toBe('');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"fingerprint": "69e9182ae205183ade24c3d8ad5d7292aea677144b1cbe443dd631bc25b0cafe",
|
||||
"fingerprint": "4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5",
|
||||
"scaling_budget": 1.8,
|
||||
"max_ms_large": 1000,
|
||||
"_note": "fingerprint = sha256 over per-file digests (filename + sha256(file bytes)), entry list sorted — binds each emitted line to its file so a row routed to the WRONG pair file changes the hash, AND catches within-file row reordering (file bytes hashed as-written). Byte-identity gate for #2203 U2/U3. NOTE: a future change that legitimately reorders emit (without changing the node/edge SET) will trip --check; regenerate then. scaling_budget bounds (t_large/t_small)/(LARGE/SMALL): observed ~0.95-1.05 (linear); 1.8 tolerates disk-I/O timing noise on CI while still catching an O(n^2) re-regression (~4x). max_ms_large=1000ms is a coarse absolute backstop (observed ~200ms) that catches a gross uniform slowdown the ratio gate misses; generous so CI host noise won't flake it. Regenerate via `node --import tsx bench/emit-persistence/measure.mjs`."
|
||||
"_rebaselined_2856_property_is_detail": "Third and last of the bench guards this branch left red. The Property node table gained an `isDetail` BOOLEAN column (see PROPERTY_SCHEMA in src/core/lbug/schema.ts), so `streamAllCSVsToDisk` writes one more header field and one more cell per Property row — csv-generator.ts `propertyHeader` and the `node.label === 'Property'` tail. Verified to be header-only drift rather than a change in what is emitted: dumping every CSV this bench produces on `origin/main` and on this branch and diffing per-file (filename, byte length, sha256) shows the file SET is identical at 35 CSVs on both sides, 34 of the 35 are byte-identical, and the sole difference is `property.csv` growing 68 -> 77 bytes, `id,name,filePath,startLine,endLine,content,description,declaredType` -> `...,declaredType,isDetail`. The synthetic graph has no Property nodes, so no ROW moved at all. That is the check that matters here: a row routed to the wrong pair file, or a within-file reordering, is what this fingerprint exists to catch, and neither happened. Prior 69e9182ae205183ade24c3d8ad5d7292aea677144b1cbe443dd631bc25b0cafe -> 4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5. Both timing gates passed unchanged while this was red (scaling_ratio 0.783 vs budget 1.8, elapsed_ms_large 229ms vs the 1000ms backstop), so no throughput claim is being rebaselined away.",
|
||||
"_note": "fingerprint = sha256 over per-file digests (filename + sha256(file bytes)), entry list sorted — binds each emitted line to its file so a row routed to the WRONG pair file changes the hash, AND catches within-file row reordering (file bytes hashed as-written). Byte-identity gate for #2203 U2/U3. NOTE: a future change that legitimately reorders emit (without changing the node/edge SET) will trip --check; regenerate then, and record WHY in a `_rebaselined_<reason>` key alongside — bench/scope-capture/baselines.json sets that convention and it is what makes a regenerated hash reviewable. scaling_budget bounds (t_large/t_small)/(LARGE/SMALL): observed ~0.95-1.05 (linear); 1.8 tolerates disk-I/O timing noise on CI while still catching an O(n^2) re-regression (~4x). max_ms_large=1000ms is a coarse absolute backstop (observed ~200ms) that catches a gross uniform slowdown the ratio gate misses; generous so CI host noise won't flake it. Regenerate via `node --import tsx bench/emit-persistence/measure.mjs`."
|
||||
}
|
||||
|
|
|
|||
14
gitnexus/bench/kotlin-import-target/baselines.json
Normal file
14
gitnexus/bench/kotlin-import-target/baselines.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"_comment": "Baselines for bench/kotlin-import-target/measure.mjs --check. `fingerprint` is a sha256 over every `fileSet | fromFile | targetRaw -> result` record the correctness corpus resolves, in BOTH file-set iteration orders; it is a CORRECTNESS gate, so drift means Kotlin import resolution started returning a different file set and IMPORTS/CALLS edges moved in every Kotlin repository. Explain it, never re-baseline to make CI green. `cases` and `non_null` are asserted beside it because a shrunken or hollowed corpus produces a perfectly valid fingerprint over a smaller surface — all three are one re-baseline, never separate ones. `scaling_budget`, `depth_budget` and `small_ms_ceiling` are timing gates and carry deliberate headroom for shared CI runners.",
|
||||
"_provenance": "This fingerprint is the value the PRE-INDEX implementation produces. It was not read off the new code: the same corpus was run against `git show <pre-index>:gitnexus/src/core/ingestion/languages/kotlin/import-target.ts` — the four-tier per-import scan — and against the index that replaced it. Both print ebf1790bf1d42dad483a51f2cbdeb2351e493b9e8236e4eedeef592dd81e2c5c over 20106 cases, 13256 of them non-null. That is what makes the index change a performance change rather than a behaviour change, and it is reproducible: swap the module specifier at the top of measure.mjs for the old file and re-run. The corpus deliberately includes the shapes where the two could have diverged — repeated directory names whose FIRST occurrence is not the parent (`data/src/main/kotlin/com/example/data/Repo.kt` is NOT a child of `data`, because the old scan tested startsWith and then used indexOf), doubly nested same-name directories, an exact match appearing after a suffix match in iteration order, `.kt`/`.kts` stem collisions, backslash paths, repo-root files, wildcard `.*` targets landing on the single-file tier rather than fanning out, and non-Kotlin noise.",
|
||||
"_gate_controls": "The gate is only worth its baseline if a plausible regression moves it, so each arm was checked against the mutation it exists to catch, with the resolver otherwise untouched. Caught, all with the corpus below: capping suffixByStem key depth at 7 (fingerprint a0e6eb98f9…); skipping the dirChildren suffix loop above depth 8 (d53182ebbc…, non_null 13256 -> 12746); capping a dirChildren bucket at 17 entries (ed3ea85c59…). Also caught, with the RESOLVER untouched and only the corpus edited: dropping the competing file from the exact-beats-earlier-suffix case and emptying the repeated-directory negative case (44df5093ee…). All four passed silently before this corpus carried deep paths, packages above 16 files, queries against suffix keys deeper than 7, and the file set inside the hashed record. Re-check them after any corpus edit — a corpus that stops spanning an axis takes the gate with it.",
|
||||
"fingerprint": "ebf1790bf1d42dad483a51f2cbdeb2351e493b9e8236e4eedeef592dd81e2c5c",
|
||||
"cases": 20106,
|
||||
"non_null": 13256,
|
||||
"scaling_budget": 1.6,
|
||||
"depth_budget": 2.4,
|
||||
"small_ms_ceiling": 40,
|
||||
"_scaling_note": "(t_large/t_small)/(1600/400). ~1.0 is linear. OBSERVED BAND: 0.99-1.04 on a 12-core dev box, small arm ~6 ms. Read that band as a floor, not a spec — independent runs on other hardware during review came out 0.954-1.014, 0.965-1.036 and ~0.95-1.08, so a 1.2 reading is noise and should be re-run, not investigated. IMPORTS_PER_FILE is sized so the small arm lands in the ms rather than the ~2 ms a first revision measured, where timer granularity and JIT warm-up, not scaling, set the number; bench/cpp-qualified-ns documents the same artifact. TRIAGE: every timing arm here is a TIMING signal — RE-RUN IT on an idle machine before investigating; runner contention dominates. The fingerprint arm is the opposite: deterministic, a re-run never changes it, and it must never be wished away. FLOOR CHECK: the pre-index implementation — i.e. exactly the regression this gate exists to catch — measures ratio 3.737 on this corpus (2207.8 ms small, 33003.5 ms large, one cold run) against ~1.0 for the index. Independent review runs measured its floor at 3.905-4.297. Treat the absolute times as an order of magnitude only: the floor arm is one cold run because best-of-seven against a quadratic implementation costs minutes, while the index arm is best-of-seven after two warmups.",
|
||||
"_depth_note": "deep_ms/shallow_ms at a FIXED file count, paths 24 components against 8. scaling_ratio divides the file count out, so it is scale-invariant and structurally cannot see a cost that grows with path depth instead — and both loops this change added are depth loops (one suffixByStem entry per '/' in a stem, one dirChildren pass per component of dir). OBSERVED BAND: 1.44-1.51 over four unloaded runs. It sits above 1.0 legitimately: 3x the depth is 3x the suffix keys per file, so the build genuinely does more work; what the budget of 2.4 forbids is that growing faster than the depth ratio itself.",
|
||||
"_ceiling_note": "small_ms_ceiling is an ABSOLUTE bound, because scaling_ratio is a ratio and a constant-factor regression that grows both arms equally passes it. Measured during review: a full workspace scan reintroduced on 1-in-16 imports is caught by the ratio (1.814), but at 1-in-32 it passes at 1.490 while running 2.8x slower in absolute terms. 40 ms against an observed 5.9-6.1 ms leaves ~6x of headroom for a loaded shared runner while still catching that shape."
|
||||
}
|
||||
540
gitnexus/bench/kotlin-import-target/measure.mjs
Normal file
540
gitnexus/bench/kotlin-import-target/measure.mjs
Normal file
|
|
@ -0,0 +1,540 @@
|
|||
/**
|
||||
* Build-free identity + scaling bench for `resolveKotlinImportTarget`, the
|
||||
* Kotlin import resolver.
|
||||
*
|
||||
* Before this bench's companion change the resolver walked the ENTIRE
|
||||
* `allFilePaths` Set on every import. Its four tiers — exact/suffix,
|
||||
* directory child, package fan-out, progressive prefix strip — each ran
|
||||
* `for (const raw of allFilePaths)` with a `replace(/\\/g, '/')` and several
|
||||
* string comparisons per entry, and they are tried in cascade, so one
|
||||
* unresolved import cost two to four full passes. Resolution was therefore
|
||||
* O(imports x files). Once a repository reaches tens of thousands of Kotlin
|
||||
* files that is on the order of 10^10 string operations on one thread:
|
||||
* `analyze` sits at exactly 1.00 core with a flat heap and emits nothing for
|
||||
* hours, because every allocation is a short-lived string and nothing
|
||||
* accumulates to hint at progress.
|
||||
*
|
||||
* This is the same shape #1918 fixed for Python and #2788 for C++, and it
|
||||
* returns the same way: someone adds a tier, reaches for `allFilePaths`, and
|
||||
* writes a loop. Neither existing gate can catch it here —
|
||||
* `bench/python-scope/import-target-fingerprint.mjs` drives the Python
|
||||
* resolver only, and `bench/scope-capture/measure.mjs` fingerprints
|
||||
* `emit<Lang>ScopeCaptures`, a different function that never calls import
|
||||
* resolution. Hence this bench, in an always-on CI step.
|
||||
*
|
||||
* TWO ARMS, and they fail for opposite reasons:
|
||||
*
|
||||
* - `fingerprint` — a sha256 over every `fromFile | targetRaw -> result`
|
||||
* triple the correctness corpus resolves (an exhaustive branch matrix plus
|
||||
* a deterministic fuzz). This is a CORRECTNESS gate. Drift means Kotlin
|
||||
* imports started resolving a DIFFERENT file set, i.e. CALLS/IMPORTS edges
|
||||
* moved in every Kotlin repository. It is deterministic: a re-run never
|
||||
* changes it, and it must never be re-baselined to make CI green. This
|
||||
* value is the one the pre-index implementation produced — see
|
||||
* `_provenance` in baselines.json.
|
||||
*
|
||||
* - `scaling_ratio` — `(t_large/t_small)/(LARGE/SMALL)` over a synthetic
|
||||
* Kotlin monorepo at two scales, timing the index build TOGETHER with
|
||||
* resolving every import. ~1.0 is linear; a reintroduced per-import scan
|
||||
* measures ~4 at this scale gap. This is a TIMING gate: re-run it on an
|
||||
* idle machine before investigating.
|
||||
*
|
||||
* A ratio cannot see a constant factor and a file-count ratio cannot see a
|
||||
* depth cost, so `--check` also asserts a DEPTH ratio (file count fixed, paths
|
||||
* ~3x deeper) and an absolute ceiling on the small arm. A full workspace scan
|
||||
* reintroduced on 1-in-32 imports scores 1.490 — inside the scaling budget —
|
||||
* while running 2.8x slower; the ceiling is what catches that shape.
|
||||
*
|
||||
* One honest limit: at a very small import count the index loses. Building it
|
||||
* is one workspace pass, so a single import into a 100k-file workspace costs
|
||||
* ~0.8 s against ~0 for a scan that returns on its first hit. It inverts at
|
||||
* roughly 15 imports, and in the polyglot case that motivates the worry —
|
||||
* 100k files, 5% Kotlin, a couple of imports — the index already wins, because
|
||||
* the build skips non-`.kt` entries as cheaply as the scan did.
|
||||
*
|
||||
* Five properties of the corpora are load-bearing and must not be
|
||||
* "simplified" away:
|
||||
*
|
||||
* 1. **The correctness corpus fuzzes each file set in BOTH iteration
|
||||
* orders.** Every tie-break in this resolver is expressed only through
|
||||
* Set-iteration order — "first suffix match wins", and the two stem maps
|
||||
* keeping the FIRST path inserted per key. A single-order corpus scores an
|
||||
* implementation that keeps the LAST match identically.
|
||||
* 2. **The correctness corpus contains repeated directory names where the
|
||||
* first occurrence is not the parent** (`data/src/main/kotlin/com/example/
|
||||
* data/Repo.kt`). The pre-index scan tested `startsWith` and then used
|
||||
* `indexOf`, so it only ever considered the FIRST `/dir/`; that file is
|
||||
* therefore NOT a child of `data`. The index reproduces it deliberately.
|
||||
* Without these shapes the fingerprint cannot tell the preserved rule from
|
||||
* the intuitive one.
|
||||
* 3. **~40% of the scaling corpus's imports are unresolvable.** The old cost
|
||||
* was worst when nothing matched, because only then did all four tiers
|
||||
* run. A corpus where every import hits tier 1 exits after one pass and
|
||||
* scores a per-import scan far closer to linear.
|
||||
* 4. **The hashed record includes the FILE SET, not just the query and the
|
||||
* result.** Otherwise a corpus edit that swaps the workspace under a case
|
||||
* while leaving its result string alone is invisible: dropping the
|
||||
* competing file from the "exact beats an earlier suffix" case, or
|
||||
* emptying the repeated-directory negative case, each leaves `cases`,
|
||||
* `non_null` and the fingerprint byte-identical and the gate green.
|
||||
* 5. **Path depth and package size are spanned, not pinned.** Both loops this
|
||||
* change added are driven by depth — one `suffixByStem` entry per '/' in a
|
||||
* stem, one `dirChildren` pass per component of `dir` — and the fan-out
|
||||
* tier returns a bucket whose length is the package size. While the corpus
|
||||
* capped depth at 8 components and packages at 16 files, three plausible
|
||||
* follow-up guards (cap suffix depth at 7, skip the `dirChildren` suffix
|
||||
* loop above depth 8, cap a bucket at 17) all passed `--check` with a
|
||||
* byte-identical fingerprint — while on a standard Gradle layout the depth
|
||||
* skip resolved EVERY package import to null and the bucket cap truncated
|
||||
* fan-out by 58%. Import ARITY, by contrast, was never blind: a tier-4 cap
|
||||
* at 4 dotted segments already failed the gate, because the branch matrix
|
||||
* carries 6- and 8-segment cases.
|
||||
*
|
||||
* Run:
|
||||
* node --import tsx bench/kotlin-import-target/measure.mjs # report
|
||||
* node --import tsx bench/kotlin-import-target/measure.mjs --check # CI gate
|
||||
*/
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resolveKotlinImportTarget } from '../../src/core/ingestion/languages/kotlin/import-target.ts';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
|
||||
|
||||
const SMALL = 400;
|
||||
const LARGE = 1600;
|
||||
/** Imports per file. Keeps the import count proportional to the file count, so
|
||||
* a per-import workspace scan shows up as a quadratic ratio rather than being
|
||||
* amortized away by a fixed import budget. Sized so the SMALL arm measures in
|
||||
* the tens of ms: at ~2 ms timer granularity and JIT warm-up, not scaling, set
|
||||
* the ratio — the same artifact bench/cpp-qualified-ns documents. */
|
||||
const IMPORTS_PER_FILE = 32;
|
||||
/** Depth arm: same file count either side, ~3x the path depth on one side. */
|
||||
const DEPTH_FILES = 800;
|
||||
const DEPTH_PAD = 16;
|
||||
const WARMUP = 2;
|
||||
const REPS = 7;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Correctness arm
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const lines = [];
|
||||
let nonNull = 0;
|
||||
|
||||
function resolve(files, targetRaw, fromFile) {
|
||||
return resolveKotlinImportTarget(
|
||||
{ kind: 'named', localName: 'X', importedName: 'X', targetRaw },
|
||||
{ fromFile, allFilePaths: new Set(files) },
|
||||
);
|
||||
}
|
||||
|
||||
/** Record one case in BOTH file-set iteration orders — see header property 1. */
|
||||
function record(files, targetRaw, fromFile = 'App.kt') {
|
||||
for (const [order, list] of [
|
||||
['fwd', files],
|
||||
['rev', [...files].reverse()],
|
||||
]) {
|
||||
const r = resolve(list, targetRaw, fromFile);
|
||||
if (r !== null) nonNull++;
|
||||
const rendered = r === null ? 'NULL' : Array.isArray(r) ? `[${r.join(',')}]` : r;
|
||||
// The FILE SET is part of the hashed record, not just the query and the
|
||||
// result — see header property 4. Without it a corpus edit that changes
|
||||
// which workspace a case runs against, while leaving the result string
|
||||
// alone, is invisible: dropping the competing file from the
|
||||
// "exact beats an earlier suffix" case, or emptying the repeated-directory
|
||||
// negative case, both leave `cases`, `non_null` and the fingerprint
|
||||
// byte-identical.
|
||||
lines.push(`${order}\t${list.join('|')}\t${fromFile}\t${targetRaw}\t${rendered}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 1. Exhaustive branch matrix ------------------------------------------
|
||||
|
||||
// Tier 1, exact.
|
||||
record(['util/User.kt', 'util/Repo.kt'], 'util.User');
|
||||
// Tier 1, suffix (import is not workspace-rooted).
|
||||
record(['src/main/kotlin/util/User.kt'], 'util.User');
|
||||
// Exact anywhere beats a suffix found earlier.
|
||||
record(['deep/util/User.kt', 'util/User.kt'], 'util.User');
|
||||
// No exact match: first suffix in iteration order wins.
|
||||
record(['a/util/User.kt', 'b/util/User.kt'], 'util.User');
|
||||
// .kt / .kts sharing a stem.
|
||||
record(['dup/Thing.kt', 'dup/Thing.kts'], 'dup.Thing');
|
||||
// Multi-segment suffix query.
|
||||
record(['src/main/com/example/User.kt'], 'com.example.User');
|
||||
record(['a/b/com/example/User.kt', 'com/example/User.kt'], 'com.example.User');
|
||||
// Tier 2: stripped path matches a file (class-or-object holding the member).
|
||||
record(['util/OneArg.kt'], 'util.OneArg.writeAudit');
|
||||
record(['src/main/kotlin/util/OneArg.kt'], 'util.OneArg.writeAudit');
|
||||
// Tier 3: package fan-out to every direct child, in order.
|
||||
record(['models/User.kt', 'models/Repo.kt', 'models/sub/Deep.kt'], 'models.getRepo');
|
||||
record(['models/User.kt', 'models/sub/Deep.kt', 'models/Repo.kt'], 'models.getRepo');
|
||||
// Fan-out where the package directory is reached by suffix, not at the root.
|
||||
record(['app/src/main/kotlin/models/User.kt', 'app/src/main/kotlin/models/Repo.kt'], 'models.get');
|
||||
// Tier 4: progressive prefix strip, one and several skip levels.
|
||||
record(['x/y/z/Deep.kt'], 'com.example.z.Deep');
|
||||
record(['z/Deep.kt'], 'a.b.c.d.z.Deep');
|
||||
record(['q/Deep.kt'], 'a.b.c.d.e.f.q.Deep');
|
||||
// Tier 4 reaching the fan-out tier after stripping.
|
||||
record(['pkg/A.kt', 'pkg/B.kt'], 'com.example.pkg.someFunction');
|
||||
// Backslash normalization.
|
||||
record(['win\\pkg\\A.kt'], 'win.pkg.A');
|
||||
record(['win\\pkg\\A.kt', 'win\\pkg\\B.kt'], 'win.pkg.someFunction');
|
||||
// Non-Kotlin files never resolve.
|
||||
record(['pkg/A.java', 'pkg/A.md', 'pkg/A.kt.txt'], 'pkg.A');
|
||||
// Kotlin file alongside non-Kotlin noise of the same stem.
|
||||
record(['pkg/A.java', 'pkg/A.kt'], 'pkg.A');
|
||||
// Header property 2: repeated directory name, first occurrence is not the parent.
|
||||
record(['data/src/main/kotlin/com/example/data/Repo.kt'], 'data.something');
|
||||
record(['data/src/main/kotlin/com/example/data/Repo.kt'], 'data.Repo');
|
||||
record(['a/c/b/c/File.kt'], 'c.X');
|
||||
record(['c/b/c/File.kt'], 'c.X');
|
||||
// Doubly nested same-name directory, both below the root.
|
||||
record(['top/data/mid/data/Repo.kt'], 'data.something');
|
||||
// A path starting with the directory name is not its child unless direct.
|
||||
record(['data/sub/Repo.kt'], 'data.something');
|
||||
record(['data/Repo.kt'], 'data.something');
|
||||
// Repo-root file has no package directory.
|
||||
record(['Root.kt'], 'Root');
|
||||
record(['Root.kt', 'pkg/Root.kt'], 'Root');
|
||||
// Wildcard: `.*` is stripped and lands on the single-file tier, not fan-out.
|
||||
record(['models/User.kt', 'models/Repo.kt'], 'models.*');
|
||||
record(['models/Repo.kt', 'models/User.kt'], 'models.*');
|
||||
record(['util/User.kt'], 'util.User.*');
|
||||
// Unknown target.
|
||||
record(['pkg/A.kt'], 'nowhere.Thing');
|
||||
// Single-segment target with no directory anywhere.
|
||||
record(['pkg/A.kt'], 'A');
|
||||
// Empty-ish and degenerate targets.
|
||||
record(['pkg/A.kt'], '*');
|
||||
record(['pkg/A.kt'], 'pkg.');
|
||||
// fromFile variation must not change the outcome (this resolver ignores it) —
|
||||
// pinned so a future change that starts consulting it is visible here.
|
||||
record(['util/User.kt'], 'util.User', 'deep/nested/Caller.kt');
|
||||
|
||||
// ---- 1b. Depth and package size, the two axes the loops scale on ----------
|
||||
//
|
||||
// Header property 5. The index writes one `suffixByStem` entry per '/' in a
|
||||
// stem and walks `dir` once per component, so DEPTH is what those two loops
|
||||
// cost, and `dirChildren` bucket length is what the fan-out tier returns. A
|
||||
// corpus that pins either as a constant cannot see a guard on it: capping
|
||||
// suffix-key depth at 7, skipping the `dirChildren` suffix loop above depth 8,
|
||||
// or capping a bucket at 17 entries all left the fingerprint, `cases` and
|
||||
// `non_null` byte-identical before these cases existed — while, on a standard
|
||||
// Gradle layout, the depth skip resolved EVERY package import to null and the
|
||||
// bucket cap silently truncated fan-out by 58%.
|
||||
const DEEP = 'core/data/src/main/kotlin/com/example/core/data/repository';
|
||||
// 11 components — ordinary for Android/Gradle source, which runs 9-12.
|
||||
record([`${DEEP}/UserRepository.kt`], 'com.example.core.data.repository.UserRepository');
|
||||
record([`${DEEP}/UserRepository.kt`], 'repository.UserRepository');
|
||||
record([`${DEEP}/UserRepository.kt`], 'core.data.repository.UserRepository');
|
||||
record([`${DEEP}/UserRepository.kt`, `${DEEP}/PostRepository.kt`], 'repository.findAll');
|
||||
record([`${DEEP}/UserRepository.kt`, `${DEEP}/PostRepository.kt`], 'core.data.repository.findAll');
|
||||
// Deeper still, and with the repeated-name shape at depth.
|
||||
const DEEPER = 'feature/home/src/main/kotlin/com/example/feature/home/data/local/dao';
|
||||
record([`${DEEPER}/UserDao.kt`], 'dao.UserDao');
|
||||
record([`${DEEPER}/UserDao.kt`, `${DEEPER}/PostDao.kt`], 'dao.insertAll');
|
||||
record([`${DEEPER}/UserDao.kt`], 'home.data.local.dao.UserDao');
|
||||
// Suffix keys deeper than 7 components. Depth in the FILE is not enough on its
|
||||
// own: a cap on how many component-suffixes a stem contributes stays invisible
|
||||
// unless something QUERIES one of the deep keys, and every Gradle-shaped import
|
||||
// above is 6 segments or fewer. These reach the top of the stem.
|
||||
record(
|
||||
[`${DEEP}/UserRepository.kt`],
|
||||
'src.main.kotlin.com.example.core.data.repository.UserRepository',
|
||||
);
|
||||
record(
|
||||
[`${DEEP}/UserRepository.kt`],
|
||||
'data.src.main.kotlin.com.example.core.data.repository.UserRepository',
|
||||
);
|
||||
record([`${DEEPER}/UserDao.kt`], 'src.main.kotlin.com.example.feature.home.data.local.dao.UserDao');
|
||||
record(
|
||||
[`${DEEPER}/UserDao.kt`],
|
||||
'home.src.main.kotlin.com.example.feature.home.data.local.dao.UserDao',
|
||||
);
|
||||
record(
|
||||
[`${DEEP}/UserRepository.kt`, `${DEEP}/PostRepository.kt`],
|
||||
'src.main.kotlin.com.example.core.data.repository.findAll',
|
||||
);
|
||||
|
||||
// A package larger than any plausible bucket cap. 40 files in one package is
|
||||
// ordinary; a silent sibling cap is exactly what #2732 shipped on the JVM side.
|
||||
const BIG_PACKAGE = Array.from({ length: 40 }, (_, i) => `${DEEP}/Item${i}.kt`);
|
||||
record(BIG_PACKAGE, 'repository.someTopLevelFun');
|
||||
record(BIG_PACKAGE, 'com.example.core.data.repository.someTopLevelFun');
|
||||
record([...BIG_PACKAGE, `${DEEP}/sub/Nested.kt`], 'repository.someTopLevelFun');
|
||||
|
||||
// ---- 2. Deterministic fuzz -------------------------------------------------
|
||||
|
||||
/** xorshift32 — seeded, so the corpus is identical on every machine. */
|
||||
let seed = 0x9e3779b9;
|
||||
function rnd() {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >>> 17;
|
||||
seed ^= seed << 5;
|
||||
seed >>>= 0;
|
||||
return seed / 0x100000000;
|
||||
}
|
||||
function pick(arr) {
|
||||
return arr[Math.floor(rnd() * arr.length)];
|
||||
}
|
||||
|
||||
const DIRS = [
|
||||
'',
|
||||
'app',
|
||||
'core',
|
||||
'data',
|
||||
'feature/home',
|
||||
'lib/data',
|
||||
'src/main/kotlin',
|
||||
'src/main/kotlin/com/example',
|
||||
'module/src/main/kotlin/com/example/data',
|
||||
'data/src/main/kotlin/com/example/data',
|
||||
'top/data/mid/data',
|
||||
'win\\pkg',
|
||||
// Depth beyond the Gradle norm, so the fuzz spans the axis too rather than
|
||||
// leaving it to the hand-written cases above.
|
||||
'core/data/src/main/kotlin/com/example/core/data/repository',
|
||||
'feature/home/src/main/kotlin/com/example/feature/home/data/local/dao',
|
||||
'a/b/c/d/e/f/g/h/i/j/k/l',
|
||||
];
|
||||
// Segment alphabet overlaps the DIRS entries on purpose: a random dotted target
|
||||
// only exercises a deep suffix key if its segments can actually align with a
|
||||
// deep path.
|
||||
const SEGS = [
|
||||
'User',
|
||||
'Repo',
|
||||
'Util',
|
||||
'Service',
|
||||
'Model',
|
||||
'data',
|
||||
'core',
|
||||
'api',
|
||||
'store',
|
||||
'sub',
|
||||
'src',
|
||||
'main',
|
||||
'kotlin',
|
||||
'com',
|
||||
'example',
|
||||
'repository',
|
||||
'dao',
|
||||
];
|
||||
const EXTS = ['.kt', '.kt', '.kt', '.kts', '.java', '.md'];
|
||||
|
||||
function randPath() {
|
||||
const dir = pick(DIRS);
|
||||
const base = pick(SEGS);
|
||||
const file = `${base}${pick(EXTS)}`;
|
||||
if (dir === '') return file;
|
||||
return dir.includes('\\') ? `${dir}\\${file}` : `${dir}/${file}`;
|
||||
}
|
||||
function randDotted() {
|
||||
// Up to 9 segments, not 4: import arity is the one axis the branch matrix
|
||||
// already spanned, but the fuzz should cover it too now that the corpus
|
||||
// carries paths deep enough for a long target to align with one.
|
||||
const n = 1 + Math.floor(rnd() * 9);
|
||||
const parts = [];
|
||||
for (let i = 0; i < n; i++) parts.push(pick(SEGS));
|
||||
return rnd() < 0.12 ? `${parts.join('.')}.*` : parts.join('.');
|
||||
}
|
||||
|
||||
// File counts run to 45, not 16: a package that never exceeds 16 direct
|
||||
// children cannot distinguish an uncapped `dirChildren` bucket from one capped
|
||||
// at 17 (header property 5).
|
||||
for (let repo = 0; repo < 400; repo++) {
|
||||
const fileCount = 3 + Math.floor(rnd() * 43);
|
||||
const files = [];
|
||||
for (let i = 0; i < fileCount; i++) files.push(randPath());
|
||||
const fromFile = randPath();
|
||||
for (let imp = 0; imp < 25; imp++) record(files, randDotted(), fromFile);
|
||||
}
|
||||
|
||||
const correctnessFingerprint = crypto
|
||||
.createHash('sha256')
|
||||
.update([...lines].sort().join('\n'))
|
||||
.digest('hex');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scaling arm
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A synthetic Kotlin monorepo: Gradle-module roots over a shared package
|
||||
* namespace, at the path depth real Kotlin source has (the index stores one
|
||||
* suffix entry per '/' in a stem and walks `dir` once per component, so depth
|
||||
* is a cost driver and a flat corpus would understate the build).
|
||||
*
|
||||
* `padDepth` inserts filler segments so the depth arm below can hold the file
|
||||
* count fixed and vary only depth — the scaling ratio is scale-invariant in
|
||||
* FILE COUNT and would otherwise never see a depth-driven cost regression. */
|
||||
function buildCorpus(fileCount, padDepth = 0) {
|
||||
const pad = Array.from({ length: padDepth }, (_, d) => `p${d}`).join('/');
|
||||
const files = [];
|
||||
for (let i = 0; i < fileCount; i++) {
|
||||
const mod = i % 16;
|
||||
const root = pad === '' ? `lib${mod}` : `lib${mod}/${pad}`;
|
||||
files.push(`${root}/src/main/kotlin/com/example/mod${mod}/Class${i}.kt`);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/** Import targets for the corpus, ~40% of them unresolvable — see header
|
||||
* property 3: only a miss drives all four tiers, which is where the
|
||||
* per-import scan was worst. */
|
||||
function buildImports(fileCount) {
|
||||
const imports = [];
|
||||
for (let i = 0; i < fileCount * IMPORTS_PER_FILE; i++) {
|
||||
const kind = i % 5;
|
||||
const mod = i % 16;
|
||||
if (kind === 0)
|
||||
imports.push(`com.example.mod${mod}.Class${i % fileCount}`); // tier 1 hit
|
||||
else if (kind === 1)
|
||||
imports.push(`com.example.mod${mod}.someFunction`); // fan-out
|
||||
else if (kind === 2)
|
||||
imports.push(`mod${mod}.Class${i % fileCount}`); // suffix
|
||||
else imports.push(`org.absent.pkg${mod}.Missing${i}`); // full cascade, no hit
|
||||
}
|
||||
return imports;
|
||||
}
|
||||
|
||||
function fastest(values) {
|
||||
return Math.min(...values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Time one full pass: the index build PLUS resolving every import. The build is
|
||||
* the work the per-import scan was traded for, so hiding it would let an index
|
||||
* that is itself quadratic pass. Each pass gets its own Set object, because the
|
||||
* index is memoized on Set identity and a shared Set would build once and make
|
||||
* every later pass free. The Sets are constructed OUTSIDE the timer so their
|
||||
* own O(files) cost never lands in the measurement.
|
||||
*/
|
||||
function timeResolution(files, imports) {
|
||||
const sets = [];
|
||||
for (let i = 0; i < WARMUP + REPS; i++) sets.push(new Set(files));
|
||||
const fromFile = files[0];
|
||||
|
||||
for (let w = 0; w < WARMUP; w++) {
|
||||
for (const t of imports) {
|
||||
resolveKotlinImportTarget(
|
||||
{ kind: 'named', localName: 'X', importedName: 'X', targetRaw: t },
|
||||
{ fromFile, allFilePaths: sets[w] },
|
||||
);
|
||||
}
|
||||
}
|
||||
const samples = [];
|
||||
for (let r = 0; r < REPS; r++) {
|
||||
const set = sets[WARMUP + r];
|
||||
const t0 = performance.now();
|
||||
for (const t of imports) {
|
||||
resolveKotlinImportTarget(
|
||||
{ kind: 'named', localName: 'X', importedName: 'X', targetRaw: t },
|
||||
{ fromFile, allFilePaths: set },
|
||||
);
|
||||
}
|
||||
samples.push(performance.now() - t0);
|
||||
}
|
||||
return fastest(samples);
|
||||
}
|
||||
|
||||
const scales = {};
|
||||
for (const [name, fileCount] of [
|
||||
['small', SMALL],
|
||||
['large', LARGE],
|
||||
]) {
|
||||
const files = buildCorpus(fileCount);
|
||||
const imports = buildImports(fileCount);
|
||||
scales[name] = {
|
||||
files: fileCount,
|
||||
imports: imports.length,
|
||||
ms: Number(timeResolution(files, imports).toFixed(3)),
|
||||
};
|
||||
}
|
||||
|
||||
const scalingRatio = scales.large.ms / scales.small.ms / (LARGE / SMALL);
|
||||
|
||||
// Depth arm: file count fixed, depth roughly tripled. `scaling_ratio` divides
|
||||
// out the file count, so it is scale-INVARIANT and structurally cannot see a
|
||||
// cost that grows with path depth instead — and both loops this PR added are
|
||||
// depth loops. Same corpus size, same imports, only the paths get longer.
|
||||
const depthFiles = buildCorpus(DEPTH_FILES, 0);
|
||||
const depthFilesPadded = buildCorpus(DEPTH_FILES, DEPTH_PAD);
|
||||
const depthImports = buildImports(DEPTH_FILES);
|
||||
const shallowMs = timeResolution(depthFiles, depthImports);
|
||||
const deepMs = timeResolution(depthFilesPadded, depthImports);
|
||||
const depthRatio = deepMs / shallowMs;
|
||||
|
||||
const report = {
|
||||
small: scales.small,
|
||||
large: scales.large,
|
||||
scaling_ratio: Number(scalingRatio.toFixed(3)),
|
||||
depth: {
|
||||
files: DEPTH_FILES,
|
||||
shallow_components: 8,
|
||||
deep_components: 8 + DEPTH_PAD,
|
||||
shallow_ms: Number(shallowMs.toFixed(3)),
|
||||
deep_ms: Number(deepMs.toFixed(3)),
|
||||
},
|
||||
depth_ratio: Number(depthRatio.toFixed(3)),
|
||||
cases: lines.length,
|
||||
non_null: nonNull,
|
||||
fingerprint: correctnessFingerprint,
|
||||
};
|
||||
|
||||
if (!process.argv.includes('--check')) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf-8'));
|
||||
const failures = [];
|
||||
if (report.fingerprint !== baseline.fingerprint) {
|
||||
failures.push(
|
||||
`fingerprint drift: ${report.fingerprint} != ${baseline.fingerprint} — Kotlin import ` +
|
||||
`resolution returned a DIFFERENT file set. That is a behaviour change, not a perf one: ` +
|
||||
`IMPORTS/CALLS edges move in every Kotlin repository. Explain it, never re-baseline to ` +
|
||||
`make CI green.`,
|
||||
);
|
||||
}
|
||||
for (const field of ['cases', 'non_null']) {
|
||||
if (report[field] !== baseline[field]) {
|
||||
failures.push(
|
||||
`${field} ${report[field]} != ${baseline[field]} — the corpus itself changed, so the ` +
|
||||
`fingerprint above is computed over a different surface and proves nothing about the ` +
|
||||
`resolver. Re-baseline every corpus field together, deliberately.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (report.scaling_ratio > baseline.scaling_budget) {
|
||||
failures.push(
|
||||
`scaling ${report.scaling_ratio} > budget ${baseline.scaling_budget} — per-import cost grows ` +
|
||||
`with workspace size again, i.e. a tier went back to walking allFilePaths. Timing arm: ` +
|
||||
`re-run on an idle machine before investigating (see _scaling_note in baselines.json); the ` +
|
||||
`fingerprint arm is deterministic and never warrants a re-run.`,
|
||||
);
|
||||
}
|
||||
if (report.depth_ratio > baseline.depth_budget) {
|
||||
failures.push(
|
||||
`depth ratio ${report.depth_ratio} > budget ${baseline.depth_budget} — cost now grows with ` +
|
||||
`PATH DEPTH at a fixed file count. scaling_ratio divides the file count out and cannot ` +
|
||||
`see this. Timing arm: re-run on an idle machine first.`,
|
||||
);
|
||||
}
|
||||
if (report.small.ms > baseline.small_ms_ceiling) {
|
||||
failures.push(
|
||||
`small arm ${report.small.ms} ms > ceiling ${baseline.small_ms_ceiling} ms — scaling_ratio is ` +
|
||||
`a RATIO, so a constant-factor regression that grows both arms equally passes it (a full ` +
|
||||
`scan reintroduced on 1-in-32 imports measured 1.490, inside the budget, while running ` +
|
||||
`2.8x slower). This ceiling is what catches that. Timing arm: re-run on an idle machine.`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (failures.length > 0) {
|
||||
console.error(`[kotlin-import-target --check] FAIL\n - ${failures.join('\n - ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('[kotlin-import-target --check] PASS');
|
||||
|
|
@ -364,8 +364,13 @@ also resolves, so PHP nullable field types already work.
|
|||
|
||||
**C++ — the base already resolves, but `this->` field receivers do not.**
|
||||
`pointerArrowChain` and `valueDotChain` both RESOLVE, so a decorated C++ base is
|
||||
not a gap. But `this->repo.save()` and `this->repo->save()` are both
|
||||
INVISIBLE-GAP — a distinct defect, not a decoration one.
|
||||
not a gap. `this->repo.save()` and `this->repo->save()` were both INVISIBLE-GAP
|
||||
when this was written — a distinct defect, not a decoration one — and #2833
|
||||
closed it: a language that declares `this` IS the enclosing class
|
||||
(`resolveThisViaEnclosingClass`) synthesizes no `this` typeBinding anywhere, so
|
||||
a chain whose BASE is `this` could never seed its head. It was never a generics
|
||||
gap; the NON-generic control failed identically. C++'s `fieldReceiverCall` and
|
||||
`decoratedFieldType` cells moved INVISIBLE-GAP -> RESOLVES with it.
|
||||
|
||||
**Rust — the decorated receiver is NOT a gap.** `&mut self` resolves, so Go is
|
||||
the only language whose method receiver decoration defeats the lookup. Rust's
|
||||
|
|
|
|||
|
|
@ -61,9 +61,9 @@
|
|||
"awaitParen": "N/A",
|
||||
"explicitTypeArgs": "VISIBLE-GAP",
|
||||
"indexElement": "RESOLVES",
|
||||
"fieldReceiverCall": "INVISIBLE-GAP",
|
||||
"fieldReceiverCall": "RESOLVES",
|
||||
"decoratedReceiverBase": "N/A",
|
||||
"decoratedFieldType": "INVISIBLE-GAP"
|
||||
"decoratedFieldType": "RESOLVES"
|
||||
},
|
||||
"go": {
|
||||
"plainChain": "RESOLVES",
|
||||
|
|
@ -200,10 +200,11 @@
|
|||
},
|
||||
"countArm": {
|
||||
"callDrops": 102,
|
||||
"totalDropsAllKinds": 129,
|
||||
"totalDropsAllKinds": 140,
|
||||
"bySiteKind": {
|
||||
"call": 102,
|
||||
"read": 27
|
||||
"read": 27,
|
||||
"write": 11
|
||||
},
|
||||
"callDropsByExtension": {
|
||||
".java": 49,
|
||||
|
|
|
|||
|
|
@ -13,9 +13,10 @@ node --import tsx bench/schema-pairs/measure.mjs --check # gate vs baselines.
|
|||
|
||||
`src/core/lbug/schema.ts` generates its relation pairs from two cross products,
|
||||
and declines to add a third one **on the strength of a number** — roughly 1.04×
|
||||
at 450 declared pairs, 1.6× at 786, 2.1× at 1024. That measurement used to live
|
||||
in a scratch directory, so nobody proposing a third rule could re-run it. This
|
||||
harness is that measurement, committed — and it reproduces those figures.
|
||||
near production's pair count, 1.6× at 786, 2.1× at 1024. That measurement used
|
||||
to live in a scratch directory, so nobody proposing a third rule could re-run
|
||||
it. This harness is that measurement, committed — and it reproduces those
|
||||
figures.
|
||||
|
||||
Run it before widening a rule, and quote the new ratio in the review.
|
||||
|
||||
|
|
@ -29,8 +30,27 @@ Observed on the reference box, **four runs** (ratios vs the 332-pair list):
|
|||
| 786 | 1.52–1.75× | 1.19–1.31× |
|
||||
| 1024 | 2.03–2.34× | 1.31–1.57× |
|
||||
|
||||
Production's 450 came out _faster_ than 332 on three of the four runs, so at this
|
||||
size the pair count is inside run-to-run noise. Everything past ~640 is not.
|
||||
Production's former 450-pair surface came out _faster_ than 332 on three of the
|
||||
four runs, so at this size the pair count is inside run-to-run noise. Everything
|
||||
past ~640 is not.
|
||||
|
||||
#2801 remeasured the new 461-pair production surface on Windows six times:
|
||||
|
||||
| run | untyped ratio | typed ratio | interpretation |
|
||||
| --- | ------------- | ----------- | ----------------------------------- |
|
||||
| 1 | 1.101× | 1.157× | noise-dominated (`typed > untyped`) |
|
||||
| 2 | 1.324× | 1.122× | below the operational budget |
|
||||
| 3 | 2.705× | 1.089× | exceeds the operational budget |
|
||||
| 4 | 1.417× | 1.065× | below the operational budget |
|
||||
| 5 | 1.196× | 1.050× | below the operational budget |
|
||||
| 6 | 1.585× | 2.577× | noise-dominated (`typed > untyped`) |
|
||||
|
||||
The three comparable Windows runs below the 1.5× operational ceiling span
|
||||
**1.20–1.42× untyped / 1.05–1.12× typed**. Run 3 is published rather than
|
||||
silently discarded: no pre-registered rule excludes it, and `--check` would
|
||||
correctly reject it. These Windows measurements are not combined with the
|
||||
historical reference-box rows to infer cross-size ordering.
|
||||
|
||||
**Quote the range, not a single run** — one run is not evidence here.
|
||||
|
||||
## What it measures
|
||||
|
|
@ -52,8 +72,8 @@ data**, then times two query shapes over 40 anchors × 15 reps (median):
|
|||
control; the real cost of widening sits between it and `ratio_*`. A run where
|
||||
`typed_ratio` moves _more_ than `ratio` is noise-dominated and should be
|
||||
rerun.
|
||||
- **`ratio_<size>`** — `untyped_ms_<size> / untyped_ms_332`. `ratio_450` is the
|
||||
figure `schema.ts` quotes.
|
||||
- **`ratio_<size>`** — `untyped_ms_<size> / untyped_ms_332`. The
|
||||
production-size ratio is the figure `schema.ts` quotes.
|
||||
|
||||
### Sizes
|
||||
|
||||
|
|
@ -66,7 +86,8 @@ harness fails if the row counts ever differ across sizes.
|
|||
| size | what it is |
|
||||
| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 332 | the pre-#2792 hand-written list — the reference for every ratio |
|
||||
| 450 | production today (two cross products + 72 hand-declared pairs) |
|
||||
| 450 | production before Record became linkable (#2801) |
|
||||
| 461 | production today (two cross products + 69 hand-declared pairs) |
|
||||
| 641 | the third cross product `schema.ts` defers (`DEFINITION_ANCHOR_LABELS × {CodeElement, Section, Typedef, Union, Namespace, Impl, TypeAlias, Static, Template}`), which would leave ~29 hand-declared lines |
|
||||
| 786 | the size an earlier revision of that comment attributed to the third rule — it is 641; kept as a measured waypoint |
|
||||
| 1024 | the full cross product, the ceiling |
|
||||
|
|
@ -77,11 +98,13 @@ Before timing anything, the harness round-trips the **real** `SCHEMA_QUERIES`
|
|||
through a real database and asserts that `CALL SHOW_CONNECTION('CodeRelation')`
|
||||
reports exactly the pairs `parseRelationSchemaPairs` finds in `RELATION_SCHEMA`.
|
||||
|
||||
No magic number is baked in: the invariant is that the DDL LadybugDB _accepted_
|
||||
carries the pair set our own parser believes it declares. The absolute count is
|
||||
reported as `declared_pairs`. A pair declared twice would not reach this check at
|
||||
all — LadybugDB rejects the `CREATE REL TABLE` outright, which is why a duplicate
|
||||
kills every `analyze` rather than one repository's.
|
||||
No production-size magic number is baked in: the measured production size and
|
||||
budget key are derived from that parsed DDL count. A missing `ratio_<size>_budget`
|
||||
entry makes `--check` fail closed. The invariant is that the DDL LadybugDB
|
||||
_accepted_ carries the pair set our own parser believes it declares. The
|
||||
absolute count is reported as `declared_pairs`. A pair declared twice would not
|
||||
reach this check at all — LadybugDB rejects the `CREATE REL TABLE` outright,
|
||||
which is why a duplicate kills every `analyze` rather than one repository's.
|
||||
|
||||
## What it does NOT measure
|
||||
|
||||
|
|
@ -93,8 +116,11 @@ kills every `analyze` rather than one repository's.
|
|||
|
||||
## Regenerating the baseline
|
||||
|
||||
`baselines.json` holds one budget, `ratio_450_budget` — the ceiling on what
|
||||
`baselines.json` holds one production-size budget — the ceiling on what
|
||||
production's own pair count may cost relative to the 332-pair hand-list it
|
||||
replaced. Re-run without `--check` **several times** and copy the top of the
|
||||
observed `ratio_450` range plus headroom — the spread between runs on this box
|
||||
is wider than the effect being measured at 450, so a single run cannot set it.
|
||||
observed production-size ratio range plus headroom — the spread between runs on
|
||||
this box is wider than the effect being measured near production, so a single
|
||||
run cannot set it. Publish the raw ratios and apply only the pre-registered
|
||||
`typed_ratio > ratio` noise rule; do not silently discard another run to make a
|
||||
budget pass.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
{
|
||||
"_comment": "ratio_450_budget — ceiling on what production's 450-pair set may cost on untyped-endpoint anchored queries, relative to the 332-pair hand-list it replaced. Observed 0.94x and 1.05x across two runs on the reference box (i.e. inside run-to-run noise; it came out faster than 332 once). The budget carries headroom for that spread — compare typed_ratio_450 (1.10-1.17x) for this box's floor. Raise it only with a measured range, never a single run.",
|
||||
"ratio_450_budget": 1.3
|
||||
"_comment": "ratio_461_budget — operational ceiling on what production's 461-pair set may cost on untyped-endpoint anchored queries, relative to the 332-pair hand-list it replaced. #2801 measured 1.20-1.42x across three comparable Windows runs (typed floor 1.05-1.12x), so 1.5x leaves explicit host headroom. All six raw runs are published in README.md; two meet the pre-registered typed_ratio > ratio noise rule, while one additional 2.705x run is not silently discarded and would fail this gate. Raise the budget only with a published measured range, never a single run.",
|
||||
"ratio_461_budget": 1.5
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@
|
|||
*
|
||||
* `src/core/lbug/schema.ts` declares its relation pairs from two cross products
|
||||
* plus a small hand-written remainder, and it justifies NOT adding a third cross
|
||||
* product with a number: anchored queries cost ~1.04× at 450 declared pairs but
|
||||
* 1.6× at 786 and 2.1× at 1024. That measurement previously lived in a scratch
|
||||
* directory, so the claim could not be re-checked when someone proposed
|
||||
* widening a rule. This is it, committed.
|
||||
* product with a number: anchored queries cost ~1.04× near production's pair
|
||||
* count but 1.6× at 786 and 2.1× at 1024. That measurement previously lived in
|
||||
* a scratch directory, so the claim could not be re-checked when someone
|
||||
* proposed widening a rule. This is it, committed.
|
||||
*
|
||||
* WHAT IT MEASURES. Against a real `@ladybugdb/core` database, with byte-identical
|
||||
* DATA at every size, it times the query shape whose plan actually depends on the
|
||||
|
|
@ -34,7 +34,8 @@
|
|||
* same query at every size, and the only variable is how many UNUSED pairs the
|
||||
* table declares:
|
||||
* - 332 — the pre-#2792 hand-written list (the historical baseline);
|
||||
* - 450 — production today (two cross products + 72 hand-declared);
|
||||
* - 450 — production before Record became linkable (#2801);
|
||||
* - 461 — production today (two cross products + 69 hand-declared);
|
||||
* - 641 — the third cross product schema.ts defers
|
||||
* (`DEFINITION_ANCHOR_LABELS × {CodeElement, Section, Typedef, Union,
|
||||
* Namespace, Impl, TypeAlias, Static, Template}`), which would leave
|
||||
|
|
@ -43,8 +44,8 @@
|
|||
* third rule (it is 641; 786 is kept as a measured waypoint);
|
||||
* - 1024 — the full cross product, the ceiling.
|
||||
*
|
||||
* Ratios are reported against 332, the smallest size — `ratio_450` is the
|
||||
* number schema.ts quotes.
|
||||
* Ratios are reported against 332, the smallest size — the production-size
|
||||
* ratio is the number schema.ts quotes.
|
||||
*
|
||||
* CORRECTNESS GATE. Before timing anything it round-trips the REAL
|
||||
* `SCHEMA_QUERIES` through a real database and asserts that
|
||||
|
|
@ -61,9 +62,9 @@
|
|||
* node --import tsx bench/schema-pairs/measure.mjs # print JSON lines
|
||||
* node --import tsx bench/schema-pairs/measure.mjs --check # gate vs baselines.json
|
||||
*
|
||||
* `--check` fails if the correctness gate breaks, or if `ratio_450` exceeds its
|
||||
* budget — i.e. if production's own pair count starts costing materially more
|
||||
* than the hand-written list it replaced.
|
||||
* `--check` fails if the correctness gate breaks, or if the production-size
|
||||
* ratio exceeds its budget — i.e. if production's own pair count starts
|
||||
* costing materially more than the hand-written list it replaced.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
|
|
@ -85,9 +86,14 @@ const lbug = (await import('@ladybugdb/core')).default;
|
|||
|
||||
// ---- sizes + the pair enumeration every size is a prefix of ----
|
||||
|
||||
const SIZES = [332, 450, 641, 786, 1024];
|
||||
const REFERENCE_SIZE = 332; // ratios are relative to this
|
||||
const PRODUCTION_SIZE = 450; // the size schema.ts ships
|
||||
// Derive production from the same executable DDL the correctness gate
|
||||
// round-trips. A LINKABLE_LABELS widening must not require a second copied
|
||||
// count here — and cannot silently select a stale/missing budget key.
|
||||
const PRODUCTION_SIZE = parseRelationSchemaPairs(RELATION_SCHEMA).size;
|
||||
const SIZES = [...new Set([REFERENCE_SIZE, 450, PRODUCTION_SIZE, 641, 786, 1024])].sort(
|
||||
(a, b) => a - b,
|
||||
);
|
||||
|
||||
// The four pairs the synthetic data uses. Pinned to the FRONT of the
|
||||
// enumeration so they are declared at every size — otherwise a smaller pair set
|
||||
|
|
@ -338,8 +344,13 @@ if (!CHECK) {
|
|||
process.stdout.write(JSON.stringify(summary) + '\n');
|
||||
} else {
|
||||
const baselines = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8'));
|
||||
const budget = baselines[`ratio_${PRODUCTION_SIZE}_budget`];
|
||||
if (budget !== undefined && summary[`ratio_${PRODUCTION_SIZE}`] >= budget) {
|
||||
const budgetKey = `ratio_${PRODUCTION_SIZE}_budget`;
|
||||
const budget = baselines[budgetKey];
|
||||
if (budget === undefined) {
|
||||
failures.push(`no ${budgetKey} in baselines.json — the production gate is disarmed`);
|
||||
} else if (typeof budget !== 'number' || !Number.isFinite(budget)) {
|
||||
failures.push(`${budgetKey} must be a finite number (got ${JSON.stringify(budget)})`);
|
||||
} else if (summary[`ratio_${PRODUCTION_SIZE}`] >= budget) {
|
||||
failures.push(
|
||||
`production pair set (${PRODUCTION_SIZE}) costs ${summary[`ratio_${PRODUCTION_SIZE}`]}× vs ` +
|
||||
`${REFERENCE_SIZE} pairs, >= budget ${budget} (untyped ${reference.untyped_ms}ms -> ` +
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
{
|
||||
"_comment": "Per-language baselines for bench/scope-capture/measure.mjs --check. fingerprint = order-independent sha256 over the lang-resolution/<lang>-* fixture corpus + a 20-entity synthetic source (correctness gate; re-baseline intentionally on a legitimate capture change). scaling_budget = max allowed (t800/t250)/(800/250); ~1.0 is linear, ~3.2 is quadratic. The synthetic source is now HERITAGE-BEARING for every language (each Entity extends/implements/embeds/uses-trait/conforms-to a shared base) so the #1951 @reference.inherits synth is gated at scale, not just the base capture loop. All languages thread the tree-sitter captured node instead of re-deriving it with findNodeAtRange(tree.rootNode,...) per match, so all are linear (go #1915, python #1918, ruby/php/rust/csharp #1951, java #1956).",
|
||||
"go": {
|
||||
"fingerprint": "c27fb803598581fa4eb7ddf5ef6f8369b9e3a150082d11362e7aa3ec8faaa832",
|
||||
"fingerprint": "e386598526e502d131e52a17d219635b3a4196d94f1ebdd25922a2582c985d18",
|
||||
"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 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a -> 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a; scaling 1.058 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: provider-owned callable assignment/copy/formal/argument/invoke facts with invocation/constructor-result suppression. Prior 09ecd94911b830f52fa8807560abcbd79f163d02a2072870c1a59297e9a326e1 -> 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a; scaling 1.039 < 1.5.",
|
||||
"_rebaselined": "#1976: F33 generic composite literal constructor inference adds generic_type captures in composite_literal patterns; fingerprint drift expected.",
|
||||
"_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 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a -> 5d6c59c2f2c0dd937c53bf5d736e0f8376b2899a381e488a33aec23524823efb.",
|
||||
"_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 — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a -> 5d6c59c2f2c0dd937c53bf5d736e0f8376b2899a381e488a33aec23524823efb.",
|
||||
"_rebaselined_2766_go_pointer_receiver_fixture": "#2766: added test/fixtures/lang-resolution/go-pointer-receiver-field-chain/ (2 Go files) as the committed regression fixture for pointer-receiver base resolution. Go fixture_count 100 -> 102. Prior 5d6c59c2f2c0dd937c53bf5d736e0f8376b2899a381e488a33aec23524823efb -> 8cba537ff211fab3bac5fb4456cd1ffba14d6a2db75c40acae28ab8bf29f3d2e. FIXTURE-CORPUS GROWTH, NOT A CAPTURE CHANGE: the accompanying fix is a resolution-time lookup fallback (stripTypePreservingDecoration) and cannot move capture output; go was the ONLY language whose fingerprint drifted, and every other language matched its baseline on the same run.",
|
||||
"_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 8cba537ff211fab3bac5fb4456cd1ffba14d6a2db75c40acae28ab8bf29f3d2e -> 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f.",
|
||||
"_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 \u2014 the two whose fixture corpora contain such receivers. Prior 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f -> c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9.",
|
||||
"_rebaselined_2766_phantom_callee_read_site": "#2766: Go's `@reference.read` pattern matches EVERY selector_expression, so a member call `h.dep.Work()` minted THREE sites \u2014 the call, the genuine `h.dep` field read, and a PHANTOM read on the callee `h.dep.Work`. The phantom resolved through findOwnedMember (which prefers methods over fields) and emitted an ACCESSES edge to the METHOD duplicating the CALLS edge at the same position; visible today on any receiver the text cascade can type (`RunFromValueReceiver -> DoWork`). The emitter now drops a read match whose selector is in FUNCTION position. FEWER capture matches for Go, no other language affected \u2014 go was the only fingerprint of 15 that moved. A method VALUE (`f := h.dep.Work`) is not in function position and is untouched. Prior c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9 -> 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3.",
|
||||
"_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|…` instead of `1|…`. 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 8cba537ff211fab3bac5fb4456cd1ffba14d6a2db75c40acae28ab8bf29f3d2e -> 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f.",
|
||||
"_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 — the two whose fixture corpora contain such receivers. Prior 8162272bb897b0b89472c406321cf8d88a5ae4ea83ea9e3c45f8e817041bff9f -> c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9.",
|
||||
"_rebaselined_2766_phantom_callee_read_site": "#2766: Go's `@reference.read` pattern matches EVERY selector_expression, so a member call `h.dep.Work()` minted THREE sites — the call, the genuine `h.dep` field read, and a PHANTOM read on the callee `h.dep.Work`. The phantom resolved through findOwnedMember (which prefers methods over fields) and emitted an ACCESSES edge to the METHOD duplicating the CALLS edge at the same position; visible today on any receiver the text cascade can type (`RunFromValueReceiver -> DoWork`). The emitter now drops a read match whose selector is in FUNCTION position. FEWER capture matches for Go, no other language affected — go was the only fingerprint of 15 that moved. A method VALUE (`f := h.dep.Work`) is not in function position and is untouched. Prior c9c908f441e3be12fad2448120ed3ea35dc235a12b3f63b0ec532ffdae11d9e9 -> 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3.",
|
||||
"_rebaselined_2766_callee_position_marker": "#2766 review fix: a call's callee selector is no longer DROPPED at capture. An earlier commit on this branch dropped it outright, which also deleted the genuine field read on a func-typed struct field (`h.dep.Work()` where `Work func() error`) - callback/hook/mock structs lost their only ACCESSES evidence. The match is now emitted carrying `@reference.callee-position`, and the phantom is suppressed at EMIT by the resolved target's kind instead. Go only: the other 14 languages' fingerprints are byte-identical, which is the check that this is not a cross-language capture change. Prior 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3 -> e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3; scaling 1.001 < 1.5; fixtures 102 (unchanged), capture_groups_fp 2103.",
|
||||
"_rebaselined_2813_interface_field_dispatch_fixture": "#2813: added test/fixtures/lang-resolution/go-interface-field-dispatch/ (8 Go files) as the committed regression fixture for calls through an interface-typed struct field. Go fixture_count 102 -> 110. FIXTURE-CORPUS GROWTH, NOT A CAPTURE CHANGE: the accompanying fixes are a detection-time method-set change (interface-impls.ts) and a resolution-time fan-out in the shared receiver pass, neither of which emits captures; go/query.ts and go/captures.ts are untouched. Go was the ONLY language whose fingerprint drifted, and every other language matched its baseline on the same run - the same check used for the #2766 fixture growth above. Prior e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3 -> cffee41cadbf350855d99bd5aee7c015b1e8b31d1c343d02f113540abe86c765; scaling 1.074 < 1.5, capture_groups_fp 2303."
|
||||
"_rebaselined_2813_interface_field_dispatch_fixture": "#2813: added test/fixtures/lang-resolution/go-interface-field-dispatch/ (8 Go files) as the committed regression fixture for calls through an interface-typed struct field. Go fixture_count 102 -> 110. FIXTURE-CORPUS GROWTH, NOT A CAPTURE CHANGE: the accompanying fixes are a detection-time method-set change (interface-impls.ts) and a resolution-time fan-out in the shared receiver pass, neither of which emits captures; go/query.ts and go/captures.ts are untouched. Go was the ONLY language whose fingerprint drifted, and every other language matched its baseline on the same run - the same check used for the #2766 fixture growth above. Prior e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3 -> cffee41cadbf350855d99bd5aee7c015b1e8b31d1c343d02f113540abe86c765; scaling 1.074 < 1.5, capture_groups_fp 2303.",
|
||||
"_rebaselined_2837": "#2837: Go struct/interface captures re-anchored from the type_declaration onto the type_spec (@scope.class/@declaration.struct/@declaration.interface in languages/go/query.ts, @definition.struct/@definition.interface in GO_QUERIES). A grouped `type (...)` block used to yield ONE scope and ONE node for every type in it, so each type after the first lost its field typeBindings and every field-receiver call in the file emitted nothing. Capture COUNT is unchanged; only ranges moved, plus the new go-grouped-type-decl fixture. Prior c27fb803598581fa4eb7ddf5ef6f8369b9e3a150082d11362e7aa3ec8faaa832 -> e386598526e502d131e52a17d219635b3a4196d94f1ebdd25922a2582c985d18; scaling 1.054 < 1.5."
|
||||
},
|
||||
"cobol": {
|
||||
"fingerprint": "c8c00b56a7da24e04080eb885714fbbf45e3903324f0cf9df0754f5b5a92e3aa",
|
||||
|
|
@ -20,7 +21,7 @@
|
|||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: COBOL procedure-pointer callable flow facts; multi-topic extraction now consumes each grouped scope/declaration match once instead of requiring a duplicate declaration-only match. Prior 68ee0e95eb9f86f2d92ca35f730f4c2d4d83abc1b5241ae767ff3437780ec8d1 -> d45bb091b0893d0de4fae2486b31ba21719c9377bf35a0908fd3a36fa1c3bf4e; scaling 0.853 < 1.5.",
|
||||
"_note": "Updated for F17-F23 fixes (P2: TIMES guard, ADD GIVING, SQL AS alias). See PR #1959.",
|
||||
"_rebaselined_2793_declaratives": "PR #2793: corpus-only re-baseline. `cobol-declaratives` was added to test/fixtures/lang-resolution to reproduce the `Namespace\u2192Record` analyze abort (DECLARATIVES / USE AFTER STANDARD ERROR ON <file>), and this bench globs `lang-resolution/cobol-*`, so the corpus grew 14 -> 15 files. Verified capture-neutral: with that one fixture moved aside the fingerprint is byte-identical to the prior d45bb091b0893d0de4fae2486b31ba21719c9377bf35a0908fd3a36fa1c3bf4e. No COBOL capture code changed in that PR. Scaling 0.677 < 1.5."
|
||||
"_rebaselined_2793_declaratives": "PR #2793: corpus-only re-baseline. `cobol-declaratives` was added to test/fixtures/lang-resolution to reproduce the `Namespace→Record` analyze abort (DECLARATIVES / USE AFTER STANDARD ERROR ON <file>), and this bench globs `lang-resolution/cobol-*`, so the corpus grew 14 -> 15 files. Verified capture-neutral: with that one fixture moved aside the fingerprint is byte-identical to the prior d45bb091b0893d0de4fae2486b31ba21719c9377bf35a0908fd3a36fa1c3bf4e. No COBOL capture code changed in that PR. Scaling 0.677 < 1.5."
|
||||
},
|
||||
"c": {
|
||||
"fingerprint": "3418cded9f7072152f68992f0a426f43ae7d9d553579a47075fc0cab185848a5",
|
||||
|
|
@ -28,13 +29,15 @@
|
|||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 57fee292147ae6d2db7062da1e07d17122cf355207c8967fa85fd2ec9ca398a4 -> 3418cded9f7072152f68992f0a426f43ae7d9d553579a47075fc0cab185848a5; scaling 1.073 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: C function-pointer signatures plus direct-callee argument metadata and invocation-result suppression. Prior 75bcdbbf006bf9bd263c0f5857461b118f39b164e9f821cb0651ad0ec46ef6ae -> 57fee292147ae6d2db7062da1e07d17122cf355207c8967fa85fd2ec9ca398a4; scaling 1.035 < 1.5.",
|
||||
"_rebaselined_callable_flow": "Callable-value-flow facts for C function pointers, copies, pointer-to-pointer cells, arguments, and indirect invokes. Prior 12a196b2d6249c8d86a931b12ecebc2a0cdf8d6f47683acdd0d8e9d8bc7657f5 -> 75bcdbbf006bf9bd263c0f5857461b118f39b164e9f821cb0651ad0ec46ef6ae; measured scaling ratio 0.980 < 1.5.",
|
||||
"_added": "#1956: c added to the scope-capture bench (was UNBENCHED). C has no inheritance \u2014 flat scale source. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in c/captures.ts (threaded c.node, byte-identical over c-* fixtures); scaling 3.475 -> 0.96.",
|
||||
"_note": "#1983: + c-static-linkage-worker fixture (caller.c/lib.c/lib.h/local.c \u2014 worker-path static-linkage side-channel test). Pure fixture-corpus drift: no c/captures.ts or query change branch-vs-main, existing fixtures' captures byte-identical (c-captures.test.ts 45/45), scaling stays linear (~0.97). The baseline was missed when the fixture landed; regenerated here. fingerprint 0de009b->39f3a83.",
|
||||
"_added": "#1956: c added to the scope-capture bench (was UNBENCHED). C has no inheritance — flat scale source. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in c/captures.ts (threaded c.node, byte-identical over c-* fixtures); scaling 3.475 -> 0.96.",
|
||||
"_note": "#1983: + c-static-linkage-worker fixture (caller.c/lib.c/lib.h/local.c — worker-path static-linkage side-channel test). Pure fixture-corpus drift: no c/captures.ts or query change branch-vs-main, existing fixtures' captures byte-identical (c-captures.test.ts 45/45), scaling stays linear (~0.97). The baseline was missed when the fixture landed; regenerated here. fingerprint 0de009b->39f3a83.",
|
||||
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression)."
|
||||
},
|
||||
"cpp": {
|
||||
"fingerprint": "856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc",
|
||||
"fingerprint": "bf3587674267be1759e7c45abef143c3b81fe8629cfd17da5f8af40e83cc39ec",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_2833_qualified_member_fields": "#2833 follow-up: the six per-qualifier-depth `field_declaration` type-binding rules for a QUALIFIED generic member are replaced by three depth-agnostic ones that match the outer `qualified_identifier` itself, with the qualifier reduced to its top-level tail in `interpret.ts` (`cppQualifiedTail`). This is a CAPTURE-LOGIC change and it moves the fingerprint in two places at once. (1) A qualified NON-generic member (`ns::Address addr;`, `std::string name;`) was captured by nothing at all and now binds — that is the whole +24 on the fixture corpus, every one of them a `std::string` member. (2) Qualifier depth is no longer enumerated, so `a::b::c::Repo<User>` (depth 3+) is captured where the old rules stopped at 2. Capture-name histogram, cpp-* corpus (278 files): `@type-binding.field` 8 -> 32, `@type-binding.name` and `@type-binding.type` 401 -> 425; synthetic DAO-20: `@type-binding.field` 40 -> 60, `@type-binding.name` and `@type-binding.type` 61 -> 81 (= 20 entities x the one `std::string name;` member the DAO unit already declared). NO OTHER TAG MOVED in either set — not one `@declaration.*`, `@scope.*` or `@reference.*` count — which is the property that says three rules replaced six without widening what a field_declaration matches. Measured over the 13 cpp-* fixture repos whose sources gained a binding, the distinct CALLS edge set is byte-identical before and after (32 edges): a reduced tail that names no workspace class binds nothing. Prior bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a -> db1156d81b3e3341faf5e938a4a34417f4fd246588b6150b4686481823262529; scaling 1.04 < 1.5.",
|
||||
"_rebaselined_2833_generic_member_fields": "#2833 review follow-up: the cpp DAO generator's unit gains two GENERIC member fields — `Repo<Entity_n> repo;` (bare template_type) and `std::vector<Entity_n> items;` (qualified_identifier wrapping a template_type) — plus the header declaring `template <typename T> class Repo`. CORPUS CHANGE, NOT A CAPTURE-LOGIC CHANGE: no extractor edit accompanies it. It exists because the corpus had ZERO template-typed member fields and, across 279 cpp-* fixtures, not one qualified generic member either, so BOTH rounds of new `field_declaration` type-binding rules landed with a byte-identical cpp fingerprint — the gate was structurally blind to the exact thing being changed. Measured under the new corpus, the three states now differ: pre-#2833 query 0e7cbda71360b7ff35dd76091c77f288d6af6a5cfa9185ad85a372aae8c85191 (4521 groups) -> the three template_type field rules de07d8b5300ed867b460918e16b4d80259c7eb6efc1034d32bebe9ff7cab126d (4541) -> the six qualified rules bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a (4561); under the OLD corpus all three were 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc. Capture-name histogram over the synthetic DAO-20: `@type-binding.field` 0 -> 40, `@declaration.field` 40 -> 80, `@type-binding.type`/`@type-binding.name` 20 -> 61, `@declaration.name` 104 -> 147 — 40 = 20 entities x 2 fields, with the residual +1/+2/+3 attributable to the one-off header declaration; every `@reference.*` count is unchanged. Prior 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc -> bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a; scaling 1.058 < 1.5. `c` is unaffected (3418cded..., unchanged).",
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature/cv metadata. Prior dde874d2c30bda9f634f9799281a66de800cad9f76cf65e7c31839e2ae9da9ff -> 57860dd2a8d4b06c6d2dd0d854c08b781faee3da8f2b6c42ba0c68a9f70e5ccb; scaling 1.090 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: C++ overload-aware function/reference/member-pointer flow facts with invocation/constructor-result suppression. Prior 3a503a1513e7eede3f7a223dcce0896c06d15bdfa920445224c9025848c0d710 -> dde874d2c30bda9f634f9799281a66de800cad9f76cf65e7c31839e2ae9da9ff; scaling 1.034 < 1.5.",
|
||||
"_rebaselined_callable_flow": "Callable-value-flow facts for C++ function pointers/references, reference aliases, contextual arity, arguments, and member-pointer syntax. Prior 6ab657c8f9bfe988a3759098c2cffdcc0443def75ff263f1282b82c21d96e931 -> 3a503a1513e7eede3f7a223dcce0896c06d15bdfa920445224c9025848c0d710; measured scaling ratio 1.069 < 1.5.",
|
||||
|
|
@ -42,37 +45,49 @@
|
|||
"_note_1899_followup": "#1899 follow-up: braced-init metadata now carries element count, intentionally changing C++ capture output; CI benchmark scaling remains linear (1.129 < 1.5).",
|
||||
"_added": "#1956: cpp added to the scope-capture bench (was UNBENCHED). Heritage-bearing scale source (: public Base, public Mixin) drives emitCppInheritanceCaptures at scale. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in cpp/captures.ts (~12 sites, threaded c.node, byte-identical over 263 cpp-* fixtures); scaling 2.30 -> 1.12.",
|
||||
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression). #2094: deleted C++ declarations retain @declaration.is-deleted metadata; deleted operator and pointer-return shapes plus the expanded deleted-overload fixture are included. Intended capture drift; scaling remains linear (1.139 < 1.5).",
|
||||
"_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift \u2014 no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures \u2014 pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture \u2014 pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae. #2077 review follow-up: cpp-member-lattice adds cross-file, qualified-base, nested-template, inherited-using, this-receiver, and non-virtual-override regressions; fixture_count 274->275. Capture scaling remains linear (1.134 < 1.5). #1899: braced-init call arguments emit a conservative parameter-type capture; fixture_count 277, scaling remains linear (1.141 < 1.5).",
|
||||
"_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift — no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures — pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture — pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae. #2077 review follow-up: cpp-member-lattice adds cross-file, qualified-base, nested-template, inherited-using, this-receiver, and non-virtual-override regressions; fixture_count 274->275. Capture scaling remains linear (1.134 < 1.5). #1899: braced-init call arguments emit a conservative parameter-type capture; fixture_count 277, scaling remains linear (1.141 < 1.5).",
|
||||
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: outermost-chain passing modes; ->* ERROR-recovery role order; member-store visibility. Prior 57860dd2a8d4b06c6d2dd0d854c08b781faee3da8f2b6c42ba0c68a9f70e5ccb -> f29bc3f7b1622954d6f6b7647bc9cf6c7a2629ffcc0fe00ac7918e4925876b65; scaling ratio re-verified within budget.",
|
||||
"_rebaselined_2522_prototype_value_cells": "Plain function/method prototypes no longer index as callable value cells (only pointer/parenthesized variable declarators do) \u2014 removes the spurious indirect-invoke facts that leaked phantom CALLS past two-phase suppression. Prior f29bc3f7b1622954d6f6b7647bc9cf6c7a2629ffcc0fe00ac7918e4925876b65 -> a70625bb0a9ef74e760d9d79cc5557485d0f0d3fb935e8a22a0c9556c65b5bb1; scaling re-verified within budget.",
|
||||
"_rebaselined_2522_prototype_value_cells": "Plain function/method prototypes no longer index as callable value cells (only pointer/parenthesized variable declarators do) — removes the spurious indirect-invoke facts that leaked phantom CALLS past two-phase suppression. Prior f29bc3f7b1622954d6f6b7647bc9cf6c7a2629ffcc0fe00ac7918e4925876b65 -> a70625bb0a9ef74e760d9d79cc5557485d0f0d3fb935e8a22a0c9556c65b5bb1; scaling re-verified within budget.",
|
||||
"_rebaselined_receiver_chain_2747": "#2747: additionally adds the `cpp-receiver-chain-arrow` fixture, the behavioural proof for a `->` BASE receiver (`svc->getUser()->save()`) that the rollout fixed and that `cpp-chain-call/` could never catch because it uses the value `.` form. Prior a70625bb0a9ef74e760d9d79cc5557485d0f0d3fb935e8a22a0c9556c65b5bb1 -> 7e27aea46f3e17f33c41babbe0ddd982d1ab5920f143864763e0a1c6aef882a5.",
|
||||
"_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 7e27aea46f3e17f33c41babbe0ddd982d1ab5920f143864763e0a1c6aef882a5 -> 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc."
|
||||
"_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|…` instead of `1|…`. 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 7e27aea46f3e17f33c41babbe0ddd982d1ab5920f143864763e0a1c6aef882a5 -> 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc.",
|
||||
"capture_groups_small": 5021,
|
||||
"capture_groups_large": 16021,
|
||||
"capture_groups_fp": 4605,
|
||||
"fixture_count": 279
|
||||
},
|
||||
"csharp": {
|
||||
"_rebaselined": "#1956 synth-widening: + csharp-qualified-base fixture; the synth now walks record_declaration + struct_declaration base_lists and handles alias_qualified_name (matching the #1940 legacy leg), so record/struct heritage now emits. csharp-record-base gains a record inherits capture. (record->record SAME-namespace EXTENDS is a separate registry resolution gap, tracked as follow-up.) Linear (~1.00). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged. | #1924 F16: record primary-constructor base bindings now exclude constructor arguments; capture fingerprint changes, scaling remains linear. | #2036 review follow-up: csharp-record-base now exercises primary-constructor base dispatch end to end; +2 capture groups, scaling remains linear.",
|
||||
"fingerprint": "476d98a7cc659951c315d63319c8077bbcf0e5f3ec12d32ed773992a1f3a2adc",
|
||||
"fingerprint": "2930ef49fdce984a4c051409880bddfe8445e30e1c6bf802bd90a0a0f8f6b094",
|
||||
"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 f31544530924748f9aa37d11cec570bc10c3ddf9d9b237e6df7a17623fd2bb3a -> 75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1; scaling 1.061 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: C# method-group/delegate callable flow facts with invocation-result suppression. Prior 2bb5bc8c19cb8eb08c9590545ad8a1968a7152951f7e12746e2d7901d542fed9 -> f31544530924748f9aa37d11cec570bc10c3ddf9d9b237e6df7a17623fd2bb3a; scaling 1.115 < 1.5.",
|
||||
"_note": "#2046: F35 qualified-constructor captures now emit @reference.qualified-name + a simple-name @reference.name on `new Ns.Foo()`/`new A.B.Foo()`; namespace_declaration/file_scoped_namespace_declaration now emit @declaration.namespace name captures (feeding the non-destructive namespacePrefix sidecar for `new B.Foo()` same-tail disambiguation). + csharp-interface-only-base and csharp-namespace-qualified-ctor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.11).",
|
||||
"_rebaselined_2563_instance_ownership": "#2563: csharp-using-static adds same-file ownership, local-function, overload, partial-class, and cross-namespace same-name coverage. Prior 75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1 -> e05dc27456bde8175948586c9e7689033a378fa40e9ca4ce78cce41fbea0f2f8; scaling 1.058 < 1.5.",
|
||||
"_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 05a85bae70cf9c94f42459c843cfc36e3e81c872e5dcc7d77bc42fbc390f4bfe -> 8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855.",
|
||||
"_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 8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855 -> 476d98a7cc659951c315d63319c8077bbcf0e5f3ec12d32ed773992a1f3a2adc."
|
||||
"_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 — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 05a85bae70cf9c94f42459c843cfc36e3e81c872e5dcc7d77bc42fbc390f4bfe -> 8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855.",
|
||||
"_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|…` instead of `1|…`. 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 8a282254b93b3ef2ff34c2fdba819ebc95c53c4fcb09942cbad99f96d3687855 -> 476d98a7cc659951c315d63319c8077bbcf0e5f3ec12d32ed773992a1f3a2adc.",
|
||||
"capture_groups_small": 4259,
|
||||
"capture_groups_large": 13609,
|
||||
"capture_groups_fp": 2657,
|
||||
"fixture_count": 178
|
||||
},
|
||||
"rust": {
|
||||
"fingerprint": "6174889b8c98e0af430fa54c268dc781989ca9a8172d690eebae37a95f77e809",
|
||||
"fingerprint": "116a971fee0004f340477aff69fa110a1d92bd8ba882d7c926483c6b1e8ca2b9",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_mod_node_identity_2745_review": "#2745 review: added rust-2742-mod-members, rust-2742-nested-mods and rust-2742-type-vs-module under lang-resolution for the container/owner-edge fix, nested inline modules, and the imported-type-vs-module precedence. emitRustScopeCaptures is unchanged \u2014 verified by removing ONLY those three fixture dirs and re-running, which reproduces the prior fingerprint exactly, so the shift is purely corpus growth (fixture_count 196 -> 202, capture_groups_fp 3432 -> 3556). Prior 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5 -> 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300; scaling 1.022 local / 1.057 CI < 1.5. NOTE for the next fixture author: a new rust-* fixture drifts BOTH this bench baseline and the rust-captures-golden snapshot. Updating only the golden is how this reached CI red.",
|
||||
"_rebaselined_mod_node_identity_2745_review": "#2745 review: added rust-2742-mod-members, rust-2742-nested-mods and rust-2742-type-vs-module under lang-resolution for the container/owner-edge fix, nested inline modules, and the imported-type-vs-module precedence. emitRustScopeCaptures is unchanged — verified by removing ONLY those three fixture dirs and re-running, which reproduces the prior fingerprint exactly, so the shift is purely corpus growth (fixture_count 196 -> 202, capture_groups_fp 3432 -> 3556). Prior 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5 -> 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300; scaling 1.022 local / 1.057 CI < 1.5. NOTE for the next fixture author: a new rust-* fixture drifts BOTH this bench baseline and the rust-captures-golden snapshot. Updating only the golden is how this reached CI red.",
|
||||
"_rebaselined_dyn_trait_object_2604": "#2604: RUST_SCOPE_QUERY now captures function_signature_item (abstract trait methods, no body) as a scope + declaration, so a &dyn Trait receiver can dispatch a CALLS edge to the trait's own method. Additive capture shift across every bench fixture with a required trait method. Prior df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29 -> f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846; scaling 1.033 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 65e5bca66bb1ca117949409e8fb5c80ee69d6f1b5318908eaaecf08da0482e5c -> df369c5a5f8de7753fc8bab8b4108ef5081750974ea5085ba9a867675ac9eb29; scaling 1.065 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Rust fn-value callable flow facts with invocation/constructor-result suppression. Prior ac610bbe97666bf285923479dd7b43a2fe4c5354aae8df1bcbafdc04fb220f82 -> 65e5bca66bb1ca117949409e8fb5c80ee69d6f1b5318908eaaecf08da0482e5c; scaling 1.024 < 1.5.",
|
||||
"_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04). #1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls) \u2014 legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target; rust scope-extractor captures byte-identical. | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
|
||||
"_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED \u2014 @declaration.macro/@reference.macro + MacroRegistry \u2192 USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures \u2014 pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f.",
|
||||
"_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04). #1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls) — legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target; rust scope-extractor captures byte-identical. | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
|
||||
"_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED — @declaration.macro/@reference.macro + MacroRegistry → USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures — pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f.",
|
||||
"_rebaselined_import_disambiguation_2514": "#2514: added rust-import-* and rust-dup-* fixtures under lang-resolution for the range-binding ambiguity latch + import-disambiguated resolution (for-loops / struct destructuring across explicit/aliased/glob use imports). emitRustScopeCaptures is unchanged; the corpus fingerprint shifts purely because the fixture set grew (130 -> 174). Prior f7742f65f14d7d6590df7f16303fc3cc9dc0c233cd80bf90c98b084933cd3846 -> 655aed01cf1b6b84fa0c64d48dfb2526ecb67f47d90f0a91edabacd269a212db; scaling 1.06 < 1.5.",
|
||||
"_rebaselined_self_type_binding_2714": "#2714: a Rust `Self` type binding now records the enclosing impl's type instead of the literal 'Self'. `let fresh = Self { .. }` inside `impl User` binds `fresh: User`; recorded verbatim it bound `fresh: Self`, which resolves to nothing. The type-env channel already substituted this (type-extractors/rust.ts findEnclosingImplType); the scope-resolution channel did not, so the two disagreed. The gap was invisible while lookupCore Step 1 still walked the lexical chain for NAMED receivers \u2014 the impl scope binds the method by name, so fresh.validate() resolved by accident \u2014 and became a lost CALLS edge when #2714 stopped that walk. Only the rust fingerprint moves; the other 14 languages are byte-identical.",
|
||||
"_rebaselined_self_type_binding_2714": "#2714: a Rust `Self` type binding now records the enclosing impl's type instead of the literal 'Self'. `let fresh = Self { .. }` inside `impl User` binds `fresh: User`; recorded verbatim it bound `fresh: Self`, which resolves to nothing. The type-env channel already substituted this (type-extractors/rust.ts findEnclosingImplType); the scope-resolution channel did not, so the two disagreed. The gap was invisible while lookupCore Step 1 still walked the lexical chain for NAMED receivers — the impl scope binds the method by name, so fresh.validate() resolved by accident — and became a lost CALLS edge when #2714 stopped that walk. Only the rust fingerprint moves; the other 14 languages are byte-identical.",
|
||||
"_rebaselined_module_tree_2730": "#2730 + #2741 review: RUST_SCOPE_QUERY captures mod_item as @declaration.namespace (a Rust module is an item, mirroring the C++ namespace_definition capture) and tags scoped call sites with @reference.qualified-name so the written path survives to resolution. Both are additive captures: every bench fixture holding a mod block or a Foo::bar() call gains groups, and the corpus also grew by the rust-2730-* fixtures added for the fix and its review (workspace-crates, type-qualified, gaps, samename-wrapper, crate-layout). Prior 7f1240b38457468f06b7931e0c2c578f218f922774d0dc7e2ee6ef3b08d4d689 -> 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5; scaling 1.061 < 1.5; fixture_count 196. Only the rust fingerprint moves; the other 14 languages are byte-identical. The earlier revision of this note cited 655aed01... as the prior value, which was two rebaselines stale (it predates #2604 and #2714); the CI gate compares live fingerprints, not this prose, so nothing caught it.",
|
||||
"_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 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300 -> 83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c.",
|
||||
"_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 83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c -> 6174889b8c98e0af430fa54c268dc781989ca9a8172d690eebae37a95f77e809."
|
||||
"_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 — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300 -> 83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c.",
|
||||
"_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|…` instead of `1|…`. 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 83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c -> 6174889b8c98e0af430fa54c268dc781989ca9a8172d690eebae37a95f77e809.",
|
||||
"capture_groups_small": 5507,
|
||||
"capture_groups_large": 17607,
|
||||
"capture_groups_fp": 3556,
|
||||
"fixture_count": 202
|
||||
},
|
||||
"php": {
|
||||
"fingerprint": "b213a872342da2d866b04681dede988770e4d3dfdc0d6e9f62212ec5b59cdc2c",
|
||||
|
|
@ -80,9 +95,9 @@
|
|||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior df7b1565f9115d66b1ae32e4a408d651afb2521b14e5ca615f3be426c29af618 -> 4a688fa5a7016546f7f3c6d44de023608ae80c5b0e3670c16f6e61b3632608fd; scaling 1.078 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: PHP first-class callable and variable-invocation flow facts with invocation-result suppression. Prior 31c9e3f3cb7094a2bf9021cf9db859036e002f8b44605cd993b470fc600e97cb -> df7b1565f9115d66b1ae32e4a408d651afb2521b14e5ca615f3be426c29af618; scaling 1.074 < 1.5.",
|
||||
"_rebaselined": "#1956: heritage-bearing scale source (class extends Base + use trait); both forms gated at scale; linear (~1.04). | #2481/#2482: PHP imports carry a symbol-kind capture so function/constant imports resolve by declaring file; capture shape changes, scaling remains linear (~1.04).",
|
||||
"_note": "PR #1931: F53 import multi-clause, F54 enum_case, F55 anonymous_class \u2014 fixture count 138\u2192140, fingerprint drift expected.",
|
||||
"_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 4a688fa5a7016546f7f3c6d44de023608ae80c5b0e3670c16f6e61b3632608fd -> 3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28.",
|
||||
"_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 3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28 -> b213a872342da2d866b04681dede988770e4d3dfdc0d6e9f62212ec5b59cdc2c."
|
||||
"_note": "PR #1931: F53 import multi-clause, F54 enum_case, F55 anonymous_class — fixture count 138→140, fingerprint drift expected.",
|
||||
"_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 — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 4a688fa5a7016546f7f3c6d44de023608ae80c5b0e3670c16f6e61b3632608fd -> 3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28.",
|
||||
"_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|…` instead of `1|…`. 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 3745662053c76b6ae0a84a29aad319626ed5ccb88f7b9376c2680d3dc6502e28 -> b213a872342da2d866b04681dede988770e4d3dfdc0d6e9f62212ec5b59cdc2c."
|
||||
},
|
||||
"ruby": {
|
||||
"fingerprint": "1c8c9c4b54036fa24c2a81e39ea530e938645c856d369075e5f437da78218c57",
|
||||
|
|
@ -90,10 +105,10 @@
|
|||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior cff273ae6cb7232c977d9241581834a2a2fa8bcf6369f7bd8f2471cd4419a6ef -> bf50ec6a53c8c91680dc6feac63a8956e78b1059249232dc25a0cfed25f31236; scaling 1.103 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Ruby Method/Proc callable flow facts with invocation/constructor-result suppression. Prior b5ea93bb3d0469c3821a8c70f5d5991c6f326e41097c119ad691154301dcc753 -> cff273ae6cb7232c977d9241581834a2a2fa8bcf6369f7bd8f2471cd4419a6ef; scaling 1.086 < 1.5.",
|
||||
"_rebaselined": "#1956 synth-widening: + ruby-qualified-base fixture; synth now reduces a scope_resolution superclass (class C < Mod::Super) to its trailing constant (matching the #1940 legacy leg), at parity. Linear (~1.03). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
|
||||
"_note": "F62: + scope_resolution class/module declaration captures \u2014 fixture count 78\u219281, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) \u2014 pure fixture-corpus drift, scope-extractor captures unchanged; 81\u219282. #1991: + ruby-nested-mixin-tail-collision fixture (85\u219286). Recomputed on the #942 merge (fixture-comment rewording shifts capture byte-positions, capture LOGIC unchanged): bf6b13a -> b5ea93bb.",
|
||||
"_note": "F62: + scope_resolution class/module declaration captures — fixture count 78→81, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) — pure fixture-corpus drift, scope-extractor captures unchanged; 81→82. #1991: + ruby-nested-mixin-tail-collision fixture (85→86). Recomputed on the #942 merge (fixture-comment rewording shifts capture byte-positions, capture LOGIC unchanged): bf6b13a -> b5ea93bb.",
|
||||
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: bare identifiers are calls, not callable references (bareNamesAreCalls). Prior bf50ec6a53c8c91680dc6feac63a8956e78b1059249232dc25a0cfed25f31236 -> 070e4e11502442998ddf4048c2981cf1b2b735a87362ff854c5d14d71f98f4e2; 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 fea3edf82f521995147874b7f6c5f9e2eb88efdebf6365668f3260e913f0b558 -> fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83.",
|
||||
"_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."
|
||||
"_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 — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior fea3edf82f521995147874b7f6c5f9e2eb88efdebf6365668f3260e913f0b558 -> fc81941b0a921074fa80dc448284de9a23bd07358ddc84d4894797cc08c3fe83.",
|
||||
"_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|…` instead of `1|…`. 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",
|
||||
|
|
@ -102,8 +117,8 @@
|
|||
"_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.",
|
||||
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression).",
|
||||
"_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_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 — the tag is added to existing call matches, never a new match — 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|…` instead of `1|…`. 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."
|
||||
},
|
||||
"dart": {
|
||||
|
|
@ -117,11 +132,11 @@
|
|||
"_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0."
|
||||
},
|
||||
"java": {
|
||||
"fingerprint": "a9943355e945e03ddb87c800f4cc1f62b3d04feefb3ec64c258d8e0bb3b3fcd9",
|
||||
"fingerprint": "b29e263524f55151dcb7cfc4c929d3d1d7bb360355cee4e832158f927857f663",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata; same-name lexical regions use an O(ancestor-depth) ID-set lookup. Prior d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a -> 004a3592998dca1193bd1429a8284513725de7764f2a3eceedaaa984cfd763b4; scaling 0.992 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Java method-reference/SAM callable flow facts with invocation-result suppression. Prior 062d754764aaa8a6772fb90875c710502a63e3e7a300e633942381ed914faada -> d5c59d7dc9e206637515d5aea1163f7c1cdd76410c38c5fe6143d13d19677d6a; scaling 1.074 < 1.5.",
|
||||
"_rebaselined": "#2357 (supersedes #2353): + java-cast-receiver, java-this-field-chain, java-this-dispatch fixtures (cast-wrapped receivers, this.field chains incl. initializer contexts, bare-this dispatch pinning). Drift is purely fixture-additive: with the three new dirs parked, the fingerprint reproduces the prior baseline byte-identically \u2014 no emit/capture change. #1956 synth-widening: + java-iface-extends fixture; synthesizeJavaInheritanceReferences now ALSO walks interface_declaration extends_interfaces (interface IA extends IB, IC<T>), matching the #1940 legacy leg. (Earlier U2+review: java-qualified-base fixture covers 2- AND 3-segment qualified bases guarding the legacy end-anchor; synth tail-resolves scoped bases.) Linear (~1.03). (Earliest: java added to bench, exposed+fixed the O(n^2) findNodeAtRange root-walk; 3.09 -> ~0.99.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
|
||||
"_rebaselined": "#2357 (supersedes #2353): + java-cast-receiver, java-this-field-chain, java-this-dispatch fixtures (cast-wrapped receivers, this.field chains incl. initializer contexts, bare-this dispatch pinning). Drift is purely fixture-additive: with the three new dirs parked, the fingerprint reproduces the prior baseline byte-identically — no emit/capture change. #1956 synth-widening: + java-iface-extends fixture; synthesizeJavaInheritanceReferences now ALSO walks interface_declaration extends_interfaces (interface IA extends IB, IC<T>), matching the #1940 legacy leg. (Earlier U2+review: java-qualified-base fixture covers 2- AND 3-segment qualified bases guarding the legacy end-anchor; synth tail-resolves scoped bases.) Linear (~1.03). (Earliest: java added to bench, exposed+fixed the O(n^2) findNodeAtRange root-walk; 3.09 -> ~0.99.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
|
||||
"_note": "#1928 / #2045: F35 adds qualified + qualified-generic constructor query captures (`new pkg.Foo()`, `new a.b.Foo()`, `new pkg.Box<T>()`); F38 synthesizes `@reference.call.constructor` on `super(...)`/`this(...)` explicit_constructor_invocation nodes; F41 generic-aware stripQualifier in interpret (type-binding normalization). + java-qualified-constructor and java-explicit-constructor fixtures. Pure capture-additive + fixture-corpus drift; scaling stays linear (~1.06).",
|
||||
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: get/test dropped from callableProtocolMethods. Prior 004a3592998dca1193bd1429a8284513725de7764f2a3eceedaaa984cfd763b4 -> f3b4f4b6610e07c3ac90deb1c53d3572b6ad55a36e5d7134984876d30031ff67; scaling ratio re-verified within budget.",
|
||||
"_rebaselined_2550_instance_model": "PR #2549 (#2550): anonymous class bodies emit synthesized @declaration.class/@declaration.name (Worker$N), an @reference.inherits to the constructed type, and receiver @type-binding.* captures; six new java-* fixtures joined the corpus. Prior f3b4f4b6610e07c3ac90deb1c53d3572b6ad55a36e5d7134984876d30031ff67 -> d79c3b92acfc866094981499b977388ca14f90839bca0c040342ab1cec00aa90; scaling 1.058 < 1.5.",
|
||||
|
|
@ -129,33 +144,43 @@
|
|||
"_rebaselined_2564_record_capture": "PR for #2564: JAVA_QUERIES gained a (record_declaration name: (identifier) @name) @definition.record capture, previously entirely missing (record_declaration had no structure-phase capture at all, unlike class/interface/enum) - a record's methods existed as ownerless Method nodes with no HAS_METHOD edge. Two new java-* fixtures (java-record-methods, java-new-expr-chain-call) joined the corpus. Prior 975b68aaac6d06094260fb0c67f9b1bc03692ba7220669d192aca9dccd5fc0ca -> 85fc7af9c3c1bceac76cb4f27214410b04967682a2eaa7e468e26efd1f4e2537; scaling 1.059 < 1.5.",
|
||||
"_rebaselined_2561_enum_constant_receiver": "PR for #2561: synthesizeJavaAnonymousClassDeclarations now emits a class-scope @type-binding.annotation/name/type per enum constant (constant simple name -> its E$N synthesized class when bodied, else the host enum) so E.CONST.method() resolves through the existing compound-receiver chain walk. Two drivers of the drift, both in the java-enum-constant-body fixture (this bench's corpus IS test/fixtures/lang-resolution): (1) one extra type-binding match per enum_constant from the capture change; (2) review follow-up added a body-less Plain.java enum + EnumConst.dispatchToConstant/dispatchInherited methods (bodied-override, inherited-via-MRO, and body-less dispatch call sites). The review's fail-safe hardening (bodied constant binds ONLY to E$N, never the host enum, when name synthesis fails on a malformed tree) is output-neutral on this well-formed corpus (verified: fingerprint identical with and without it). Prior 85fc7af9c3c1bceac76cb4f27214410b04967682a2eaa7e468e26efd1f4e2537 -> d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686; scaling < 1.5.",
|
||||
"_rebaselined_2562_local_classes": "#2562: Java block-local classes, enums, records, and interfaces use source-type-relative JLS 13.1 Host$NLocal identities with javac-compatible per-(host, simple-name) numbering; anonymous numbering remains separate. Lexical aliases begin at each declaration and end with its immediate block. Expanded java-local-class-naming fixtures cover declaration order, disjoint blocks, initializers, lambdas, local type kinds, and recursive local/member/anonymous host chains. Prior d04298a91beec76d0fa7099b3d71265723be60c1df688969aa954f135dd49686 -> 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197; scaling 1.204 < 1.5.",
|
||||
"_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 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197 -> 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee.",
|
||||
"_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 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee -> a9943355e945e03ddb87c800f4cc1f62b3d04feefb3ec64c258d8e0bb3b3fcd9."
|
||||
"_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 — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 6dd5913a58400a191ff54abf9b852b03d5add657d16c11e60a7c4608ba186197 -> 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee.",
|
||||
"_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|…` instead of `1|…`. 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 310adbc2e0827b5ac749acaa981cd12d256fc5b7cbc5592c5bee219e92abf9ee -> a9943355e945e03ddb87c800f4cc1f62b3d04feefb3ec64c258d8e0bb3b3fcd9.",
|
||||
"capture_groups_small": 5005,
|
||||
"capture_groups_large": 16005,
|
||||
"capture_groups_fp": 3452,
|
||||
"fixture_count": 206
|
||||
},
|
||||
"java-local-types": {
|
||||
"fingerprint": "8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633",
|
||||
"scaling_budget": 1.5,
|
||||
"_added": "#2562 performance follow-up: co-scales same-host, same-name local classes and anonymous classes to gate JLS binary-name ordinal allocation. Precomputed per-sequence ordinals reduce the focused 100->800 workload from 176->6655ms to 141->752ms; normalized 250->800 scaling is 1.054.",
|
||||
"_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 a9ad88de21ca6747a923260dbdf677fb74a004abbf9d57781f745e3a9027530b -> 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236.",
|
||||
"_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 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236 -> 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633."
|
||||
"_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 — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior a9ad88de21ca6747a923260dbdf677fb74a004abbf9d57781f745e3a9027530b -> 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236.",
|
||||
"_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|…` instead of `1|…`. 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 3ca67847ea2b9a71b0a41e09f943767e5a2d3a113d3e203499ee364e37f40236 -> 8c50bbc83dff4f7f5abd06078aa6abc6b64af05fddb17ee826b5f3df3d346633."
|
||||
},
|
||||
"typescript": {
|
||||
"fingerprint": "248b56f0d7a0a6fc7a949dc7afb8611e135ed642bccc2631b96ebb9d686bb965",
|
||||
"fingerprint": "f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7",
|
||||
"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 27f937bfb47d4bded316ea3c785ff659c8cd88a5761d928f113477a08c802c78 -> e05446620c5b80b7aae291cfdf32f693580fada2ae687124769b04a0c03bfe63; scaling 0.983 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: lexical callable bindings, direct-callee argument metadata, and invocation-result suppression. Prior db5933cc6760234ed7d495123410feba6de243646d583f20d43032b9459f81fd -> 27f937bfb47d4bded316ea3c785ff659c8cd88a5761d928f113477a08c802c78; scaling 0.975 < 1.5.",
|
||||
"_rebaselined_callable_flow": "Callable assignment/copy/formal/argument/invoke facts (also consumed by Vue script blocks). Prior 25de86fd3377132c4e35d3d98f4f94a58e0cfeb7c22948a8ea3be4e793be74fd -> db5933cc6760234ed7d495123410feba6de243646d583f20d43032b9459f81fd; measured scaling ratio 0.951 < 1.5.",
|
||||
"_rebaselined": "#1962: F44 (class scope@), F85 (enum member declarations), F87 (optional_parameter type annotations) add new captures \u2014 fingerprint drift expected.",
|
||||
"_note": "#1968: F44, F85, F87 \u2014 fingerprint drift expected.",
|
||||
"_rebaselined": "#1962: F44 (class scope@), F85 (enum member declarations), F87 (optional_parameter type annotations) add new captures — fingerprint drift expected.",
|
||||
"_note": "#1968: F44, F85, F87 — fingerprint drift expected.",
|
||||
"_rebaselined_2522": "#2522 intentional @reference.value-ref/property-key capture additions. GitHub Actions run 29553361660 job 87800394279: prior 3f44a4a6892698df2d145c8ff2812c3b318807648983c88aca28fbd694f172f9 -> 25de86fd3377132c4e35d3d98f4f94a58e0cfeb7c22948a8ea3be4e793be74fd; scaling ratio 0.987 < 1.5.",
|
||||
"_rebaselined_2550_instance_model": "PR #2549 (#2545/#2551): object literals emit @scope.object (was unscoped, then @scope.block during development). Prior e05446620c5b80b7aae291cfdf32f693580fada2ae687124769b04a0c03bfe63 -> 3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4; scaling 0.981 < 1.5.",
|
||||
"_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) \u2014 every other capture count is byte-identical, so no existing capture moved. Prior 3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4 -> 281e95484203b481094729ca249ef0423c41273eac35e424cdfd032a0dac7699.",
|
||||
"_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 cad25be9f81d6e021ebae8dcb166bc0af3a1ba8021f1506f6ca93fd4c2649000 -> 9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc.",
|
||||
"_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 9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc -> cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff.",
|
||||
"_rebaselined_inferred_field_receiver_2807": "#2807: inference-typed class fields now emit a type binding \u2014 `public_field_definition` with a `new_expression` value, and `this.<field> = new ...` carrying a @type-binding.this-field marker. ADDS @type-binding.constructor captures only; no capture is removed, and the annotated form is unchanged because annotation outranks constructor-inferred in typeBindingStrength. Prior cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff -> 248b56f0d7a0a6fc7a949dc7afb8611e135ed642bccc2631b96ebb9d686bb965; scaling 0.994 < 1.5."
|
||||
"_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) — every other capture count is byte-identical, so no existing capture moved. Prior 3280b13d3f9378ab23eee31c2edc779b5a9ae1e7bb510c23a24855b44406d2f4 -> 281e95484203b481094729ca249ef0423c41273eac35e424cdfd032a0dac7699.",
|
||||
"_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 — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior cad25be9f81d6e021ebae8dcb166bc0af3a1ba8021f1506f6ca93fd4c2649000 -> 9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc.",
|
||||
"_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|…` instead of `1|…`. 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 9e112415f1169f08576826c12ea1d137d1994e34b44c45986c9ffee83b8b4edc -> cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff.",
|
||||
"_rebaselined_inferred_field_receiver_2807": "#2807: inference-typed class fields now emit a type binding — `public_field_definition` with a `new_expression` value, and `this.<field> = new ...` carrying a @type-binding.this-field marker. ADDS @type-binding.constructor captures only; no capture is removed, and the annotated form is unchanged because annotation outranks constructor-inferred in typeBindingStrength. Prior cdefe88d3c275f31953216c676ef32c7bf5727d56b9c3840b81ee6bf85749dff -> 248b56f0d7a0a6fc7a949dc7afb8611e135ed642bccc2631b96ebb9d686bb965; scaling 0.994 < 1.5.",
|
||||
"_rebaselined_ts_heritage_2842": "#2842 review: TypeScript heritage capture now emits `@reference.inherits` for `interface_declaration` (bases on `extends_type_clause`) and `abstract_class_declaration` (bases on `class_heritage`), which were both silently skipped — so `interface B extends A` and `abstract class X implements I` produced no edge and every interface-dispatch walk dead-ended on a bodiless declaration. Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus (145 files) with and without the change: the ONLY deltas are @reference.inherits 17 -> 20 (+3) and its paired @reference.name 245 -> 248 (+3), emitted together by emitTsInheritanceBase. Every other capture count is byte-identical, so no existing capture moved. The +3 is the three `interface X extends BasePayload` declarations in typescript-generic-calls/src/{auth,admin,guest}.ts. javascript is unchanged (no interfaces in the language). Prior 248b56f0d7a0a6fc7a949dc7afb8611e135ed642bccc2631b96ebb9d686bb965 -> 7a960908031331360ce582f5b55b7681e1cd7f8a2eabfd73c00982cb17f2a949.",
|
||||
"capture_groups_small": 4503,
|
||||
"capture_groups_large": 14403,
|
||||
"capture_groups_fp": 2338,
|
||||
"fixture_count": 151,
|
||||
"_rebaselined_blind_spots_2856": "#2856 blind-spots series: the JS/TS SCOPE queries gained capture rules, so fingerprint drift is expected and additive. Verified before re-baselining by diffing the capture-name sets in both scope queries against origin/main: TypeScript gained exactly @reference.read.identifier (A2 bare-identifier reads in value positions) and @reference.type (R2-2 type references, so a declared contract stops reporting incoming:{}); JavaScript gained exactly @reference.read.identifier, @reference.read.destructured (R2-1c) and @reference.write.property-key (R2-1b record-construction writes). NOTHING was removed on either side — the delta is a pure superset, which is the check that no existing capture moved. capture_groups_small/large are unchanged (4503/14403) because those measure the SYNTHETIC scaling source, which this branch does not touch; only the fixture-corpus count moves. capture_groups_fp 2097 -> 2338 and fixture_count 146 -> 151 from 21 new lang-resolution fixtures. Scaling stayed linear and inside budget: typescript 1.116 < 1.5, javascript 1.010 < 1.5. Prior typescript ed92588e0fc7b28b3a0174339ac378b4dd85965fe007db1208dea97a65ce0571 -> f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7; prior javascript 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594 -> 2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3."
|
||||
},
|
||||
"javascript": {
|
||||
"fingerprint": "806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594",
|
||||
"fingerprint": "2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3",
|
||||
"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 b59fe8135b6a31a12bc3f872b224054b16592588153ae3661d03958d787c76f3 -> 479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b; scaling 1.050 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: lexical callable bindings, direct-callee argument metadata, and invocation-result suppression. Prior 917a9cd975ba035bdad71fdb70cd72eeddec58c25797e5a1addfa6172808a55c -> b59fe8135b6a31a12bc3f872b224054b16592588153ae3661d03958d787c76f3; scaling 1.093 < 1.5.",
|
||||
|
|
@ -164,23 +189,28 @@
|
|||
"_rebaselined": "#1956 synth-widening: + javascript-qualified-base fixture; synthesizeJsInheritanceReferences now handles a member_expression base (class S extends ns.Base -> Base), matching the #1940 legacy leg + the TS terminalTsTypeNameNode property_identifier case, at parity. Linear (~1.05). | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged.",
|
||||
"_rebaselined_2522": "#2522 intentional @reference.value-ref/property-key capture additions. GitHub Actions run 29553361660 job 87800394279: prior d72f03c6c502235d2d4b74d66baa5c7d361f040d7a1b72e84acad61210d05ae8 -> 5567dd47e7ba29821a518c4a9852adc3b774e25ef3e7a6e2b3ecb7b59ddab73c; scaling ratio 1.031 < 1.5.",
|
||||
"_rebaselined_2550_instance_model": "PR #2549 (#2545/#2551): object literals emit @scope.object. Prior 479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b -> f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c; scaling 1.096 < 1.5.",
|
||||
"_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) \u2014 every other capture count is byte-identical, so no existing capture moved. Prior f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c -> 90601494695b834d3a9af7ac4844eac603f4f432809a05554cc59de0674a4354.",
|
||||
"_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 1c71ef628eb75a3b111afa8c2a7c351c16a7f5aab9fac2f098f82b2866312aa8 -> 83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc.",
|
||||
"_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 83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc -> 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594."
|
||||
"_rebaselined_receiver_owner_2701": "#2701: every non-arrow function form now carries a `@receiver-owner.this` marker on the same node as `@scope.function`, so a scope that BINDS its own `this` can stop the receiver walk (`Scope.ownsReceivers`). Verified before re-baselining by diffing the capture-name histogram over this same fixture corpus against 1d3088173f6f93827641b476d614d5d15cd4f3ea: the ONLY delta is @receiver-owner.this (typescript +143, javascript +32) — every other capture count is byte-identical, so no existing capture moved. Prior f1ccf42a36895c8e34dcb724286f247d469835f2dcbb23ad3347190adc7fde1c -> 90601494695b834d3a9af7ac4844eac603f4f432809a05554cc59de0674a4354.",
|
||||
"_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 — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 1c71ef628eb75a3b111afa8c2a7c351c16a7f5aab9fac2f098f82b2866312aa8 -> 83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc.",
|
||||
"_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|…` instead of `1|…`. 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 83344b7cba093702f4528eeee44e438809c229d43b12e69ed288812ce7ffc7bc -> 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594.",
|
||||
"_rebaselined_blind_spots_2856": "#2856 blind-spots series: the JS/TS SCOPE queries gained capture rules, so fingerprint drift is expected and additive. Verified before re-baselining by diffing the capture-name sets in both scope queries against origin/main: TypeScript gained exactly @reference.read.identifier (A2 bare-identifier reads in value positions) and @reference.type (R2-2 type references, so a declared contract stops reporting incoming:{}); JavaScript gained exactly @reference.read.identifier, @reference.read.destructured (R2-1c) and @reference.write.property-key (R2-1b record-construction writes). NOTHING was removed on either side — the delta is a pure superset, which is the check that no existing capture moved. capture_groups_small/large are unchanged (4503/14403) because those measure the SYNTHETIC scaling source, which this branch does not touch; only the fixture-corpus count moves. capture_groups_fp 2097 -> 2338 and fixture_count 146 -> 151 from 21 new lang-resolution fixtures. Scaling stayed linear and inside budget: typescript 1.116 < 1.5, javascript 1.010 < 1.5. Prior typescript ed92588e0fc7b28b3a0174339ac378b4dd85965fe007db1208dea97a65ce0571 -> f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7; prior javascript 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594 -> 2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3."
|
||||
},
|
||||
"kotlin": {
|
||||
"fingerprint": "efd5dbf80ffcd3bab2834d1010f6fe2b239dcc5d58229938dea9cff8d0f380f2",
|
||||
"fingerprint": "a184f8ff0ae40d246db855b63f7ff26bda3afac03e5f4c76e4593c7e2cefce54",
|
||||
"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 bddba25d5a88152bbbee8d70e82c944b5302accb4b625df782adb1d4f7a7ac12 -> e856951c2a779163d555dadc8e1bf59304a86caed78ac1f450d9caa2b50f63d1; scaling 1.090 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: Kotlin callable-reference flow facts with invocation-result suppression. Prior 4900431791f2b9280009deb2b82659c26ead8aa6fb8731190a7c505dec5a9041 -> bddba25d5a88152bbbee8d70e82c944b5302accb4b625df782adb1d4f7a7ac12; scaling 0.880 < 1.5.",
|
||||
"_added": "#1951: bench coverage added (was ungated); scale source heritage-bearing (: Base()); js/kotlin O(n^2) findNodeAtRange-per-match fixed to threaded captured node, now linear.",
|
||||
"_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0.",
|
||||
"_rebaselined_2271": "PR #2271: re-vendored tree-sitter-kotlin 0.3.8 -> unreleased fwcd main c8ac3d26 for `fun interface` support + new kotlin-fun-interface fixture in the corpus. Drift is both corpus-additive (the fixture) and grammar-driven (the new grammar parses `fun interface` as a class_declaration, not an ERROR node). Baselined to the NEW grammar's fingerprint, so this --check passes only once the regenerated prebuilds land \u2014 until then CI loads the committed 0.3.8 binary and the bench is red, same as the kotlin fun-interface integration tests. scaling ~0.83 (linear).",
|
||||
"_rebaselined_2271": "PR #2271: re-vendored tree-sitter-kotlin 0.3.8 -> unreleased fwcd main c8ac3d26 for `fun interface` support + new kotlin-fun-interface fixture in the corpus. Drift is both corpus-additive (the fixture) and grammar-driven (the new grammar parses `fun interface` as a class_declaration, not an ERROR node). Baselined to the NEW grammar's fingerprint, so this --check passes only once the regenerated prebuilds land — until then CI loads the committed 0.3.8 binary and the bench is red, same as the kotlin fun-interface integration tests. scaling ~0.83 (linear).",
|
||||
"_rebaselined_2522_review_fixes": "PR #2522 review fixes: fieldless assignment nodes decomposed positionally. Prior e856951c2a779163d555dadc8e1bf59304a86caed78ac1f450d9caa2b50f63d1 -> 4b31f46cfb004ba769a96feeb06ae4ef109c77410f54e7aaab4a688df599b112; scaling ratio re-verified within budget.",
|
||||
"_rebaselined_2550_instance_model": "PR #2549 (#2545): anonymous object expressions (object_literal) emit @scope.class, and the kotlin-object-literal-scope fixture joined the corpus. Prior 4b31f46cfb004ba769a96feeb06ae4ef109c77410f54e7aaab4a688df599b112 -> a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091; scaling 0.951 < 1.5.",
|
||||
"_rebaselined_2563_instance_ownership": "#2563: kotlin-instance-ownership adds unrelated, inherited, outer-instance, and anonymous-object coverage. Prior a6fce0dff00e88d41d85023eaf3f35016b5217c7e5225f24a598e4c70bb63091 -> 9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195; scaling 1.257 < 1.5.",
|
||||
"_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 9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195 -> d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1.",
|
||||
"_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 d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1 -> c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b.",
|
||||
"_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 \u2014 the two whose fixture corpora contain such receivers. Prior c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b -> efd5dbf80ffcd3bab2834d1010f6fe2b239dcc5d58229938dea9cff8d0f380f2."
|
||||
"_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 — the tag is added to existing call matches, never a new match — so this is digest drift only. Prior 9f159f8810d342ef1c821f466efd6920dad9a190f06000056e6cd2815861b195 -> d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1.",
|
||||
"_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|…` instead of `1|…`. 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 d3c4d2fa0d82d248a2299cfc888b067187ad1faf2c87a97f93c6ed835eefc3f1 -> c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b.",
|
||||
"_rebaselined_2766_await_subscript_emission": "#2766: extractMixedChain now walks THROUGH await and subscript nodes and peels transparent wrappers at loop entry, so sites whose receiver is `repos[0]` or `(await f())` mint a receiver chain where they previously minted none. EMISSION CHANGE: more sites carry `@reference.receiver-chain`; no existing chain changed shape. Only go and kotlin drifted of 15 — the two whose fixture corpora contain such receivers. Prior c1f0cc9058ab11b7cd6fc8b440deb6db2b2f530f2eb21178923e68a3d0796c4b -> efd5dbf80ffcd3bab2834d1010f6fe2b239dcc5d58229938dea9cff8d0f380f2.",
|
||||
"capture_groups_small": 4753,
|
||||
"capture_groups_large": 15203,
|
||||
"capture_groups_fp": 2334,
|
||||
"fixture_count": 137
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -209,10 +209,22 @@ const LANGS = [
|
|||
// Heritage-bearing: `: public Base, public Mixin` (single + multiple
|
||||
// inheritance) drives emitCppInheritanceCaptures (#1951) at scale. Added
|
||||
// (was unbenched); adding it exposed + fixed the same O(n²) root-walk (#1956).
|
||||
//
|
||||
// Also GENERIC-MEMBER-bearing (#2833): `Repo<Entity_n> repo;` is a member
|
||||
// whose declared type is a bare `template_type`, and
|
||||
// `std::vector<Entity_n> items;` is the far commoner spelling where a
|
||||
// `qualified_identifier` WRAPS that template_type. Both were absent, and
|
||||
// their absence is why two successive rounds of `field_declaration`
|
||||
// type-binding rules landed with a byte-identical cpp fingerprint: the gate
|
||||
// could not see a member field it had no instance of. With them present,
|
||||
// reverting either round of rules drifts the fingerprint, which is the
|
||||
// property that makes the gate worth running.
|
||||
header:
|
||||
'#include <string>\n\nclass Base {\n public:\n long baseId() const { return 0; }\n};\n\nclass Mixin {\n public:\n void mix() {}\n};\n\n',
|
||||
'#include <string>\n#include <vector>\n\ntemplate <typename T>\nclass Repo {\n public:\n void save(T v) {}\n};\n\nclass Base {\n public:\n long baseId() const { return 0; }\n};\n\nclass Mixin {\n public:\n void mix() {}\n};\n\n',
|
||||
unit: (n) =>
|
||||
`class Entity${n} : public Base, public Mixin {\n public:\n long id;\n std::string name;\n` +
|
||||
` Repo<Entity${n}> repo;\n` +
|
||||
` std::vector<Entity${n}> items;\n` +
|
||||
` long getId() const { return id; }\n` +
|
||||
` void setName(std::string v) { name = v; }\n};\n\n`,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,9 +5,19 @@
|
|||
* 1. Global `gitnexus` on PATH (best — no install step)
|
||||
* 2. npm 11+ with pnpm on PATH → `pnpm --allow-build=… dlx` (avoids the npx
|
||||
* arborist crash *and* pnpm 10+ ignored-build-script failures, #1939)
|
||||
* 3. npm < 11 with npm on PATH → `npx` (works; simpler than pnpm dlx)
|
||||
* 4. pnpm-only → `pnpm --allow-build=… dlx`
|
||||
* 5. Last resort → `npx` (warned on npm 11+ from analyze.ts)
|
||||
* 3. npm 11+ without pnpm but with bunx → `bunx` (dodges the same crash)
|
||||
* 4. npm < 11 with npm on PATH → `npx` (works; simpler than pnpm dlx)
|
||||
* 5. pnpm-only → `pnpm --allow-build=… dlx`
|
||||
* 6. bun-only → `bunx`
|
||||
* 7. Last resort → `npx` (warned on npm 11+ from analyze.ts)
|
||||
*
|
||||
* The bun branches exist because a Node toolchain is no longer implied: on a
|
||||
* bun-only machine npm, npx and pnpm are all absent, so every rung above
|
||||
* resolved to `npx` and the emitted command could not run at all. `bunx` is
|
||||
* bun's install-free one-shot runner and needs no allow-build equivalent — bun
|
||||
* skips lifecycle scripts unconditionally for a `bunx` fetch, which the native
|
||||
* loader recovers from directly (see core/lbug/native-check.ts). Both bun rungs
|
||||
* gate on `bunx` actually running, not merely existing on PATH — see `hasBun`.
|
||||
*
|
||||
* The `--allow-build` flags MUST precede the `dlx` token. pnpm < 10.14 keeps
|
||||
* `dlx` in its argv escape list, so flags placed *after* `dlx` are parsed as
|
||||
|
|
@ -42,7 +52,12 @@ const PNPM_ALLOW_BUILD_EMBEDDINGS = ['onnxruntime-node'];
|
|||
// hook first runs `git rev-parse --git-common-dir` (~2s) and `git rev-parse HEAD`
|
||||
// (~3s); the pnpm path then adds up to two 1s `--version` probes (npm, pnpm), so
|
||||
// the worst case is ~7s — within budget. A healthy `--version` returns in well
|
||||
// under a second, so the realistic cost is far lower.
|
||||
// under a second, so the realistic cost is far lower. The bun rungs add at most
|
||||
// one more 1s probe (`bunx --version`), reached only when pnpm is unusable and
|
||||
// npm is 11+ or unreadable, for a ~8s theoretical cap. That cap needs an absent
|
||||
// pnpm to burn its full second, which only Windows can do (`shell: true` spawns
|
||||
// cmd.exe); on POSIX an absent pnpm ENOENTs in ~1ms, so the real ceiling is
|
||||
// unmoved.
|
||||
const PROBE_TIMEOUT_MS = 1000;
|
||||
|
||||
/**
|
||||
|
|
@ -104,9 +119,17 @@ function resolveOnPath(
|
|||
return weakHit;
|
||||
}
|
||||
|
||||
// One spawn of `<command> --version` → { major, minor } (each null when
|
||||
// One spawn of `<command> --version` → { ran, major, minor } (versions null when
|
||||
// unreadable). Version injection happens at the resolver seam (getNpmMajorVersion
|
||||
// / formatPnpmAllowBuildArgs), so this stays a pure real-process probe.
|
||||
//
|
||||
// `ran` is liveness, kept separate from the version because a PATH hit proves a
|
||||
// file exists, not that it works, and the two answers differ: a banner-printing
|
||||
// or oddly-versioned tool is alive with `major: null`, while a stale shim left by
|
||||
// a partial uninstall is neither. Only the bun rung consults `ran` today (see
|
||||
// hasBun) — it is the one runner with no version to read, so a dedicated probe is
|
||||
// its only liveness signal; pnpm gets the same evidence for free from the version
|
||||
// spawn it must make anyway, and deliberately forgives an unreadable one (#1939).
|
||||
function probeVersion(command) {
|
||||
try {
|
||||
const output = execFileSync(command, ['--version'], {
|
||||
|
|
@ -131,11 +154,13 @@ function probeVersion(command) {
|
|||
.find((l) => /^v?\d+\.\d+/.test(l));
|
||||
const match = versionLine ? versionLine.match(/^v?(\d+)\.(\d+)/) : null;
|
||||
return {
|
||||
ran: true,
|
||||
major: match ? Number(match[1]) : null,
|
||||
minor: match ? Number(match[2]) : null,
|
||||
};
|
||||
} catch {
|
||||
return { major: null, minor: null };
|
||||
// Spawn failure, non-zero exit, or the timeout — the command did not run.
|
||||
return { ran: false, major: null, minor: null };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -176,14 +201,14 @@ function formatDocumentationDlxCommand(gitnexusArgs, options = {}) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Resolve `gitnexus` | `pnpm` | `npx`. `GITNEXUS_INVOCATION` forces a mode
|
||||
* (test/escape hatch). `probe` is injectable so the preference order can be
|
||||
* Resolve `gitnexus` | `pnpm` | `bun` | `npx`. `GITNEXUS_INVOCATION` forces a
|
||||
* mode (test/escape hatch). `probe` is injectable so the preference order can be
|
||||
* unit-tested without spawning; it defaults to the real PATH probe. `deps` can
|
||||
* inject `{ npmMajor, pnpmMajor }` for tests.
|
||||
* inject `{ npmMajor, pnpmMajor, bunPresent, bunRuns }` for tests.
|
||||
*/
|
||||
function resolveInvocationMode(probe = resolveOnPath, deps = {}) {
|
||||
const forced = process.env.GITNEXUS_INVOCATION?.trim().toLowerCase();
|
||||
if (forced === 'gitnexus' || forced === 'pnpm' || forced === 'npx') {
|
||||
if (forced === 'gitnexus' || forced === 'pnpm' || forced === 'npx' || forced === 'bun') {
|
||||
return forced;
|
||||
}
|
||||
if (probe('gitnexus', true)) return 'gitnexus';
|
||||
|
|
@ -202,12 +227,33 @@ function resolveInvocationMode(probe = resolveOnPath, deps = {}) {
|
|||
? deps.pnpmMajor !== null
|
||||
: Boolean(probe('pnpm'));
|
||||
|
||||
// bun usability is resolved lazily: only the two branches below can select it,
|
||||
// so a machine with pnpm, or with npm < 11, never pays the PATH scan or the
|
||||
// spawn. `bunx` (not `bun`) is probed because `bunx` is what the resolved
|
||||
// command actually runs. Two gates, `&&`-ordered cheapest first: a spawn-free
|
||||
// PATH scan, then liveness — a PATH hit alone would route a present-but-broken
|
||||
// shim to a command that can only fail at execution time.
|
||||
let bunCache;
|
||||
const hasBun = () => {
|
||||
if (bunCache === undefined) {
|
||||
const present = 'bunPresent' in deps ? Boolean(deps.bunPresent) : Boolean(probe('bunx'));
|
||||
bunCache = present && ('bunRuns' in deps ? Boolean(deps.bunRuns) : probeVersion('bunx').ran);
|
||||
}
|
||||
return bunCache;
|
||||
};
|
||||
|
||||
// npm 11+ npx install crash (#1939) — prefer pnpm dlx when available.
|
||||
if (hasPnpm && npmMajor !== null && npmMajor >= 11) return 'pnpm';
|
||||
// Same crash, no pnpm to fall back on: bunx is install-free and unaffected.
|
||||
if (npmMajor !== null && npmMajor >= 11 && hasBun()) return 'bun';
|
||||
// npm 10 and earlier: npx works; prefer it over pnpm dlx when npm is present.
|
||||
if (npmMajor !== null && npmMajor < 11) return 'npx';
|
||||
// npm absent or unreadable — use pnpm if present (with allow-build flags).
|
||||
if (hasPnpm) return 'pnpm';
|
||||
// Neither npm nor pnpm — bunx is the only install-free runner left. Without
|
||||
// this rung a bun-only machine fell through to `npx`, which is not installed
|
||||
// there, so the emitted command failed with "npx: command not found".
|
||||
if (hasBun()) return 'bun';
|
||||
|
||||
return 'npx';
|
||||
}
|
||||
|
|
@ -218,6 +264,17 @@ function formatPnpmDlxCommand(gitnexusArgs, options = {}, deps = {}) {
|
|||
return `pnpm ${prefix}dlx ${NPX_REF} ${gitnexusArgs}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* bun's install-free one-shot runner. Deliberately flag-free: bun has no
|
||||
* per-invocation `--allow-build` equivalent (`--trust` is a `bun add`/`bun
|
||||
* install` flag that writes trustedDependencies into a project package.json,
|
||||
* which a one-shot `bunx` has nowhere to put), so the skipped lifecycle copy is
|
||||
* recovered by the native loader instead of by the invocation.
|
||||
*/
|
||||
function formatBunxCommand(gitnexusArgs) {
|
||||
return `bunx ${NPX_REF} ${gitnexusArgs}`;
|
||||
}
|
||||
|
||||
function formatAnalyzeCommand(options = {}, deps = {}) {
|
||||
const suffix = options.embeddings ? ' --embeddings' : '';
|
||||
// Keep the stale-index hook budget tight by querying each tool at most once.
|
||||
|
|
@ -238,7 +295,8 @@ function formatAnalyzeCommand(options = {}, deps = {}) {
|
|||
const forced = process.env.GITNEXUS_INVOCATION?.trim().toLowerCase();
|
||||
// pnpm is only consulted when no non-pnpm mode is already certain: forced
|
||||
// gitnexus/npx never use pnpm, and a present global gitnexus wins outright.
|
||||
const mightUsePnpm = forced === 'pnpm' || (forced !== 'gitnexus' && forced !== 'npx');
|
||||
const mightUsePnpm =
|
||||
forced === 'pnpm' || (forced !== 'gitnexus' && forced !== 'npx' && forced !== 'bun');
|
||||
if (mightUsePnpm && (forced === 'pnpm' || !probe('gitnexus', true))) {
|
||||
const { major, minor } = probeVersion('pnpm');
|
||||
// Carry presence separately from version: when the version probe fails
|
||||
|
|
@ -252,6 +310,7 @@ function formatAnalyzeCommand(options = {}, deps = {}) {
|
|||
const mode = resolveInvocationMode(probe, resolved);
|
||||
if (mode === 'gitnexus') return `gitnexus analyze${suffix}`;
|
||||
if (mode === 'pnpm') return `${formatPnpmDlxCommand(`analyze${suffix}`, options, resolved)}`;
|
||||
if (mode === 'bun') return formatBunxCommand(`analyze${suffix}`);
|
||||
return `npx ${NPX_REF} analyze${suffix}`;
|
||||
}
|
||||
|
||||
|
|
@ -268,6 +327,7 @@ function buildRunnerArgv(mode, gitnexusArgs, deps = {}) {
|
|||
(a) => a === '--embeddings' || a.startsWith('--embeddings='),
|
||||
);
|
||||
if (mode === 'gitnexus') return { program: 'gitnexus', args: [...gitnexusArgs] };
|
||||
if (mode === 'bun') return { program: 'bunx', args: [NPX_REF, ...gitnexusArgs] };
|
||||
if (mode === 'pnpm') {
|
||||
return {
|
||||
program: 'pnpm',
|
||||
|
|
@ -279,6 +339,7 @@ function buildRunnerArgv(mode, gitnexusArgs, deps = {}) {
|
|||
|
||||
module.exports = {
|
||||
formatAnalyzeCommand,
|
||||
formatBunxCommand,
|
||||
formatDocumentationDlxCommand,
|
||||
formatPnpmAllowBuildArgs,
|
||||
formatPnpmDlxCommand,
|
||||
|
|
@ -291,7 +352,7 @@ module.exports = {
|
|||
};
|
||||
|
||||
// Direct-exec entrypoint (#1945): `node run.cjs <gitnexus args…>` resolves the
|
||||
// best available runner (global `gitnexus` → `pnpm dlx` → `npx`) at call time and
|
||||
// best available runner (global `gitnexus` → `pnpm dlx` → `bunx` → `npx`) at call time and
|
||||
// runs it, inheriting stdio and propagating the child's exit code. This lets the
|
||||
// committed skills and generated AGENTS.md/CLAUDE.md reference ONE stable,
|
||||
// CLI-neutral command without baking in a package-manager assumption. `gitnexus
|
||||
|
|
|
|||
60
gitnexus/package-lock.json
generated
60
gitnexus/package-lock.json
generated
|
|
@ -10,7 +10,7 @@
|
|||
"hasInstallScript": true,
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"dependencies": {
|
||||
"@ladybugdb/core": "^0.18.3",
|
||||
"@ladybugdb/core": "^0.19.0",
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"busboy": "^1.6.0",
|
||||
|
|
@ -1254,9 +1254,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@ladybugdb/core": {
|
||||
"version": "0.18.3",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.18.3.tgz",
|
||||
"integrity": "sha512-XjpPKW4MrL28D2gYGTZuIjiEcPx12L21lx58QggrdrItw8o/e9Lmg/Ejoo4Kz08lZj+rIcC1Fu9thzIYOTUlJw==",
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core/-/core-0.19.0.tgz",
|
||||
"integrity": "sha512-vlE2D2b6Ej/OiwtBCRtye34j8uRH9aV/ziJM+ZnXow77VkVr8zeVjSrAEj/eisj+uPPQ/pmuOXzJjJra0m0Bbg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -1265,17 +1265,17 @@
|
|||
"node-addon-api": "^6.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@ladybugdb/core-darwin-arm64": "0.18.3",
|
||||
"@ladybugdb/core-darwin-x64": "0.18.3",
|
||||
"@ladybugdb/core-linux-arm64": "0.18.3",
|
||||
"@ladybugdb/core-linux-x64": "0.18.3",
|
||||
"@ladybugdb/core-win32-x64": "0.18.3"
|
||||
"@ladybugdb/core-darwin-arm64": "0.19.0",
|
||||
"@ladybugdb/core-darwin-x64": "0.19.0",
|
||||
"@ladybugdb/core-linux-arm64": "0.19.0",
|
||||
"@ladybugdb/core-linux-x64": "0.19.0",
|
||||
"@ladybugdb/core-win32-x64": "0.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ladybugdb/core-darwin-arm64": {
|
||||
"version": "0.18.3",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.18.3.tgz",
|
||||
"integrity": "sha512-DGZTOlvSS4esEb1vTekY5IDoAvZAeYzR5cXVkECtQj9BVkk05zsvCAdTPo1Rz1BuI0qvqUVF+2WlIerI67iA2g==",
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-arm64/-/core-darwin-arm64-0.19.0.tgz",
|
||||
"integrity": "sha512-3Ut3XL9kowzBoHw0wrN3QnW4xy5wYoPBypeuMtH92j59fol2w9e3lQJ6DM29YJ4F6mAVfW2cYGBXH2l9aXGmmw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1286,9 +1286,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@ladybugdb/core-darwin-x64": {
|
||||
"version": "0.18.3",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-x64/-/core-darwin-x64-0.18.3.tgz",
|
||||
"integrity": "sha512-Qp6j0CM/orBlK6KD0p/s4ofkIhNUwi1hdCgMw+fj81UHugWHkVLiYV4grRBdHhyplw+snchZpTxvfpxFbkG1Cw==",
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-darwin-x64/-/core-darwin-x64-0.19.0.tgz",
|
||||
"integrity": "sha512-KHuCBx+jkyxdfFEmmbsfUS1f108P4rS+VNkwCrMLvzJmJOZTZgNKeRmT0vO7qRum5KEoHSIPJLj2Le//rCYigQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1299,9 +1299,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@ladybugdb/core-linux-arm64": {
|
||||
"version": "0.18.3",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.18.3.tgz",
|
||||
"integrity": "sha512-F9miYjBuS43I7uNG199FNMqwdHJ98WA6dU3v2SZCeLXmXCdRzmYcuHQWlbNr2Tba9CX58w2XvBZoUaXZKJ/yKQ==",
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-arm64/-/core-linux-arm64-0.19.0.tgz",
|
||||
"integrity": "sha512-z4Z67LZlgj6H7YnKMB1PornbdmeszVxfENusfDQ2MFyOyrze1X6c/3MkhVPyunuaTtriN9SBnEdyK39mB7NdyQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -1312,9 +1312,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@ladybugdb/core-linux-x64": {
|
||||
"version": "0.18.3",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.18.3.tgz",
|
||||
"integrity": "sha512-AfG5RDp/f/IDctDMpTAT5+2MYNtlWT191xiQNjSaWD4X85DhY3Dzps8Qu5VteIAPih5d6mmoaKGs8q0XIjfkFA==",
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-linux-x64/-/core-linux-x64-0.19.0.tgz",
|
||||
"integrity": "sha512-aJOh7+XbTzCLNloK+KlDXuRmHgr7nLqyQwR/BR8fP/XsWVnxCKGpVVL8s+Lms7JNQAh6vEsvExttGgUq9hAu1Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -1325,9 +1325,9 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@ladybugdb/core-win32-x64": {
|
||||
"version": "0.18.3",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.18.3.tgz",
|
||||
"integrity": "sha512-bHuFk0m9cnq0WGd9I4D8or8g6cC/BS58iatMtilqM3JpDPIQIFk6MQl6exL7P4xyWbkLwQgsrv2ToDnyoQNKvg==",
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@ladybugdb/core-win32-x64/-/core-win32-x64-0.19.0.tgz",
|
||||
"integrity": "sha512-y2/IOMKmydo4ZfQPDZuZhiFC104VJQ9lwc0w1KVdvb4Z2RIUUL8/tn5QD4Uq8DUrJGxQhoEESPnlkk69cKaaDQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -3018,9 +3018,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.6.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz",
|
||||
"integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==",
|
||||
"version": "8.6.2",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz",
|
||||
"integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3",
|
||||
|
|
@ -5430,9 +5430,9 @@
|
|||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.1",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz",
|
||||
"integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==",
|
||||
"version": "4.23.5",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.5.tgz",
|
||||
"integrity": "sha512-rw55FUaqOoI7RvlQwLbhO4nSDApnQ4/CykPuiQ/EPvtrX3WA9Ig55jIt9VvbBJbzJuj12ueRu4PMZ2SxPVbihg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@
|
|||
"version": "node scripts/sync-plugin-manifests.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ladybugdb/core": "^0.18.3",
|
||||
"@ladybugdb/core": "^0.19.0",
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"busboy": "^1.6.0",
|
||||
|
|
@ -97,6 +97,11 @@
|
|||
"@huggingface/transformers": "^4.1.0",
|
||||
"onnxruntime-node": "^1.24.0"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@ladybugdb/core",
|
||||
"gitnexus",
|
||||
"tree-sitter"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@babel/generator": "^8.0.0",
|
||||
"@babel/parser": "^8.0.0",
|
||||
|
|
|
|||
|
|
@ -47,6 +47,13 @@ export const WINDOWS_WEIGHTS_SEC: Readonly<Record<string, number>> = {
|
|||
'test/integration/cli-e2e.test.ts': 361,
|
||||
'test/integration/worker-pool.test.ts': 222,
|
||||
'test/unit/incremental-vector-extension-ordering.test.ts': 87,
|
||||
// ESTIMATE, not a measurement (#2841): this suite drives more full
|
||||
// `runFullAnalysis` cycles than the VECTOR sibling above, so the 8 s
|
||||
// PER_FILE_OVERHEAD floor would badly under-charge it and skew the Windows
|
||||
// split — the failure mode that produced the job timeouts this table exists
|
||||
// to prevent. Scaled from the sibling's measured 87 s by analyze-run count.
|
||||
// Replace with a real figure after the first green Windows matrix run.
|
||||
'test/unit/incremental-index-extension-dml-gate.test.ts': 180,
|
||||
'test/integration/cli-limit-e2e.test.ts': 75,
|
||||
'test/unit/hooks.test.ts': 26,
|
||||
'test/integration/analyze-heap-oom-e2e.test.ts': 23,
|
||||
|
|
|
|||
|
|
@ -154,6 +154,18 @@ const LBUG_NATIVE = [
|
|||
// proven on the windows-latest native addon, not just Ubuntu. Budget: ~25s
|
||||
// on Linux → expect ~2min on the slowest Windows shard.
|
||||
'test/unit/incremental-vector-extension-ordering.test.ts',
|
||||
// #2841: the FTS half of that same gate, plus the both-extensions-blocked
|
||||
// case — and it needs this matrix for two reasons the VECTOR sibling above
|
||||
// does not cover. The reported failure environment is a machine where the
|
||||
// extension stopped LOADING, which is the #2374 class and Windows-reported
|
||||
// (the same reason fts-extension-e2e.test.ts is registered below), so the
|
||||
// FTS-unavailable branch has to run on a real Windows/macOS runner rather
|
||||
// than only on Ubuntu where FTS always loads. And its both-blocked case is
|
||||
// gated on GITNEXUS_REQUIRE_VECTOR=1, which ci-tests.yml sets ONLY on this
|
||||
// job — everywhere else an unavailable VECTOR extension skips instead of
|
||||
// failing. Budget: four real analyze runs, so expect it to sit alongside the
|
||||
// VECTOR sibling's ~87s Windows measurement.
|
||||
'test/unit/incremental-index-extension-dml-gate.test.ts',
|
||||
];
|
||||
|
||||
// Process spawning and CLI tests — exercise child_process with real
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ description: "Use when the user needs to run GitNexus CLI commands like analyze/
|
|||
|
||||
# GitNexus CLI Commands
|
||||
|
||||
Commands below use `node .gitnexus/run.cjs <command>` — the project-local runner `gitnexus analyze` drops next to the index. It auto-selects an available runner at call time (global `gitnexus`, else `pnpm dlx`, else `npx`), so no package-manager assumption and no global install is required.
|
||||
Commands below use `node .gitnexus/run.cjs <command>` — the project-local runner `gitnexus analyze` drops next to the index. It auto-selects an available runner at call time (global `gitnexus`, else `pnpm dlx`, else `bunx`, else `npx`), so no package-manager assumption and no global install is required — including on a bun-only machine, which has no npm, npx or pnpm at all.
|
||||
|
||||
> **Not analyzed yet, or `node .gitnexus/run.cjs` reports `Cannot find module`** (the gitignored runner is absent — e.g. a fresh clone or `git clean`)? (Re)generate it with `npx gitnexus analyze` from the project root. On **npm 11.x**, if `npx` crashes during install (`node.target is null`), install once with `npm i -g gitnexus` (then `gitnexus analyze`) or use `pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze`. See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939).
|
||||
> **Not analyzed yet, or `node .gitnexus/run.cjs` reports `Cannot find module`** (the gitignored runner is absent — e.g. a fresh clone or `git clean`)? (Re)generate it with `npx gitnexus analyze` from the project root, or `bunx gitnexus@latest analyze` on a bun-only machine. On **npm 11.x**, if `npx` crashes during install (`node.target is null`), install once with `npm i -g gitnexus` (then `gitnexus analyze`), or use `bunx gitnexus@latest analyze`, or `pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze`. See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939).
|
||||
|
||||
## Commands
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,14 @@ description: "Use when the user wants to know what will break if they change som
|
|||
| 5-15 symbols, 2-5 processes | MEDIUM |
|
||||
| >15 symbols or many processes | HIGH |
|
||||
| Critical path (auth, payments) | CRITICAL |
|
||||
| **Zero callers found** | **UNKNOWN** |
|
||||
|
||||
`UNKNOWN` is not a low rung on this scale — it means the walk could not answer.
|
||||
An empty caller set is equally consistent with "genuinely unused" and "the
|
||||
callers are not resolvable by the index" (plain-object property access, dynamic
|
||||
dispatch, cross-language calls), so few-callers ⇒ LOW does **not** apply. The
|
||||
result carries a `riskNote` saying so. Confirm with a text search before
|
||||
treating the symbol as safe to change or delete.
|
||||
|
||||
## Tools
|
||||
|
||||
|
|
|
|||
|
|
@ -148,13 +148,19 @@ finding is NOT proof of safety.
|
|||
|
||||
## Adding a source / sink / sanitizer
|
||||
|
||||
Edit the language model in `taint/typescript-model.ts` (registered via the
|
||||
explicit `registerBuiltinTaintModels` seam, keyed by `SupportedLanguages`). The
|
||||
spec is hashable data (no functions). A sanitizer's `neutralizes` lists the
|
||||
EXACT sink kinds it defends — never a blanket kill. Add a fixture + assert the
|
||||
finding (or its absence) in `test/unit/taint/` (real-source harness:
|
||||
`test/helpers/ts-cfg-harness.ts`); the end-to-end proof is
|
||||
`test/integration/cfg/`.
|
||||
Taint models cover four `SupportedLanguages` ids across three files:
|
||||
TypeScript and JavaScript use `taint/typescript-model.ts`, Python uses
|
||||
`taint/python-model.ts`, and Java uses `taint/java-model.ts`. Edit the model
|
||||
for the language you are targeting. The explicit
|
||||
`registerBuiltinTaintModels` seam in `typescript-model.ts` registers all four;
|
||||
it is not an import side effect.
|
||||
|
||||
The spec is hashable data (no functions). A sanitizer's `neutralizes` lists
|
||||
the EXACT sink kinds it defends — never a blanket kill. Add a fixture + assert
|
||||
the finding (or its absence) in `test/unit/taint/`. TypeScript and JavaScript
|
||||
use the real-source harness `test/helpers/ts-cfg-harness.ts`; Python and Java
|
||||
model matches are covered by `python-model-match.test.ts` and
|
||||
`java-model-match.test.ts`. The end-to-end proof is `test/integration/cfg/`.
|
||||
|
||||
## Validation checklist for any `--pdg` change
|
||||
|
||||
|
|
|
|||
|
|
@ -121,7 +121,8 @@ export interface GitNexusContentOptions {
|
|||
skipSkills?: boolean;
|
||||
/** Project-relative path to the runner `gitnexus analyze` drops next to the
|
||||
* index (#1945). Referenced by docs so a single CLI-neutral command resolves
|
||||
* the available runner (global `gitnexus` → `pnpm dlx` → `npx`) at call time. */
|
||||
* the available runner (global `gitnexus` → `pnpm dlx` → `bunx` → `npx`) at
|
||||
* call time. */
|
||||
runnerPath?: string;
|
||||
/** Default branch for the regression-compare example (#243). Configurable so
|
||||
* projects on `develop`/`master`/etc. don't get `base_ref: "main"` rewritten
|
||||
|
|
@ -184,9 +185,15 @@ ${tableBody}`
|
|||
// stay under the CLAUDE.md block token budget (#856); the cli skill carries the
|
||||
// full bootstrap + npm-11 fallback (`node.target is null` npx install crash).
|
||||
const runner = `node ${runnerPath}`;
|
||||
// Bootstrap names every install-free one-shot rather than the one this machine
|
||||
// resolves to: the block is committed, so a host-specific command would make
|
||||
// two contributors on different package managers rewrite it at each other on
|
||||
// every analyze (the per-machine churn of #1706). `bunx` is listed because a
|
||||
// bun-only machine has no npm, npx or pnpm at all, and the npx-only note left
|
||||
// it with a bootstrap command it could not run.
|
||||
const bootstrapNote =
|
||||
`No \`${runnerPath}\` yet? \`npx gitnexus analyze\` ` +
|
||||
'(npm 11 crash → `npm i -g gitnexus`; #1939).';
|
||||
`No \`${runnerPath}\` yet? Bootstrap with \`npx\`, \`bunx\`, or \`pnpm dlx\` — ` +
|
||||
'e.g. `bunx gitnexus@latest analyze` (npm 11 npx crash; #1939).';
|
||||
|
||||
return `${GITNEXUS_START_MARKER}
|
||||
# GitNexus — Code Intelligence
|
||||
|
|
|
|||
|
|
@ -1624,6 +1624,27 @@ const analyzeCommandImpl = async (
|
|||
|
||||
// ── Summary ────────────────────────────────────────────────────
|
||||
const s = result.stats;
|
||||
// A collapsed graph write is NOT a successful index. The other incomplete
|
||||
// reasons (`incremental-in-progress`, `embedding-checkpoint-pending`)
|
||||
// describe a run that did what it said and left work for next time; this
|
||||
// one means most of your edges are gone, so every query answers a confident
|
||||
// empty and the exit code is the only thing automation reads. Printing
|
||||
// "indexed successfully" and exiting 0 here would be the same class of
|
||||
// false certainty the check itself was written to remove.
|
||||
if (result.graphWriteCollapsed) {
|
||||
const { expected, persisted } = result.graphWriteCollapsed;
|
||||
console.log(`\n Repository indexed INCOMPLETELY (${totalTime}s)\n`);
|
||||
console.log(
|
||||
` Graph write collapsed: the pipeline produced ${expected.toLocaleString()} relationships\n` +
|
||||
` but only ${persisted.toLocaleString()} are readable from the index. Queries will answer\n` +
|
||||
` with missing edges rather than an error.\n\n` +
|
||||
` The index is recorded INCOMPLETE (graph-write-collapsed). Re-run\n` +
|
||||
` \`gitnexus analyze --force\`; if it recurs, check disk space and run \`gitnexus doctor\`.`,
|
||||
);
|
||||
console.log(` ${repoPath}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.log(`\n Repository indexed successfully (${totalTime}s)\n`);
|
||||
console.log(
|
||||
` ${(s.nodes ?? 0).toLocaleString()} nodes | ${(s.edges ?? 0).toLocaleString()} edges | ${s.communities ?? 0} clusters | ${s.processes ?? 0} flows`,
|
||||
|
|
@ -1644,9 +1665,14 @@ const analyzeCommandImpl = async (
|
|||
);
|
||||
} else {
|
||||
console.log(
|
||||
// NOT "then rerun" (#2841 §5.C): this run stamped `lastCommit`, so a
|
||||
// plain rerun on an unchanged tree takes the up-to-date fast path and
|
||||
// returns before Phase 3 could rebuild anything — the advice would be
|
||||
// ineffective exactly when the user follows it. `--repair-fts` is the
|
||||
// verb that rebuilds the search indexes without re-parsing the repo.
|
||||
`\n Warning: full-text/BM25 search is disabled — the LadybugDB FTS extension was unavailable.\n` +
|
||||
` Install it once with network access (GITNEXUS_LBUG_EXTENSION_INSTALL=auto) then rerun, or\n` +
|
||||
` run \`gitnexus analyze --repair-fts\` when connected. Run \`gitnexus doctor\` for details.`,
|
||||
` Install it once with network access (GITNEXUS_LBUG_EXTENSION_INSTALL=auto), then run\n` +
|
||||
` \`gitnexus analyze --repair-fts\` to build the search indexes. Run \`gitnexus doctor\` for details.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -192,6 +192,10 @@ function nativeStatusText(check: NativeCheckResult): string {
|
|||
return '✗ @ladybugdb/core not installed';
|
||||
case 'load_failed':
|
||||
return '✗ lbugjs.node present but failed to load';
|
||||
case 'binary_unwritable':
|
||||
// The prebuilt exists; only the copy into node_modules was refused. Saying
|
||||
// "missing" here would contradict the detail printed underneath (#2672).
|
||||
return '✗ lbugjs.node not installed (node_modules not writable)';
|
||||
default:
|
||||
// 'binary_missing', and any future kind: the conservative claim.
|
||||
return '✗ lbugjs.node missing';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* npm 11.x npx-install-crash nudge for the `analyze` command (#1939).
|
||||
*
|
||||
* The gitnexus/pnpm/npx selection itself lives in the canonical hook helper
|
||||
* The gitnexus/pnpm/bun/npx selection itself lives in the canonical hook helper
|
||||
* (hooks/claude/resolve-analyze-cmd.cjs) — self-contained CJS because the copied
|
||||
* hook runtime cannot import from the package. We reuse it here via createRequire
|
||||
* instead of re-implementing it, so there is one source of truth for the
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
import { execFileSync } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
type InvocationMode = 'gitnexus' | 'pnpm' | 'npx';
|
||||
type InvocationMode = 'gitnexus' | 'pnpm' | 'npx' | 'bun';
|
||||
|
||||
interface InvocationResolver {
|
||||
// `probe` is injectable in the cjs (defaults to the real PATH probe) so the
|
||||
|
|
@ -83,8 +83,12 @@ export function getNpmMajorVersion(): number | null {
|
|||
|
||||
/**
|
||||
* One-line stderr nudge when an npm 11+ user is on the npx install path (#1939).
|
||||
* Skipped when a global `gitnexus` or `pnpm` is already preferred, so it never
|
||||
* nags users who are not exposed to the npx/arborist crash.
|
||||
* Skipped when a global `gitnexus`, `pnpm` or `bunx` is already preferred, so it
|
||||
* never nags users who are not exposed to the npx/arborist crash. "Preferred"
|
||||
* means usable, not merely on PATH: a `bunx` shim that no longer runs fails the
|
||||
* cjs liveness probe, resolves back to `npx`, and so still gets warned here —
|
||||
* without that, a broken bunx would suppress the warning AND emit a command that
|
||||
* cannot run.
|
||||
*/
|
||||
export function warnIfNpm11NpxRisk(): void {
|
||||
if (resolveInvocationMode() !== 'npx') return;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ import {
|
|||
type LanguagePatterns,
|
||||
} from '../tree-sitter-scanner.js';
|
||||
import {
|
||||
METHOD_ANNOTATION_TO_HTTP,
|
||||
springAnnotationHttpMethods,
|
||||
intersectSpringHttpMethods,
|
||||
isRouteMemberKey,
|
||||
findEnclosingClass,
|
||||
joinPath,
|
||||
|
|
@ -44,7 +45,7 @@ import type {
|
|||
|
||||
/**
|
||||
* Java HTTP plugin. Handles:
|
||||
* - Spring `@RequestMapping` class prefixes + `@(Get|Post|...)Mapping` method annotations
|
||||
* - Spring `@RequestMapping` class prefixes + shortcut/`@RequestMapping` method annotations
|
||||
* - Spring `RestTemplate.getForObject/...`, `exchange(...)`
|
||||
* - Spring `WebClient.method(HttpMethod.X, ...)`, `WebClient.get().uri(...)`
|
||||
* - OkHttp `new Request.Builder().url("...")`
|
||||
|
|
@ -408,6 +409,35 @@ function simpleName(text: string): string {
|
|||
return text.split('.').pop() ?? text;
|
||||
}
|
||||
|
||||
function declarationAnnotations(node: Parser.SyntaxNode): Parser.SyntaxNode[] {
|
||||
const modifiers = node.namedChildren.find((child) => child.type === 'modifiers');
|
||||
if (!modifiers) return [];
|
||||
return modifiers.namedChildren.filter(
|
||||
(child) => child.type === 'annotation' || child.type === 'marker_annotation',
|
||||
);
|
||||
}
|
||||
|
||||
function annotationHasRouteMember(annotation: Parser.SyntaxNode): boolean {
|
||||
const args = annotation.childForFieldName('arguments');
|
||||
if (!args) return false;
|
||||
for (const child of args.namedChildren) {
|
||||
if (child.type !== 'element_value_pair') return true;
|
||||
const key = child.childForFieldName('key');
|
||||
if (isRouteMemberKey(key ?? undefined)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function typeRequestMethods(typeNode: Parser.SyntaxNode): readonly string[] {
|
||||
const mappings = declarationAnnotations(typeNode).filter(
|
||||
(annotation) =>
|
||||
simpleName(annotation.childForFieldName('name')?.text ?? '') === 'RequestMapping',
|
||||
);
|
||||
if (mappings.length === 0) return ['*'];
|
||||
if (mappings.length !== 1) return [];
|
||||
return springAnnotationHttpMethods('RequestMapping', mappings[0].text);
|
||||
}
|
||||
|
||||
function hasAnnotation(node: Parser.SyntaxNode, names: string | readonly string[]): boolean {
|
||||
const modifiers = node.namedChildren.find((child) => child.type === 'modifiers');
|
||||
if (!modifiers) return false;
|
||||
|
|
@ -437,6 +467,8 @@ interface MethodRouteAnnotation {
|
|||
methodName: string | null;
|
||||
httpMethod: string;
|
||||
rawPath: string;
|
||||
/** OpenFeign's single effective verb; null means its contract is invalid/ambiguous. */
|
||||
feignHttpMethod?: string | null;
|
||||
}
|
||||
|
||||
interface RequestLineAnnotation {
|
||||
|
|
@ -452,7 +484,7 @@ interface RouteAnnotationScan {
|
|||
feignPrefixByInterfaceId: Map<number, string[]>;
|
||||
/** Spring HTTP Interface `@HttpExchange(url|value)` type-level prefixes per class/interface node id. */
|
||||
httpExchangePrefixByTypeId: Map<number, string[]>;
|
||||
/** One entry per resolved Spring `@(Get|...)Mapping` route — a method with N mappings yields N entries. */
|
||||
/** Resolved Spring shortcut/`@RequestMapping` routes — paths × verbs yield one entry each. */
|
||||
methodRoutes: MethodRouteAnnotation[];
|
||||
/** One entry per OpenFeign `@RequestLine` whose value parses to a verb + path. */
|
||||
requestLines: RequestLineAnnotation[];
|
||||
|
|
@ -484,6 +516,7 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
|
|||
const methodRoutes: MethodRouteAnnotation[] = [];
|
||||
const requestLines: RequestLineAnnotation[] = [];
|
||||
const exchangeRoutes: MethodRouteAnnotation[] = [];
|
||||
const httpMethodsByAnnotationId = new Map<number, readonly string[]>();
|
||||
// Interface `@RequestMapping` prefixes rank below `@FeignClient(path)`;
|
||||
// collect them and apply only after the FeignClient pass below.
|
||||
const interfaceRequestMappingPrefixes: Array<{ id: number; prefix: string }> = [];
|
||||
|
|
@ -505,18 +538,29 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
|
|||
const keyNode = captures.key; // undefined for the positional shape
|
||||
|
||||
if (node.type === 'method_declaration') {
|
||||
// Method-level: a Spring `@(Get|...)Mapping` route, or native `@RequestLine`.
|
||||
const httpMethod = METHOD_ANNOTATION_TO_HTTP[ann];
|
||||
if (httpMethod) {
|
||||
// Method-level: a Spring shortcut/`@RequestMapping` route, or native `@RequestLine`.
|
||||
const annotationNode = annNode.parent;
|
||||
if (!annotationNode) continue;
|
||||
let httpMethods = httpMethodsByAnnotationId.get(annotationNode.id);
|
||||
if (!httpMethods) {
|
||||
httpMethods = springAnnotationHttpMethods(ann, annotationNode.text);
|
||||
httpMethodsByAnnotationId.set(annotationNode.id, httpMethods);
|
||||
}
|
||||
if (httpMethods.length > 0) {
|
||||
const feignHttpMethod =
|
||||
httpMethods.length === 1 ? (httpMethods[0] === '*' ? 'GET' : httpMethods[0]) : null;
|
||||
if (!isRouteMemberKey(keyNode)) continue;
|
||||
const rawPath = unquoteLiteral(valueNode.text);
|
||||
if (rawPath !== null) {
|
||||
methodRoutes.push({
|
||||
methodNode: node,
|
||||
methodName: captures.member?.text ?? null,
|
||||
httpMethod,
|
||||
rawPath,
|
||||
});
|
||||
for (const httpMethod of httpMethods) {
|
||||
methodRoutes.push({
|
||||
methodNode: node,
|
||||
methodName: captures.member?.text ?? null,
|
||||
httpMethod,
|
||||
rawPath,
|
||||
feignHttpMethod,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (ann === 'RequestLine') {
|
||||
// Feign packs verb + path in one literal; its only named argument is `value`.
|
||||
|
|
@ -573,6 +617,43 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
|
|||
}
|
||||
}
|
||||
|
||||
const classHttpMethodsByTypeId = new Map<number, readonly string[]>();
|
||||
for (const match of runCompiledPatterns(SPRING_TYPE_DECLARATION_PATTERNS, tree)) {
|
||||
const typeNode = match.captures.type;
|
||||
if (!typeNode) continue;
|
||||
const classMethods = typeRequestMethods(typeNode);
|
||||
classHttpMethodsByTypeId.set(typeNode.id, classMethods);
|
||||
for (const methodNode of collectDirectMethods(typeNode)) {
|
||||
for (const annotationNode of declarationAnnotations(methodNode)) {
|
||||
if (annotationHasRouteMember(annotationNode)) continue;
|
||||
const ann = simpleName(annotationNode.childForFieldName('name')?.text ?? '');
|
||||
const httpMethods = springAnnotationHttpMethods(ann, annotationNode.text);
|
||||
if (httpMethods.length === 0) continue;
|
||||
const feignHttpMethod =
|
||||
httpMethods.length === 1 ? (httpMethods[0] === '*' ? 'GET' : httpMethods[0]) : null;
|
||||
for (const httpMethod of httpMethods) {
|
||||
methodRoutes.push({
|
||||
methodNode,
|
||||
methodName: getNodeName(methodNode),
|
||||
httpMethod,
|
||||
rawPath: '',
|
||||
feignHttpMethod,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const constrainedMethodRoutes = methodRoutes.flatMap((route) => {
|
||||
const typeNode =
|
||||
findEnclosingInterface(route.methodNode) ?? findEnclosingClass(route.methodNode);
|
||||
const classMethods = typeNode ? (classHttpMethodsByTypeId.get(typeNode.id) ?? ['*']) : ['*'];
|
||||
return intersectSpringHttpMethods(classMethods, [route.httpMethod]).map((httpMethod) => ({
|
||||
...route,
|
||||
httpMethod,
|
||||
}));
|
||||
});
|
||||
|
||||
// `@RequestMapping` on a Feign interface is the fallback prefix, but only when
|
||||
// the interface has no `@FeignClient(path)` of its own (path wins).
|
||||
for (const { id, prefix } of interfaceRequestMappingPrefixes) {
|
||||
|
|
@ -583,7 +664,7 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
|
|||
prefixByTypeId,
|
||||
feignPrefixByInterfaceId,
|
||||
httpExchangePrefixByTypeId,
|
||||
methodRoutes,
|
||||
methodRoutes: constrainedMethodRoutes,
|
||||
requestLines,
|
||||
exchangeRoutes,
|
||||
};
|
||||
|
|
@ -705,7 +786,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
|||
|
||||
// ─── Spring providers + OpenFeign consumers (one query pass) ────
|
||||
// `scanRouteAnnotations` resolves every route-defining annotation —
|
||||
// class/interface prefixes, method `@(Get|...)Mapping`s and native
|
||||
// class/interface prefixes, method shortcut/`@RequestMapping`s and native
|
||||
// `@RequestLine`s — from a single `matches()` pass over the tree.
|
||||
const {
|
||||
prefixByTypeId,
|
||||
|
|
@ -724,12 +805,13 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = {
|
|||
for (const route of methodRoutes) {
|
||||
const enclosingInterface = findEnclosingInterface(route.methodNode);
|
||||
if (enclosingInterface && hasAnnotation(enclosingInterface, 'FeignClient')) {
|
||||
if (!route.feignHttpMethod) continue;
|
||||
const prefixes = feignPrefixByInterfaceId.get(enclosingInterface.id) ?? [''];
|
||||
for (const prefix of prefixes) {
|
||||
out.push({
|
||||
role: 'consumer',
|
||||
framework: OPENFEIGN_FRAMEWORK,
|
||||
method: route.httpMethod,
|
||||
method: route.feignHttpMethod,
|
||||
path: joinPath(prefix, route.rawPath),
|
||||
name: route.methodName,
|
||||
line: route.methodNode.startPosition.row + 1,
|
||||
|
|
|
|||
|
|
@ -5,16 +5,94 @@ export const INDEX_INCOMPLETE_REASONS = [
|
|||
'incremental-in-progress',
|
||||
'embedding-checkpoint-pending',
|
||||
'embedding-count-unverified',
|
||||
'graph-write-collapsed',
|
||||
] as const;
|
||||
|
||||
export type IndexIncompleteReason = (typeof INDEX_INCOMPLETE_REASONS)[number];
|
||||
|
||||
/**
|
||||
* Fraction of the pipeline's relationship count that must survive into the DB
|
||||
* before the write counts as collapsed. Deliberately generous: this detects
|
||||
* "most of the graph did not persist" (the reported case lost ~91%), not a
|
||||
* per-edge reconciliation.
|
||||
*/
|
||||
export const GRAPH_WRITE_COLLAPSE_RATIO = 0.5;
|
||||
|
||||
/**
|
||||
* Below this many relationships the ratio is meaningless — a handful of edges
|
||||
* lost to legitimate filtering would trip it — so small repos are exempt.
|
||||
*/
|
||||
export const GRAPH_WRITE_COLLAPSE_MIN_EDGES = 100;
|
||||
|
||||
/**
|
||||
* Decide whether a finished write collapsed, comparing what the pipeline
|
||||
* produced against what the DB hands back.
|
||||
*
|
||||
* A RATIO, not equality: some relationship types do not round-trip one-for-one
|
||||
* and `--pdg` writes MORE rows into the same table, so demanding equality would
|
||||
* fire on healthy runs. Only a collapse is a defect.
|
||||
*
|
||||
* FAIL-SAFE at `expected === 0`: an implementation that offloads relationships
|
||||
* out of memory may not be able to report a total, and a false "your index is
|
||||
* broken" is worse than a missed one.
|
||||
*/
|
||||
export function detectGraphWriteCollapse(
|
||||
expected: number,
|
||||
/**
|
||||
* Relationships readable from the DB, or `undefined` when the count could
|
||||
* not be READ at all (no connection, a query that threw).
|
||||
*
|
||||
* The distinction is load-bearing and was got wrong once: `getLbugStats`
|
||||
* reports `edges: 0` for "no connection", "query threw" AND "empty table"
|
||||
* alike, so passing it straight in made every run without a readable DB look
|
||||
* like a total collapse. An unmeasurable count is not a measured zero —
|
||||
* accepting `undefined` here is what keeps this check from committing the
|
||||
* same confident-zero error it exists to catch.
|
||||
*/
|
||||
persisted: number | undefined,
|
||||
): { expected: number; persisted: number } | undefined {
|
||||
// Both sides must be REAL NUMBERS before any comparison. A non-numeric
|
||||
// `expected` (a graph implementation that reports no total, a lightweight
|
||||
// pipeline result) does not merely skip the guards — it INVERTS them:
|
||||
// `undefined < 100` is false, so the min-edges exemption never fires, and
|
||||
// `0 >= undefined * 0.5` is `0 >= NaN`, also false, so the ratio check
|
||||
// "passes" too and a healthy run is reported as a total collapse. Comparing
|
||||
// against a non-number is the one way this check can manufacture the exact
|
||||
// false certainty it was written to prevent.
|
||||
if (!Number.isFinite(expected) || typeof persisted !== 'number' || !Number.isFinite(persisted)) {
|
||||
return undefined;
|
||||
}
|
||||
const expectedCount = expected;
|
||||
const persistedCount = persisted;
|
||||
// A TOTAL loss is never small enough to excuse. The min-edges exemption
|
||||
// exists for "a handful of edges lost to legitimate filtering", which its own
|
||||
// docstring says — it does not describe a persisted count of zero. Evaluated
|
||||
// before the exemption because the exemption looked only at `expected`:
|
||||
// `expected = 99, persisted = 0` lost every single edge and still returned
|
||||
// `undefined`, leaving the metadata fresh and the CLI reporting success.
|
||||
if (expectedCount > 0 && persistedCount === 0) {
|
||||
return { expected: expectedCount, persisted: persistedCount };
|
||||
}
|
||||
if (expectedCount < GRAPH_WRITE_COLLAPSE_MIN_EDGES) return undefined;
|
||||
if (persistedCount >= expectedCount * GRAPH_WRITE_COLLAPSE_RATIO) return undefined;
|
||||
return { expected: expectedCount, persisted: persistedCount };
|
||||
}
|
||||
|
||||
/** Stable machine-readable reasons an index cannot be certified complete. */
|
||||
export function getIndexIncompleteReasons(
|
||||
meta: Pick<RepoMeta, 'incrementalInProgress' | 'embeddingCheckpoint'> | null | undefined,
|
||||
meta:
|
||||
| Pick<RepoMeta, 'incrementalInProgress' | 'embeddingCheckpoint' | 'graphWriteCollapsed'>
|
||||
| null
|
||||
| undefined,
|
||||
): IndexIncompleteReason[] {
|
||||
const reasons: IndexIncompleteReason[] = [];
|
||||
if (meta?.incrementalInProgress) reasons.push('incremental-in-progress');
|
||||
// The run finished and wrote metadata, but far fewer edges reached the DB
|
||||
// than the pipeline produced — the "refresh reported success, the index is
|
||||
// unusable" failure. Without this the index reads as fresh and every tool
|
||||
// answers from a graph missing most of its edges, which is indistinguishable
|
||||
// from a codebase that genuinely has no such relationships.
|
||||
if (meta?.graphWriteCollapsed) reasons.push('graph-write-collapsed');
|
||||
if (meta?.embeddingCheckpoint) {
|
||||
// The three checkpoint kinds are not one operator-facing state. GUARDRAILS
|
||||
// and the runbook document `embedding-checkpoint-pending` as "N node(s)
|
||||
|
|
|
|||
|
|
@ -5,15 +5,20 @@ import type { ClassExtractionConfig } from '../../class-types.js';
|
|||
|
||||
export const goClassConfig: ClassExtractionConfig = {
|
||||
language: SupportedLanguages.Go,
|
||||
typeDeclarationNodes: ['type_declaration'],
|
||||
// `type_spec`, not the enclosing `type_declaration` (#2837): one node per
|
||||
// DECLARED TYPE, so a grouped `type ( A struct{…}; B struct{…} )` yields one
|
||||
// each instead of one for the whole block. Every Go capture that reaches this
|
||||
// extractor is anchored the same way, so the wrapper is never handed over —
|
||||
// and accepting it would be worse than rejecting it, because picking one spec
|
||||
// out of several with no reference point silently returns the FIRST type's
|
||||
// name. A `null` here is loud; the wrong name is not.
|
||||
typeDeclarationNodes: ['type_spec'],
|
||||
fileScopeNodeTypes: ['package_clause'],
|
||||
extractName(node) {
|
||||
const typeSpec = node.namedChildren.find((child) => child.type === 'type_spec');
|
||||
return typeSpec?.childForFieldName('name')?.text;
|
||||
return node.childForFieldName('name')?.text;
|
||||
},
|
||||
extractType(node) {
|
||||
const typeSpec = node.namedChildren.find((child) => child.type === 'type_spec');
|
||||
const typeNode = typeSpec?.childForFieldName('type');
|
||||
const typeNode = node.childForFieldName('type');
|
||||
if (typeNode?.type === 'struct_type') return 'Struct';
|
||||
if (typeNode?.type === 'interface_type') return 'Interface';
|
||||
return undefined;
|
||||
|
|
|
|||
|
|
@ -282,14 +282,22 @@ interface LanguageProviderConfig {
|
|||
) => ExtractedRoute[];
|
||||
|
||||
/**
|
||||
* Extract decorator-style route annotations from a parsed file.
|
||||
* Extract routes that a parsed file declares in its own AST.
|
||||
*
|
||||
* When defined, the parse worker calls this after per-file capture processing
|
||||
* to extract framework route definitions that require AST-level analysis beyond
|
||||
* to extract route definitions that require AST-level analysis beyond
|
||||
* generic `@decorator` captures (e.g., Java Spring class-level prefix joining,
|
||||
* multi-class handling). The returned routes are appended to `decoratorRoutes`.
|
||||
*
|
||||
* Default: undefined (no language-specific decorator route extraction).
|
||||
* Decorators are the common case and the reason for the name, but not the only
|
||||
* shape: JS/TS uses this hook for hand-rolled dispatch guards
|
||||
* (`route-extractors/dispatch-guard.ts`), where a raw `node:http` server
|
||||
* declares a route by comparing the request path to a literal. Anything that
|
||||
* yields a `(path, verb, handler)` triple from one file's AST belongs here —
|
||||
* set `ExtractedDecoratorRoute.source` when the provenance is not a decorator,
|
||||
* so the `HANDLES_ROUTE` edge does not claim one.
|
||||
*
|
||||
* Default: undefined (no language-specific route extraction).
|
||||
*/
|
||||
readonly extractDecoratorRoutes?: (
|
||||
tree: Parser.Tree,
|
||||
|
|
|
|||
|
|
@ -77,10 +77,71 @@ export function interpretCppTypeBinding(captures: CaptureMatch): ParsedTypeBindi
|
|||
source = 'annotation';
|
||||
}
|
||||
|
||||
const declaredSpelling = cppPointerSpelling(captures, type, name);
|
||||
// A member field's type is captured AS WRITTEN, qualifier and all
|
||||
// (`ns::Repo<User>`), because the query matches the outer
|
||||
// `qualified_identifier` — one depth-agnostic pattern per declarator shape
|
||||
// instead of one per qualifier depth. The qualifier is dropped HERE; see the
|
||||
// "Field type, QUALIFIED" block in query.ts for why the qualified spelling
|
||||
// resolves to nothing and the tail resolves like the bare one.
|
||||
//
|
||||
// FIELDS ONLY. `@type-binding.parameter` and `@type-binding.assignment` also
|
||||
// capture qualified spellings (their patterns use `type: (_)`), and reducing
|
||||
// THOSE would newly bind every qualified local and parameter in the workspace
|
||||
// — a far wider change than the member-field miss this closes, and not one
|
||||
// anything here has measured.
|
||||
const effectiveType =
|
||||
captures['@type-binding.field'] === undefined ? type : cppQualifiedTail(type);
|
||||
// The reduced spelling is also the AS-WRITTEN one, and saying so is load
|
||||
// bearing. `collectTypeBindings` derives `TypeRef.declaredSpelling` from
|
||||
// `@type-binding.type` whenever that text differs from `rawTypeName`, and it
|
||||
// now does for every qualified member. `declaredSpelling` exists to keep a
|
||||
// CONTAINER distinguishable from a class of the same name after capture
|
||||
// reduced it; a qualifier is not a container — `ns::Address` and `Address`
|
||||
// have the identical member set — so recording one here would answer
|
||||
// "container, as written" for a plain member and hand `elementTypeOf` a
|
||||
// spelling it never sees for the bare form.
|
||||
const declaredSpelling =
|
||||
cppPointerSpelling(captures, effectiveType, name) ??
|
||||
(effectiveType === type ? undefined : effectiveType);
|
||||
return declaredSpelling === undefined
|
||||
? { boundName: name, rawTypeName: normalizeCppTypeName(type), source }
|
||||
: { boundName: name, rawTypeName: normalizeCppTypeName(type), declaredSpelling, source };
|
||||
? { boundName: name, rawTypeName: normalizeCppTypeName(effectiveType), source }
|
||||
: {
|
||||
boundName: name,
|
||||
rawTypeName: normalizeCppTypeName(effectiveType),
|
||||
declaredSpelling,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The tail of a `::`-qualified type spelling — `a::b::Repo<User>` → `Repo<User>`,
|
||||
* `ns::Address` → `Address`, an unqualified spelling unchanged.
|
||||
*
|
||||
* Only TOP-LEVEL separators count, so a qualified TYPE ARGUMENT survives:
|
||||
* `std::vector<std::string>` reduces to `vector<std::string>`, not to `string`.
|
||||
* That is the same string the old per-depth rules produced by capturing the
|
||||
* inner node, so the reduction is textual where it used to be structural and
|
||||
* the result is identical for every depth they covered.
|
||||
*/
|
||||
function cppQualifiedTail(text: string): string {
|
||||
let angleDepth = 0;
|
||||
let lastSeparator = -1;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (ch === '<') angleDepth++;
|
||||
else if (ch === '>') {
|
||||
if (angleDepth > 0) angleDepth--;
|
||||
} else if (angleDepth === 0 && ch === ':' && text[i + 1] === ':') {
|
||||
lastSeparator = i;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
if (lastSeparator === -1) return text;
|
||||
const tail = text.slice(lastSeparator + 2).trim();
|
||||
// A spelling that ends in `::` has no tail to reduce to. Cannot arise from a
|
||||
// parsed `qualified_identifier`, but returning an empty type name would make
|
||||
// the binding claim a type of `""`, so the written spelling is kept instead.
|
||||
return tail.length === 0 ? text : tail;
|
||||
}
|
||||
|
||||
/** Anchors whose capture spans a whole declaration, so the declarator — and
|
||||
|
|
|
|||
|
|
@ -55,12 +55,26 @@ const CPP_SCOPE_QUERY = `
|
|||
declarator: (type_identifier) @declaration.name) @declaration.struct
|
||||
|
||||
;; ─── Declarations — class / struct inside template_declaration ───────
|
||||
;; \`parameters:\` is the DECLARED parameter list (\`template <class T>\`), which
|
||||
;; lives on the template_declaration and not on the specifier — the opposite
|
||||
;; nesting from \`@declaration.template-arguments\` above, which is part of the
|
||||
;; specifier's own NAME. A partial specialization carries both, and that pairing
|
||||
;; is the only thing separating it from a full specialization written against
|
||||
;; the identical arguments.
|
||||
;;
|
||||
;; These four patterns are TWINS of the four standalone specifier patterns
|
||||
;; above: a templated struct matches both, minting two defs with one id, and
|
||||
;; only this half can see the parameter list. The duplicate-declaration backfill
|
||||
;; in scope-extractor.ts is what stops match order from deciding which twin
|
||||
;; keeps the parameters.
|
||||
(template_declaration
|
||||
parameters: (template_parameter_list) @declaration.type-parameters
|
||||
(class_specifier
|
||||
name: (type_identifier) @declaration.name
|
||||
body: (field_declaration_list)) @declaration.class)
|
||||
|
||||
(template_declaration
|
||||
parameters: (template_parameter_list) @declaration.type-parameters
|
||||
(class_specifier
|
||||
name: (template_type
|
||||
(type_identifier) @declaration.name
|
||||
|
|
@ -68,11 +82,13 @@ const CPP_SCOPE_QUERY = `
|
|||
body: (field_declaration_list)) @declaration.class)
|
||||
|
||||
(template_declaration
|
||||
parameters: (template_parameter_list) @declaration.type-parameters
|
||||
(struct_specifier
|
||||
name: (type_identifier) @declaration.name
|
||||
body: (field_declaration_list)) @declaration.struct)
|
||||
|
||||
(template_declaration
|
||||
parameters: (template_parameter_list) @declaration.type-parameters
|
||||
(struct_specifier
|
||||
name: (template_type
|
||||
(type_identifier) @declaration.name
|
||||
|
|
@ -537,6 +553,86 @@ const CPP_SCOPE_QUERY = `
|
|||
declarator: (reference_declarator
|
||||
(field_identifier) @type-binding.name)) @type-binding.field
|
||||
|
||||
;; Generic field type: Repo<User> repo; (#2833)
|
||||
;; The three rules above all require type: (type_identifier), so a member whose
|
||||
;; type carries template arguments is a template_type and matched NONE of them —
|
||||
;; the field got no type binding at all, and every call through it lost its edge
|
||||
;; in BOTH spellings (repo.save() and this->repo.save()), while the same type in
|
||||
;; a LOCAL resolved fine because the local declaration rules gained their
|
||||
;; template_type variant long ago (see "Covers: List<User> users;" above).
|
||||
;; These three mirror the three above, one per declarator shape. Written as
|
||||
;; separate patterns rather than one alternation: a node-type alternation in a
|
||||
;; field position is the tree-sitter 0.21 hazard this repo has been bitten by
|
||||
;; before.
|
||||
(field_declaration
|
||||
type: (template_type) @type-binding.type
|
||||
declarator: (field_identifier) @type-binding.name) @type-binding.field
|
||||
|
||||
;; Generic field, pointer: Repo<User>* repo;
|
||||
(field_declaration
|
||||
type: (template_type) @type-binding.type
|
||||
declarator: (pointer_declarator
|
||||
declarator: (field_identifier) @type-binding.name)) @type-binding.field
|
||||
|
||||
;; Generic field, reference: Repo<User>& repo;
|
||||
(field_declaration
|
||||
type: (template_type) @type-binding.type
|
||||
declarator: (reference_declarator
|
||||
(field_identifier) @type-binding.name)) @type-binding.field
|
||||
|
||||
;; ─── Field type, QUALIFIED: ns::Address addr; std::vector<Item> items; ─
|
||||
;; The six rules above require the type node to BE a type_identifier or a
|
||||
;; template_type, and a qualified member type is NEITHER: tree-sitter-cpp parses
|
||||
;; ns::Address as a qualified_identifier WRAPPING the type_identifier, and
|
||||
;; std::vector<Item> as one wrapping the template_type. So every qualified
|
||||
;; member — generic or not — matched none of the six and bound nothing, which
|
||||
;; covers the commonest member spellings in real C++ (std::string, std::mutex,
|
||||
;; std::vector<T>, ns::Config).
|
||||
;;
|
||||
;; ONE PATTERN PER DECLARATOR SHAPE, MATCHING THE OUTER qualified_identifier,
|
||||
;; and that is the whole design. A tree-sitter query cannot match a node at
|
||||
;; arbitrary nesting depth, and a::b::c::Repo<User> nests one
|
||||
;; qualified_identifier per qualifier — so enumerating the inner node instead
|
||||
;; costs 3 patterns per depth per genericity and STILL ends at whatever depth
|
||||
;; the last author enumerated (that boundary was real: depth 3 was uncaptured).
|
||||
;; Matching the outer node is depth-agnostic and genericity-agnostic, and it is
|
||||
;; a single node type in the field position, not an alternation — the
|
||||
;; tree-sitter 0.21 hazard this repo has been bitten by before.
|
||||
;;
|
||||
;; The QUALIFIER IS THEN DROPPED, by cppQualifiedTail in interpret.ts, not
|
||||
;; here — and dropping it was measured rather than assumed. Recording
|
||||
;; ns::Repo<User> resolves to NOTHING: findClassBindingInScope's dotted-tail
|
||||
;; fallback splits on "." and C++ writes "::", and resolveClassBindingForName's
|
||||
;; generic branch then looks up the base ns::Repo, which is not a key either
|
||||
;; because C++ emits no @declaration.qualified_name and indexes ns::Repo under
|
||||
;; Repo. Reducing to the tail lands on exactly the path the BARE spelling
|
||||
;; already takes — one class-like match or decline — so a qualified member field
|
||||
;; behaves like the bare one instead of like nothing. A tail that names no
|
||||
;; workspace class (std::string with no "class string" in the repo) binds
|
||||
;; nothing and emits nothing, which is why this is a miss-closing change rather
|
||||
;; than an edge-fabricating one.
|
||||
;;
|
||||
;; Like the six above, each requires the declarator to reach the field_identifier
|
||||
;; DIRECTLY, so a method whose return type is qualified (ns::Thing method();)
|
||||
;; still captures no field — a function_declarator sits in between and none of
|
||||
;; these match it. Same for a function-pointer member, a using/typedef alias, a
|
||||
;; friend declaration and an operator declaration.
|
||||
(field_declaration
|
||||
type: (qualified_identifier) @type-binding.type
|
||||
declarator: (field_identifier) @type-binding.name) @type-binding.field
|
||||
|
||||
;; Qualified field, pointer: ns::Address* addr; std::unique_ptr<Repo>* repo;
|
||||
(field_declaration
|
||||
type: (qualified_identifier) @type-binding.type
|
||||
declarator: (pointer_declarator
|
||||
declarator: (field_identifier) @type-binding.name)) @type-binding.field
|
||||
|
||||
;; Qualified field, reference: ns::Address& addr; std::vector<Item>& items;
|
||||
(field_declaration
|
||||
type: (qualified_identifier) @type-binding.type
|
||||
declarator: (reference_declarator
|
||||
(field_identifier) @type-binding.name)) @type-binding.field
|
||||
|
||||
;; ─── References — constructor calls (new Foo()) ─────────────────────
|
||||
(new_expression
|
||||
type: (type_identifier) @reference.name) @reference.call.constructor
|
||||
|
|
|
|||
|
|
@ -63,17 +63,31 @@ const CSHARP_SCOPE_QUERY = `
|
|||
;; Anonymous methods / lambdas are not scoped — out of scope per plan.
|
||||
|
||||
;; Declarations — types
|
||||
;; The parameter list is matched as an UNNAMED optional child, not through a
|
||||
;; \`type_parameters:\` field: the C# grammar gives \`interface_declaration\` that
|
||||
;; field but \`class_declaration\` / \`struct_declaration\` / \`record_declaration\`
|
||||
;; only a bare \`type_parameter_list\` child, so the field form would silently
|
||||
;; capture nothing on exactly the three most common declarations. The unnamed
|
||||
;; form matches all four.
|
||||
;;
|
||||
;; A \`where T : IRepo\` constraint is a SEPARATE sibling clause
|
||||
;; (\`type_parameter_constraints_clause\`) and is deliberately not read here — the
|
||||
;; bound stays absent for C#, which reads as "unknown", the safe direction.
|
||||
(class_declaration
|
||||
name: (identifier) @declaration.name) @declaration.class
|
||||
name: (identifier) @declaration.name
|
||||
(type_parameter_list)? @declaration.type-parameters) @declaration.class
|
||||
|
||||
(interface_declaration
|
||||
name: (identifier) @declaration.name) @declaration.interface
|
||||
name: (identifier) @declaration.name
|
||||
(type_parameter_list)? @declaration.type-parameters) @declaration.interface
|
||||
|
||||
(struct_declaration
|
||||
name: (identifier) @declaration.name) @declaration.struct
|
||||
name: (identifier) @declaration.name
|
||||
(type_parameter_list)? @declaration.type-parameters) @declaration.struct
|
||||
|
||||
(record_declaration
|
||||
name: (identifier) @declaration.name) @declaration.record
|
||||
name: (identifier) @declaration.name
|
||||
(type_parameter_list)? @declaration.type-parameters) @declaration.record
|
||||
|
||||
(enum_declaration
|
||||
name: (identifier) @declaration.name) @declaration.enum
|
||||
|
|
|
|||
|
|
@ -144,6 +144,15 @@ export const goProvider = defineLanguage({
|
|||
descriptionExtractor: createLeadingDocDescriptionExtractor({
|
||||
lineCommentPrefixes: ['//'],
|
||||
lineDirectivePrefixes: ['//go:', '// +build', '//nolint', '//line'],
|
||||
// #2837: Go type declarations anchor on the `type_spec`, whose
|
||||
// `previousNamedSibling` is null for the ordinary `type T struct{…}` form —
|
||||
// the godoc comment is a sibling of the enclosing `type_declaration`, one
|
||||
// level up. Without this the doc silently vanished for EVERY Go struct and
|
||||
// interface (measured: pre-#2837 `desc=YES`, post `none`), taking #2270's
|
||||
// description out of both the embedding header and the FTS column.
|
||||
// Functions/methods are unaffected — they anchor on their own declaration
|
||||
// node, which does carry the comment as a previous sibling.
|
||||
wrapperNodeTypes: ['type_declaration'],
|
||||
}),
|
||||
builtInNames: GO_BUILT_INS,
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,157 @@
|
|||
import type { ParsedFile, Range, SymbolDefinition } from 'gitnexus-shared';
|
||||
|
||||
/**
|
||||
* A generic Go interface's type-parameter names, in DECLARATION ORDER, stamped
|
||||
* onto its `Interface` def as a Go-private sidecar.
|
||||
*
|
||||
* Same mechanism and lifecycle as `goReceiverKind` (method-owners.ts): an extra
|
||||
* property on a def the Go resolver owns, written on the main thread and read by
|
||||
* `interface-impls.ts`. It is deliberately NOT a shared `SymbolDefinition` field
|
||||
* and deliberately NOT a capture — see {@link stampGoInterfaceTypeParameters}.
|
||||
*
|
||||
* ORDER IS THE POINT. Substitution is positional (`Repo[User]` binds the FIRST
|
||||
* type parameter), so a set or a name→constraint map would lose exactly the
|
||||
* information this exists to carry.
|
||||
*/
|
||||
type GoGenericInterfaceDefinition = SymbolDefinition & {
|
||||
readonly goTypeParameters?: readonly string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Stamp every generic interface in `parsedFiles` with its type-parameter names,
|
||||
* read out of the declaration's own source text.
|
||||
*
|
||||
* WHY SOURCE TEXT AND NOT A CAPTURE. The tree has the list right there
|
||||
* (`type_spec` carries a `type_parameters` field), and capturing it would be two
|
||||
* lines. But captures run inside the PARSE WORKER, whose script is resolved from
|
||||
* the compiled `dist/` build, and their output is additionally memoized by the
|
||||
* parse cache and the durable ParsedFile store — so a capture-side change is
|
||||
* invisible until a rebuild AND a cache-version bump, and silently wrong in
|
||||
* between. Everything here runs on the main thread from data the pipeline
|
||||
* already materialized, so it is correct on the first run and needs neither.
|
||||
*
|
||||
* The scan is exact rather than a grep over the file: an interface declaration
|
||||
* owns a `Class` scope whose range spans exactly its `type_spec`
|
||||
* (`Repo[T any] interface{ … }`), so the text is sliced by that range and the
|
||||
* type parameters are, by grammar, whatever sits between the brackets that
|
||||
* IMMEDIATELY follow the name. Comments and strings elsewhere in the file cannot
|
||||
* reach it.
|
||||
*/
|
||||
export function stampGoInterfaceTypeParameters(
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
fileContents: ReadonlyMap<string, string>,
|
||||
): void {
|
||||
for (const parsed of parsedFiles) {
|
||||
// Deferred so a file with no interface declaration never indexes its lines.
|
||||
let lines: { readonly source: string; readonly starts: readonly number[] } | undefined;
|
||||
for (const scope of parsed.scopes) {
|
||||
if (scope.kind !== 'Class') continue;
|
||||
const iface = scope.ownedDefs.find((def) => def.type === 'Interface');
|
||||
if (iface?.qualifiedName === undefined) continue;
|
||||
if (lines === undefined) {
|
||||
const source = fileContents.get(parsed.filePath);
|
||||
if (source === undefined) break;
|
||||
lines = { source, starts: buildLineStarts(source) };
|
||||
}
|
||||
const declaration = sliceRange(lines.source, lines.starts, scope.range);
|
||||
if (declaration === undefined) continue;
|
||||
const names = goTypeParameterNames(declaration, simpleGoName(iface.qualifiedName));
|
||||
if (names === undefined) continue;
|
||||
(iface as { goTypeParameters?: readonly string[] }).goTypeParameters = names;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Read back a stamp, rejecting anything whose shape does not match — the
|
||||
* sidecar is optional and a hand-built fixture def carries none. */
|
||||
export function readGoTypeParameters(def: SymbolDefinition): readonly string[] | undefined {
|
||||
const names = (def as GoGenericInterfaceDefinition).goTypeParameters;
|
||||
if (!Array.isArray(names) || names.length === 0) return undefined;
|
||||
return names.every((name): name is string => typeof name === 'string') ? names : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The declared type-parameter names of `Name[…] interface{…}`, in source order,
|
||||
* or `undefined` when the declaration is not generic.
|
||||
*
|
||||
* Go spec, Type parameter declarations: the list is comma-separated and one
|
||||
* entry may declare SEVERAL names sharing one constraint — `[K, V any]` declares
|
||||
* `K` and `V`, and `[S ~[]E, E any]` declares `S` and `E`. Each entry therefore
|
||||
* contributes exactly its FIRST token as a name; anything after it is the
|
||||
* constraint, which is not needed here (satisfaction of a constraint is a
|
||||
* separate question from implementation of an interface, and constraints are
|
||||
* never harvested as instantiations — see `interface-impls.ts`).
|
||||
*/
|
||||
function goTypeParameterNames(declaration: string, interfaceName: string): string[] | undefined {
|
||||
if (!declaration.startsWith(interfaceName)) return undefined;
|
||||
if (declaration[interfaceName.length] !== '[') return undefined;
|
||||
const close = matchingGoDelimiter(declaration, interfaceName.length);
|
||||
if (close === -1) return undefined;
|
||||
const names: string[] = [];
|
||||
for (const entry of splitTopLevelGoList(declaration.slice(interfaceName.length + 1, close))) {
|
||||
const name = /^[A-Za-z_][A-Za-z0-9_]*/.exec(entry)?.[0];
|
||||
if (name === undefined) return undefined;
|
||||
names.push(name);
|
||||
}
|
||||
return names.length === 0 ? undefined : names;
|
||||
}
|
||||
|
||||
/** Index of the delimiter closing the one at `open`, or -1 when unbalanced.
|
||||
* Tracks `[]`, `{}` and `()` together so an `interface{ M(a, b int) }`
|
||||
* constraint cannot end the list early. */
|
||||
export function matchingGoDelimiter(text: string, open: number): number {
|
||||
let depth = 0;
|
||||
for (let i = open; i < text.length; i += 1) {
|
||||
const ch = text[i];
|
||||
if (ch === '[' || ch === '{' || ch === '(') depth += 1;
|
||||
else if (ch === ']' || ch === '}' || ch === ')') {
|
||||
depth -= 1;
|
||||
if (depth === 0) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Split on commas that are not nested inside brackets, braces or parens. */
|
||||
export function splitTopLevelGoList(text: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const ch = text[i];
|
||||
if (ch === '[' || ch === '{' || ch === '(') depth += 1;
|
||||
else if (ch === ']' || ch === '}' || ch === ')') depth -= 1;
|
||||
else if (ch === ',' && depth === 0) {
|
||||
parts.push(text.slice(start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
parts.push(text.slice(start));
|
||||
return parts.map((part) => part.trim()).filter((part) => part.length > 0);
|
||||
}
|
||||
|
||||
function simpleGoName(qualifiedName: string): string {
|
||||
const dot = qualifiedName.lastIndexOf('.');
|
||||
return dot === -1 ? qualifiedName : qualifiedName.slice(dot + 1);
|
||||
}
|
||||
|
||||
/** Offsets at which each 1-based line begins. */
|
||||
function buildLineStarts(source: string): number[] {
|
||||
const starts = [0, 0];
|
||||
for (let i = 0; i < source.length; i += 1) {
|
||||
if (source[i] === '\n') starts.push(i + 1);
|
||||
}
|
||||
return starts;
|
||||
}
|
||||
|
||||
/** `Range` is 1-based on lines and 0-based on columns (`syntheticCapture`). */
|
||||
function sliceRange(
|
||||
source: string,
|
||||
lineStarts: readonly number[],
|
||||
range: Range,
|
||||
): string | undefined {
|
||||
const start = lineStarts[range.startLine];
|
||||
const end = lineStarts[range.endLine];
|
||||
if (start === undefined || end === undefined) return undefined;
|
||||
return source.slice(start + range.startCol, end + range.endCol);
|
||||
}
|
||||
|
|
@ -3,6 +3,12 @@ import type { SemanticModel } from '../../model/semantic-model.js';
|
|||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import { simpleQualifiedName } from '../../scope-resolution/graph-bridge/ids.js';
|
||||
import { resolveInheritanceBaseInScope } from '../../scope-resolution/scope/walkers.js';
|
||||
import { goPackageDir } from './package-clause.js';
|
||||
import {
|
||||
matchingGoDelimiter,
|
||||
readGoTypeParameters,
|
||||
splitTopLevelGoList,
|
||||
} from './generic-type-parameters.js';
|
||||
|
||||
type MethodSet = ReadonlyMap<string, readonly SymbolDefinition[]>;
|
||||
type MutableMethodSet = Map<string, SymbolDefinition[]>;
|
||||
|
|
@ -37,13 +43,32 @@ type DetectionIndexes = {
|
|||
readonly structsById: ReadonlyMap<string, SymbolDefinition>;
|
||||
readonly methodsByOwner: ReadonlyMap<string, MethodSet>;
|
||||
readonly effectiveMethodsByStructId: ReadonlyMap<string, MethodSet>;
|
||||
readonly interfaceById: ReadonlyMap<string, SymbolDefinition>;
|
||||
/** Every interface in the program keyed by `qualifiedName`, `null` where more
|
||||
* than one declares that name — the single probe behind
|
||||
* {@link uniqueInterfaceNamed}. */
|
||||
readonly interfacesByQualifiedName: ReadonlyMap<string, SymbolDefinition | null>;
|
||||
readonly interfaceOwnMethodsById: ReadonlyMap<string, MethodSet>;
|
||||
readonly embeddedSitesByInterfaceId: ReadonlyMap<string, readonly ReferenceSite[]>;
|
||||
readonly parentStructIdsByStructId: ReadonlyMap<string, readonly EmbeddedParent[]>;
|
||||
readonly valueMethodsByStructId: ReadonlyMap<string, MethodSet>;
|
||||
readonly structIdsByMethodName: ReadonlyMap<string, ReadonlySet<string>>;
|
||||
readonly signatureContextByDefId: ReadonlyMap<string, SignatureContext>;
|
||||
/** Type-parameter names, in declaration order, for every GENERIC interface.
|
||||
* Absence means "not generic" and is the gate on the whole instantiation
|
||||
* path — no entry, nothing below runs. */
|
||||
readonly typeParametersByInterfaceId: ReadonlyMap<string, readonly string[]>;
|
||||
/**
|
||||
* Every distinct instantiation of each generic interface observed anywhere in
|
||||
* the program: interface id → the instantiation's normalized type ARGUMENTS,
|
||||
* keyed by that list joined — which is what deduplicates it.
|
||||
*
|
||||
* An instantiation is nothing but that list. `Repo[User]` reduces to the type
|
||||
* arguments ALREADY normalized in the signature context of the file that wrote
|
||||
* them, so a cross-package `repo.Repo[model.User]` and the implementor's own
|
||||
* `model.User` compare as the same type without either side re-qualifying the
|
||||
* other's spelling.
|
||||
*/
|
||||
readonly instantiationsByInterfaceId: ReadonlyMap<string, ReadonlyMap<string, readonly string[]>>;
|
||||
readonly scopeIndexes: ScopeResolutionIndexes;
|
||||
};
|
||||
|
||||
|
|
@ -72,14 +97,21 @@ function buildDetectionIndexes(
|
|||
const signatureContextByDefId = new Map<string, SignatureContext>();
|
||||
const interfaceIdByScopeId = new Map<string, string>();
|
||||
const structIdByScopeId = new Map<string, string>();
|
||||
const typeParametersByInterfaceId = new Map<string, readonly string[]>();
|
||||
const signatureContextByFilePath = new Map<string, SignatureContext>();
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
const signatureContext = signatureContextForFile(parsed, indexes);
|
||||
signatureContextByFilePath.set(parsed.filePath, signatureContext);
|
||||
for (const def of parsed.localDefs) {
|
||||
signatureContextByDefId.set(def.nodeId, signatureContext);
|
||||
if (def.type === 'Interface') {
|
||||
interfaces.push(def);
|
||||
interfaceById.set(def.nodeId, def);
|
||||
const typeParameters = readGoTypeParameters(def);
|
||||
if (typeParameters !== undefined) {
|
||||
typeParametersByInterfaceId.set(def.nodeId, typeParameters);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (def.type === 'Struct') {
|
||||
|
|
@ -129,6 +161,16 @@ function buildDetectionIndexes(
|
|||
}
|
||||
}
|
||||
|
||||
// Built from the nodeId-keyed map, so a def that appears in two ParsedFiles is
|
||||
// one interface here just as it is one there — not a name collision with
|
||||
// itself.
|
||||
const interfacesByQualifiedName = new Map<string, SymbolDefinition | null>();
|
||||
for (const iface of interfaceById.values()) {
|
||||
const name = iface.qualifiedName;
|
||||
if (name === undefined || name.length === 0) continue;
|
||||
interfacesByQualifiedName.set(name, interfacesByQualifiedName.has(name) ? null : iface);
|
||||
}
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const scope of parsed.scopes) {
|
||||
const iface = scope.ownedDefs.find((def) => def.type === 'Interface');
|
||||
|
|
@ -215,17 +257,204 @@ function buildDetectionIndexes(
|
|||
structsById,
|
||||
methodsByOwner,
|
||||
effectiveMethodsByStructId,
|
||||
interfaceById,
|
||||
interfacesByQualifiedName,
|
||||
interfaceOwnMethodsById,
|
||||
embeddedSitesByInterfaceId,
|
||||
parentStructIdsByStructId,
|
||||
structIdsByMethodName,
|
||||
valueMethodsByStructId,
|
||||
signatureContextByDefId,
|
||||
typeParametersByInterfaceId,
|
||||
// Gated on the repo declaring at least one generic interface. A Go codebase
|
||||
// with none — the overwhelming majority — never runs the harvest at all,
|
||||
// which matters because the spellings it would scan (`[]byte`,
|
||||
// `map[string]X`) are among the commonest types in the language.
|
||||
instantiationsByInterfaceId:
|
||||
typeParametersByInterfaceId.size === 0
|
||||
? new Map()
|
||||
: collectGoInstantiations(
|
||||
parsedFiles,
|
||||
signatureContextByFilePath,
|
||||
typeParametersByInterfaceId,
|
||||
interfacesByQualifiedName,
|
||||
indexes,
|
||||
),
|
||||
scopeIndexes: indexes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every distinct instantiation of a generic interface written anywhere in the
|
||||
* program, resolved and deduplicated in one pass.
|
||||
*
|
||||
* Go records no instantiation anywhere on the DECLARATION — `Repo[User]` exists
|
||||
* only where it is written — so the sites are the field/parameter/variable type
|
||||
* spellings the capture layer already preserved: `TypeRef.declaredSpelling`
|
||||
* (which keeps the arguments `rawName` drops), and the def-side `declaredType` /
|
||||
* `parameterTypes` / `returnType`.
|
||||
*
|
||||
* A spelling is scanned rather than parsed as a whole, so decorated and nested
|
||||
* forms yield their inner instantiations too: `[]Repo[User]`, `*Repo[User]` and
|
||||
* `map[string]Repo[User]` all yield `Repo[User]`, and `Outer[Repo[User]]` yields
|
||||
* both — each of which really is an instantiation present in the program. False
|
||||
* bases (`map[` scans as base `map`) resolve to no interface and drop out.
|
||||
*/
|
||||
function collectGoInstantiations(
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
signatureContextByFilePath: ReadonlyMap<string, SignatureContext>,
|
||||
typeParametersByInterfaceId: ReadonlyMap<string, readonly string[]>,
|
||||
interfacesByQualifiedName: ReadonlyMap<string, SymbolDefinition | null>,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
): ReadonlyMap<string, ReadonlyMap<string, readonly string[]>> {
|
||||
// One map where there were two on the same key: the inner map's KEY is the
|
||||
// joined argument list, so holding it is the deduplication.
|
||||
const argsByInterfaceId = new Map<string, Map<string, readonly string[]>>();
|
||||
// A base name resolves once per scope. The bracket gate below cannot filter
|
||||
// Go's commonest types — `map[string]string` scans as base `map` — so the
|
||||
// FALSE bases dominate this pass, and each one otherwise re-walks the whole
|
||||
// scope chain for a name that will never bind.
|
||||
const basesInScope = new Map<string, SymbolDefinition | null>();
|
||||
const resolveBase = (baseName: string, inScope: string): SymbolDefinition | undefined => {
|
||||
// NUL-joined for the same reason `methodSetKey` is: it cannot occur in Go
|
||||
// source, so no two (scope, name) pairs can collide on one key.
|
||||
const key = `${inScope}\u0000${baseName}`;
|
||||
const memo = basesInScope.get(key);
|
||||
if (memo !== undefined) return memo ?? undefined;
|
||||
const iface = resolveGoInstantiationBase(baseName, inScope, interfacesByQualifiedName, indexes);
|
||||
basesInScope.set(key, iface ?? null);
|
||||
return iface;
|
||||
};
|
||||
const record = (
|
||||
spelling: string | undefined,
|
||||
inScope: string,
|
||||
context: SignatureContext,
|
||||
): void => {
|
||||
// Cheap gate first: most Go type spellings have no bracket at all, and the
|
||||
// scan below is the only per-spelling cost this pass adds.
|
||||
if (spelling === undefined || !spelling.includes('[')) return;
|
||||
for (const { baseName, rawArgs } of parseGoInstantiationSpellings(spelling)) {
|
||||
const iface = resolveBase(baseName, inScope);
|
||||
if (iface === undefined) continue;
|
||||
const typeParameters = typeParametersByInterfaceId.get(iface.nodeId);
|
||||
// A partial or over-long argument list is not a valid instantiation
|
||||
// ("For a generic type, all type arguments must always be provided
|
||||
// explicitly" — go.dev/ref/spec#Instantiations), so there is nothing to
|
||||
// substitute and the site is dropped.
|
||||
if (typeParameters === undefined || typeParameters.length !== rawArgs.length) continue;
|
||||
const normalizedArgs = normalizeGoTypeArguments(rawArgs, context);
|
||||
if (normalizedArgs === undefined) continue;
|
||||
let byArgs = argsByInterfaceId.get(iface.nodeId);
|
||||
if (byArgs === undefined) {
|
||||
byArgs = new Map<string, readonly string[]>();
|
||||
argsByInterfaceId.set(iface.nodeId, byArgs);
|
||||
}
|
||||
const key = normalizedArgs.join(',');
|
||||
if (!byArgs.has(key)) byArgs.set(key, normalizedArgs);
|
||||
}
|
||||
};
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
const context = signatureContextByFilePath.get(parsed.filePath);
|
||||
if (context === undefined) continue;
|
||||
for (const scope of parsed.scopes) {
|
||||
for (const binding of scope.typeBindings.values()) {
|
||||
record(binding.declaredSpelling ?? binding.rawName, scope.id, context);
|
||||
}
|
||||
for (const def of scope.ownedDefs) {
|
||||
record(def.declaredType, scope.id, context);
|
||||
record(def.returnType, scope.id, context);
|
||||
for (const parameterType of def.parameterTypes ?? []) {
|
||||
record(parameterType, scope.id, context);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return argsByInterfaceId;
|
||||
}
|
||||
|
||||
/** Every `Ident[…]` / `pkg.Ident[…]` application in a type spelling, with its
|
||||
* top-level (comma-separated, delimiter-balanced) arguments. */
|
||||
function parseGoInstantiationSpellings(
|
||||
spelling: string,
|
||||
): Array<{ readonly baseName: string; readonly rawArgs: readonly string[] }> {
|
||||
const out: Array<{ baseName: string; rawArgs: string[] }> = [];
|
||||
const namePattern = /[A-Za-z_][A-Za-z0-9_.]*(?=\[)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = namePattern.exec(spelling)) !== null) {
|
||||
const open = match.index + match[0].length;
|
||||
const close = matchingGoDelimiter(spelling, open);
|
||||
if (close === -1) continue;
|
||||
const rawArgs = splitTopLevelGoList(spelling.slice(open + 1, close));
|
||||
if (rawArgs.length === 0) continue;
|
||||
out.push({ baseName: match[0], rawArgs });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Normalize each type argument in the context of the file that WROTE it, or
|
||||
* `undefined` when any of them carries an unresolvable import qualifier — a
|
||||
* half-normalized argument list would compare against nothing meaningful. */
|
||||
function normalizeGoTypeArguments(
|
||||
rawArgs: readonly string[],
|
||||
context: SignatureContext,
|
||||
): string[] | undefined {
|
||||
const normalizedArgs: string[] = [];
|
||||
for (const rawArg of rawArgs) {
|
||||
const normalized = normalizeSignatureType(rawArg, context);
|
||||
if (normalized === undefined) return undefined;
|
||||
normalizedArgs.push(normalized);
|
||||
}
|
||||
return normalizedArgs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind an instantiation's base name to the generic interface it names.
|
||||
*
|
||||
* Goes through `resolveInheritanceBaseInScope` first — the same real scope
|
||||
* resolution the embedded-interface path uses — and falls back to a globally
|
||||
* UNIQUE name match.
|
||||
*/
|
||||
function resolveGoInstantiationBase(
|
||||
baseName: string,
|
||||
inScope: string,
|
||||
interfacesByQualifiedName: ReadonlyMap<string, SymbolDefinition | null>,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
): SymbolDefinition | undefined {
|
||||
const bound = resolveInheritanceBaseInScope(inScope, simpleTypeName(baseName), indexes);
|
||||
if (bound !== undefined) return bound.type === 'Interface' ? bound : undefined;
|
||||
return uniqueInterfaceNamed(baseName, interfacesByQualifiedName);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one interface a written name denotes by NAME ALONE — the fallback both
|
||||
* name-match routes here share, once the real scope resolution above them has
|
||||
* declined.
|
||||
*
|
||||
* Ambiguity drops the site rather than guessing: two same-named interfaces in
|
||||
* different packages would otherwise cross-pollinate each other's
|
||||
* instantiations, and a dropped site only costs fan-out that does not exist
|
||||
* today anyway. A qualified spelling is tried under both its own name and its
|
||||
* simple tail, and a hit under EACH is two matches, so it declines as well.
|
||||
*
|
||||
* One probe rather than a scan of every interface in the program, which is what
|
||||
* made this quadratic: the bracket gate in `collectGoInstantiations` cannot
|
||||
* filter `map[…]` or `[]T`, so a Go program pays this once per bracketed
|
||||
* spelling it writes.
|
||||
*/
|
||||
function uniqueInterfaceNamed(
|
||||
name: string,
|
||||
interfacesByQualifiedName: ReadonlyMap<string, SymbolDefinition | null>,
|
||||
): SymbolDefinition | undefined {
|
||||
const exact = interfacesByQualifiedName.get(name);
|
||||
if (exact === null) return undefined;
|
||||
const simpleName = simpleTypeName(name);
|
||||
if (simpleName === name) return exact;
|
||||
const simple = interfacesByQualifiedName.get(simpleName);
|
||||
if (simple === null) return undefined;
|
||||
if (exact !== undefined && simple !== undefined) return undefined;
|
||||
return exact ?? simple;
|
||||
}
|
||||
|
||||
function detectGoInterfaceImplementationsFromIndexes(
|
||||
indexes: DetectionIndexes,
|
||||
): Map<string, GoStructuralImplementor[]> {
|
||||
|
|
@ -236,30 +465,208 @@ function detectGoInterfaceImplementationsFromIndexes(
|
|||
if (required === undefined || required.size === 0) continue;
|
||||
if (!methodSetHasVerifiableSignatures(required)) continue;
|
||||
|
||||
const implementors: GoStructuralImplementor[] = [];
|
||||
for (const structId of candidateStructIdsFor(required, indexes)) {
|
||||
const pointerSet = indexes.effectiveMethodsByStructId.get(structId);
|
||||
if (pointerSet === undefined) continue;
|
||||
// MS(*T) is the superset: if it does not satisfy, neither does MS(T).
|
||||
if (!methodSetSatisfies(pointerSet, required, indexes.signatureContextByDefId)) continue;
|
||||
// Then ask the narrower question separately — does the VALUE type satisfy?
|
||||
// This is the distinction `var x I = T{}` turns on, and it is a fact about
|
||||
// the program, not a heuristic.
|
||||
const valueSet = indexes.valueMethodsByStructId.get(structId);
|
||||
const satisfiesByValue =
|
||||
valueSet !== undefined &&
|
||||
methodSetSatisfies(valueSet, required, indexes.signatureContextByDefId);
|
||||
implementors.push({
|
||||
structDefId: structId,
|
||||
receiverForm: satisfiesByValue ? 'value' : 'pointer',
|
||||
});
|
||||
// Hoisted, not recomputed per set: `substituteMethodSet` rewrites
|
||||
// SIGNATURES and returns the identical key set, and `candidateStructIdsFor`
|
||||
// keys off nothing but those method names — so every set below has exactly
|
||||
// these candidates. Materialized because it is iterated once per
|
||||
// instantiation and one of the branches behind it yields a live iterator.
|
||||
const candidateStructIds = [...candidateStructIdsFor(required, indexes)];
|
||||
// The declaration's own method set, then one per observed instantiation.
|
||||
// The declaration set runs FIRST and unconditionally, so this is strictly
|
||||
// additive: every implementor found before #2855 is still found, in the
|
||||
// same order, and instantiation only ever appends.
|
||||
const formByStructId = new Map<string, GoReceiverForm>();
|
||||
for (const candidateSet of [required, ...instantiatedMethodSetsFor(iface, required, indexes)]) {
|
||||
for (const structId of candidateStructIds) {
|
||||
if (formByStructId.get(structId) === 'value') continue;
|
||||
const pointerSet = indexes.effectiveMethodsByStructId.get(structId);
|
||||
if (pointerSet === undefined) continue;
|
||||
// MS(*T) is the superset: if it does not satisfy, neither does MS(T).
|
||||
if (!methodSetSatisfies(pointerSet, candidateSet, indexes.signatureContextByDefId))
|
||||
continue;
|
||||
// Then ask the narrower question separately — does the VALUE type satisfy?
|
||||
// This is the distinction `var x I = T{}` turns on, and it is a fact about
|
||||
// the program, not a heuristic.
|
||||
const valueSet = indexes.valueMethodsByStructId.get(structId);
|
||||
const satisfiesByValue =
|
||||
valueSet !== undefined &&
|
||||
methodSetSatisfies(valueSet, candidateSet, indexes.signatureContextByDefId);
|
||||
formByStructId.set(structId, satisfiesByValue ? 'value' : 'pointer');
|
||||
}
|
||||
}
|
||||
const implementors: GoStructuralImplementor[] = [...formByStructId].map(
|
||||
([structDefId, receiverForm]) => ({ structDefId, receiverForm }),
|
||||
);
|
||||
if (implementors.length > 0) implementations.set(iface.nodeId, implementors);
|
||||
}
|
||||
|
||||
return implementations;
|
||||
}
|
||||
|
||||
/**
|
||||
* The method set of each observed INSTANTIATION of a generic interface.
|
||||
*
|
||||
* Go spec, Instantiations: "A generic function or type is instantiated by
|
||||
* substituting type arguments for the type parameters. … Each type argument is
|
||||
* substituted for its corresponding type parameter in the generic declaration. …
|
||||
* Instantiating a type results in a new non-generic named type." Combined with
|
||||
* Type definitions ("Generic types must be instantiated when they are used") the
|
||||
* consequence is that `Repo` is not a type at all and `Repo[User]` is — with
|
||||
* method set `{ Save(x User) }` after substitution. Implementing an interface
|
||||
* then asks whether a type "is an element of the type set of I", and Basic
|
||||
* interfaces defines that type set as "the set of types which implement all of
|
||||
* those methods". `UserRepo`, whose method set contains `Save(x User)`, is an
|
||||
* element of `Repo[User]`'s type set — so it implements `Repo[User]`, and a call
|
||||
* through a `Repo[User]`-typed field really can land on `UserRepo.Save`. Before
|
||||
* this, it could not: the required parameter type stayed the type PARAMETER `T`,
|
||||
* matched no implementor's `User`, and the interface got no IMPLEMENTS edge at
|
||||
* all. That is the same false-silence shape as #2813/#2829, one abstraction up.
|
||||
*
|
||||
* SUBSTITUTION, NOT ERASURE. `Repo[Order]` instantiates to `Save(x Order)` and
|
||||
* is NOT satisfied by a `Save(x User)` implementor. Treating `T` as a wildcard
|
||||
* would satisfy both and mint an edge Go does not have; the whole point of
|
||||
* #2829 was that an exact model beats an approximate one.
|
||||
*
|
||||
* WHAT THIS DELIBERATELY DOES NOT MODEL. GitNexus holds one node per generic
|
||||
* DECLARATION, not one per instantiation, so an interface instantiated at two
|
||||
* different arguments in the same program unions their implementors onto the one
|
||||
* `Repo` node — `Repo[User]` and `Repo[Order]` in the same repo both fan out to
|
||||
* every type satisfying either. That is the same one-node-per-declaration
|
||||
* over-approximation every nominal language in the graph already carries (a
|
||||
* Kotlin `class UserRepo : Repo<User>` yields `UserRepo IMPLEMENTS Repo`, argument
|
||||
* discarded), and it is bounded by the arguments the program actually writes —
|
||||
* strictly narrower than erasure, which admits arguments that appear nowhere.
|
||||
*
|
||||
* Constraints are out of reach by construction and that is correct: a generic
|
||||
* interface used as a CONSTRAINT (`func F[T Repo[X]](…)`) is written in a type
|
||||
* parameter list, which produces no type binding and no declared type, so no
|
||||
* such site is ever harvested. Non-basic interfaces — the union/type-set kind
|
||||
* that "may only be used as type constraints" (General interfaces) — declare no
|
||||
* methods and are already dropped by the empty-method-set guard above.
|
||||
*/
|
||||
function instantiatedMethodSetsFor(
|
||||
iface: SymbolDefinition,
|
||||
required: MethodSet,
|
||||
indexes: DetectionIndexes,
|
||||
): MethodSet[] {
|
||||
const typeParameters = indexes.typeParametersByInterfaceId.get(iface.nodeId);
|
||||
if (typeParameters === undefined) return [];
|
||||
const instantiations = indexes.instantiationsByInterfaceId.get(iface.nodeId);
|
||||
if (instantiations === undefined || instantiations.size === 0) return [];
|
||||
const indexByName = new Map(typeParameters.map((name, index) => [name, index]));
|
||||
const sets: MethodSet[] = [];
|
||||
for (const normalizedArgs of instantiations.values()) {
|
||||
const substituted = substituteMethodSet(
|
||||
required,
|
||||
indexByName,
|
||||
normalizedArgs,
|
||||
indexes.signatureContextByDefId,
|
||||
);
|
||||
if (substituted !== undefined) sets.push(substituted);
|
||||
}
|
||||
return sets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a required method set under one instantiation, or `undefined` when any
|
||||
* signature in it cannot be normalized (an unresolved import qualifier) — a
|
||||
* partially substituted set would compare a mix of instantiated and
|
||||
* uninstantiated types, so the instantiation is dropped whole.
|
||||
*
|
||||
* The substituted defs carry a synthetic node id that is deliberately absent
|
||||
* from `signatureContextByDefId`. Their parameter/return types come out of here
|
||||
* ALREADY normalized — the type arguments in the context that WROTE them, the
|
||||
* rest in the interface's own — and `normalizeSignatureType` with no context is
|
||||
* the identity beyond whitespace, so the comparison in `signaturesCompatible`
|
||||
* cannot re-qualify a spelling that is already fully qualified.
|
||||
*/
|
||||
function substituteMethodSet(
|
||||
required: MethodSet,
|
||||
indexByName: ReadonlyMap<string, number>,
|
||||
normalizedArgs: readonly string[],
|
||||
signatureContextByDefId: ReadonlyMap<string, SignatureContext>,
|
||||
): MutableMethodSet | undefined {
|
||||
const out = new Map<string, SymbolDefinition[]>();
|
||||
for (const [name, overloads] of required) {
|
||||
const substitutedOverloads: SymbolDefinition[] = [];
|
||||
for (const def of overloads) {
|
||||
const context = signatureContextByDefId.get(def.nodeId);
|
||||
const parameterTypes: string[] = [];
|
||||
for (const parameterType of def.parameterTypes ?? []) {
|
||||
const substituted = substituteSignatureType(
|
||||
parameterType,
|
||||
indexByName,
|
||||
normalizedArgs,
|
||||
context,
|
||||
);
|
||||
if (substituted === undefined) return undefined;
|
||||
parameterTypes.push(substituted);
|
||||
}
|
||||
let returnType: string | undefined;
|
||||
if (def.returnType !== undefined) {
|
||||
returnType = substituteSignatureType(def.returnType, indexByName, normalizedArgs, context);
|
||||
if (returnType === undefined) return undefined;
|
||||
}
|
||||
substitutedOverloads.push({
|
||||
...def,
|
||||
nodeId: `${def.nodeId}\u0000instantiated`,
|
||||
...(def.parameterTypes !== undefined ? { parameterTypes } : {}),
|
||||
...(returnType !== undefined ? { returnType } : {}),
|
||||
});
|
||||
}
|
||||
out.set(name, substitutedOverloads);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder for the type argument at position `i` while its enclosing type is
|
||||
* normalized. NUL-delimited — the same separator `methodSetKey` already uses,
|
||||
* and for the same reason: it cannot occur in Go source.
|
||||
*
|
||||
* Both halves of that choice are load-bearing. `qualifyGoSignatureTypes` rewrites
|
||||
* only tokens matching `[A-Za-z_][A-Za-z0-9_]*`, which can start with neither NUL
|
||||
* nor a digit, so the placeholder survives normalization untouched; and
|
||||
* `normalizeSignatureType` strips `\s+` FIRST, so a whitespace-delimited
|
||||
* placeholder would lose its delimiters and become indistinguishable from an
|
||||
* array length (`[5]int`).
|
||||
*/
|
||||
const TYPE_PARAMETER_PLACEHOLDER = /\u0000(\d+)\u0000/g;
|
||||
|
||||
/**
|
||||
* Substitute type arguments into one signature type, preserving Go's type
|
||||
* identity rules for everything around them.
|
||||
*
|
||||
* Substitution happens BEFORE normalization and reinstatement AFTER, so the
|
||||
* argument's own spelling is never re-qualified by the interface's package while
|
||||
* the rest of the type still is: `[]T` in package `repo` with argument
|
||||
* `internal/model.User` yields `[]internal/model.User`, not
|
||||
* `[]repo.internal/model.User`. Pointer, slice, map and variadic shape survive
|
||||
* because only the identifier token is replaced (`*T` -> `*model.User`), which is
|
||||
* what makes `Save(x T)` and `Save(x *T)` stay different methods.
|
||||
*/
|
||||
function substituteSignatureType(
|
||||
typeName: string,
|
||||
indexByName: ReadonlyMap<string, number>,
|
||||
normalizedArgs: readonly string[],
|
||||
context: SignatureContext | undefined,
|
||||
): string | undefined {
|
||||
const placeheld = typeName.replace(
|
||||
/[A-Za-z_][A-Za-z0-9_]*/g,
|
||||
(token, offset: number, source: string) => {
|
||||
// `pkg.T` names `T` in package `pkg`, never the type parameter `T`.
|
||||
if (hasPackageQualifierDot(source, offset)) return token;
|
||||
const index = indexByName.get(token);
|
||||
return index === undefined ? token : `\u0000${index}\u0000`;
|
||||
},
|
||||
);
|
||||
const normalized = normalizeSignatureType(placeheld, context);
|
||||
if (normalized === undefined) return undefined;
|
||||
return normalized.replace(TYPE_PARAMETER_PLACEHOLDER, (_match, digits: string) => {
|
||||
return normalizedArgs[Number(digits)] ?? _match;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The key a method occupies in a method set.
|
||||
*
|
||||
|
|
@ -528,15 +935,7 @@ function resolveEmbeddedInterface(
|
|||
): SymbolDefinition | undefined {
|
||||
const bound = resolveInheritanceBaseInScope(site.inScope, site.name, indexes.scopeIndexes);
|
||||
if (bound !== undefined) return bound.type === 'Interface' ? bound : undefined;
|
||||
|
||||
const simpleName = simpleTypeName(site.name);
|
||||
const matches: SymbolDefinition[] = [];
|
||||
for (const iface of indexes.interfaceById.values()) {
|
||||
if (iface.qualifiedName === site.name || iface.qualifiedName === simpleName) {
|
||||
matches.push(iface);
|
||||
}
|
||||
}
|
||||
return matches.length === 1 ? matches[0] : undefined;
|
||||
return uniqueInterfaceNamed(site.name, indexes.interfacesByQualifiedName);
|
||||
}
|
||||
|
||||
function simpleTypeName(name: string): string {
|
||||
|
|
@ -752,11 +1151,13 @@ function signatureContextForFile(
|
|||
};
|
||||
}
|
||||
|
||||
/** The package directory, or `undefined` for a repo-root file.
|
||||
*
|
||||
* Shares `goPackageDir` with the package-clause resolver rather than repeating
|
||||
* its normalize-and-slice (#2837): the two disagree only on how they spell "no
|
||||
* directory", so the difference stays here, at the one call site that cares. */
|
||||
function packageQualifierForFile(filePath: string): string | undefined {
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
const slash = normalized.lastIndexOf('/');
|
||||
if (slash === -1) return undefined;
|
||||
const packageDir = normalized.slice(0, slash);
|
||||
const packageDir = goPackageDir(filePath);
|
||||
return packageDir.length === 0 ? undefined : packageDir;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
import { logger } from '../../../logger.js';
|
||||
import { isClassLike, populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
|
||||
|
||||
import { goPackageDir, inferGoPackageName } from './package-clause.js';
|
||||
import { stampGoInterfaceTypeParameters } from './generic-type-parameters.js';
|
||||
|
||||
/** Bound on the sample of no-package-clause paths named in the warning. */
|
||||
const SKIPPED_SAMPLE_CAP = 5;
|
||||
|
||||
/**
|
||||
* Populate `ownerId` on Go Method defs by matching receiver types
|
||||
* extracted from `@type-binding.self` captures against struct defs in
|
||||
|
|
@ -26,15 +33,42 @@ export function populateGoWorkspaceOwners(
|
|||
parsedFiles: readonly ParsedFile[],
|
||||
ctx: { readonly fileContents: ReadonlyMap<string, string> },
|
||||
): void {
|
||||
// Generic interfaces get their type-parameter list stamped here rather than at
|
||||
// capture time, because this is the first main-thread hook that sees BOTH the
|
||||
// parsed scopes and the file text (it already reads `fileContents` for the
|
||||
// package clause). `detectGoInterfaceImplementations` runs later in the same
|
||||
// pass and is the only reader. See `stampGoInterfaceTypeParameters`.
|
||||
stampGoInterfaceTypeParameters(parsedFiles, ctx.fileContents);
|
||||
|
||||
const filesByPackage = new Map<string, ParsedFile[]>();
|
||||
// A file with no resolvable package clause is dropped from ownership
|
||||
// resolution entirely — its methods never attach to a struct declared in a
|
||||
// sibling file. That used to be a bare `continue` with no trace, which is the
|
||||
// same false-safe silence #2813 was filed about. Report it once, bounded
|
||||
// (mirrors the fan-out-cap warning in scope-resolution/pipeline/run.ts).
|
||||
let skippedCount = 0;
|
||||
const skippedSample: string[] = [];
|
||||
for (const parsed of parsedFiles) {
|
||||
const pkgName = inferPackageName(ctx.fileContents.get(parsed.filePath) ?? '');
|
||||
if (pkgName === null) continue;
|
||||
const key = `${packageDir(parsed.filePath)}\0${pkgName}`;
|
||||
const pkgName = inferGoPackageName(ctx.fileContents.get(parsed.filePath) ?? '');
|
||||
if (pkgName === null) {
|
||||
// Count everything, retain only the sample — a misrouted vendored tree
|
||||
// would otherwise accumulate one path reference per file to print five.
|
||||
skippedCount += 1;
|
||||
if (skippedSample.length < SKIPPED_SAMPLE_CAP) skippedSample.push(parsed.filePath);
|
||||
continue;
|
||||
}
|
||||
const key = `${goPackageDir(parsed.filePath)}\0${pkgName}`;
|
||||
const bucket = filesByPackage.get(key) ?? [];
|
||||
bucket.push(parsed);
|
||||
filesByPackage.set(key, bucket);
|
||||
}
|
||||
if (skippedCount > 0) {
|
||||
logger.warn(
|
||||
{ skippedFiles: skippedCount, sample: skippedSample },
|
||||
'go: files with no resolvable package clause were excluded from method-owner ' +
|
||||
'resolution (their methods cannot attach to structs declared in sibling files)',
|
||||
);
|
||||
}
|
||||
|
||||
for (const bucket of filesByPackage.values()) {
|
||||
populateGoOwnersInPackage(bucket);
|
||||
|
|
@ -107,14 +141,3 @@ function populateGoOwnersInPackage(parsedFiles: readonly ParsedFile[]): void {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
function inferPackageName(sourceText: string): string | null {
|
||||
const match = sourceText.match(/^\s*package\s+([A-Za-z_][A-Za-z0-9_]*)/m);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function packageDir(filePath: string): string {
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
const idx = normalized.lastIndexOf('/');
|
||||
return idx === -1 ? '' : normalized.slice(0, idx);
|
||||
}
|
||||
|
|
|
|||
78
gitnexus/src/core/ingestion/languages/go/package-clause.ts
Normal file
78
gitnexus/src/core/ingestion/languages/go/package-clause.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* Go package-clause resolution — the single derivation of a file's package
|
||||
* identity (#2837).
|
||||
*
|
||||
* Both Go passes that bucket files by package (`populateGoWorkspaceOwners` and
|
||||
* `populateGoPackageSiblings`) previously carried their own byte-identical copy
|
||||
* of this, spelled as one unanchored multiline regex:
|
||||
*
|
||||
* sourceText.match(/^\s*package\s+([A-Za-z_][A-Za-z0-9_]*)/m)
|
||||
*
|
||||
* With the `m` flag that matches the first line ANYWHERE in the file starting
|
||||
* with `package <ident>` — comment bodies included. Measured against that exact
|
||||
* expression: a header comment containing `package legacy_notes kept for
|
||||
* history` yields `legacy_notes`, and an indented ` package helper old name`
|
||||
* yields `helper`. A file that mis-infers its own package gets a bucket key no
|
||||
* sibling shares, so it is isolated in BOTH passes: its methods never attach to
|
||||
* structs declared in sibling files, and it exchanges no same-package bindings.
|
||||
* Every field-receiver call in it then resolves to nothing, silently — the same
|
||||
* per-file signature #2837 reported.
|
||||
*
|
||||
* The Go spec makes the correct rule exact rather than heuristic: a source
|
||||
* file's first non-comment, non-blank token is `package`. So skip the leading
|
||||
* run of whitespace and comments, then require the very next token to be the
|
||||
* clause. Anything else is `null` — a truncated read, a misrouted non-Go file,
|
||||
* an unparseable header — reported by the caller rather than guessed at.
|
||||
*
|
||||
* ONE rule governs every leniency below (the `\s+` separator, the shebang skip,
|
||||
* CR-only line endings): a file this returns `null` for is dropped from BOTH
|
||||
* passes, so refusing a shape the previous regex accepted is a silent
|
||||
* regression, not a principled tightening. Be no stricter than the grammar.
|
||||
*/
|
||||
|
||||
/** Leading trivia: whitespace, `//` lines, block comments. Sticky. */
|
||||
const LEADING_TRIVIA = /(?:\s+|\/\/[^\n\r]*|\/\*[\s\S]*?\*\/)*/y;
|
||||
|
||||
/** The clause itself, anchored at the first non-trivia byte. `\s+` (not
|
||||
* `[ \t]+`) because Go separates tokens by any whitespace: `package\nmain` is
|
||||
* legal and tree-sitter parses it without error. */
|
||||
const PACKAGE_CLAUSE = /package\s+([A-Za-z_][A-Za-z0-9_]*)/y;
|
||||
|
||||
/**
|
||||
* The package name declared by this Go source text, or `null` when its first
|
||||
* real token is not a package clause.
|
||||
*
|
||||
* Only the leading run before the clause is skipped — deliberately NOT a
|
||||
* whole-file comment strip, which would be O(file) on every Go file and would
|
||||
* also have to model string literals to stay correct.
|
||||
*/
|
||||
export function inferGoPackageName(sourceText: string): string | null {
|
||||
let i = 0;
|
||||
// A leading `#!` line: `gorun`-style scripts carry one. Only a FIRST-line
|
||||
// `#!` is skipped; a `#` anywhere else still ends the scan.
|
||||
if (sourceText.startsWith('#!')) {
|
||||
const eol = sourceText.search(/[\n\r]/);
|
||||
if (eol === -1) return null;
|
||||
i = eol + 1;
|
||||
}
|
||||
// Whitespace (`\s` covers the BOM and every line ending), `//` lines and block
|
||||
// comments — the run a Go file may carry before its clause. Sticky, so the
|
||||
// header is skipped in place without slicing a copy of the file. An
|
||||
// unterminated `/*` or a `//` running to EOF simply leaves `lastIndex` where
|
||||
// the clause cannot match, so neither needs its own early return.
|
||||
LEADING_TRIVIA.lastIndex = i;
|
||||
LEADING_TRIVIA.exec(sourceText);
|
||||
PACKAGE_CLAUSE.lastIndex = LEADING_TRIVIA.lastIndex;
|
||||
return PACKAGE_CLAUSE.exec(sourceText)?.[1] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The directory half of a Go package key. Go package identity is
|
||||
* directory-scoped, so repeated `package main` directories must not see each
|
||||
* other's unqualified names.
|
||||
*/
|
||||
export function goPackageDir(filePath: string): string {
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
const idx = normalized.lastIndexOf('/');
|
||||
return idx === -1 ? '' : normalized.slice(0, idx);
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import type { BindingRef, ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus
|
|||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
|
||||
import { expandGoDotImports } from './expand-wildcards.js';
|
||||
import { goPackageDir, inferGoPackageName } from './package-clause.js';
|
||||
|
||||
/**
|
||||
* O(n²×d) where n = files per package, d = defs per file.
|
||||
|
|
@ -27,9 +28,13 @@ export function populateGoPackageSiblings(
|
|||
// must not see each other's unqualified names.
|
||||
const packageByFile = new Map<string, string>();
|
||||
for (const parsed of nonTestFiles) {
|
||||
const pkgName = inferPackageName(ctx.fileContents.get(parsed.filePath) ?? '');
|
||||
// Same derivation as `populateGoWorkspaceOwners` — one shared resolver, so
|
||||
// the two passes cannot disagree about a file's package (#2837). The
|
||||
// no-clause case is reported there; warning twice for one fact would be
|
||||
// noise.
|
||||
const pkgName = inferGoPackageName(ctx.fileContents.get(parsed.filePath) ?? '');
|
||||
if (pkgName !== null) {
|
||||
packageByFile.set(parsed.filePath, `${packageDir(parsed.filePath)}\0${pkgName}`);
|
||||
packageByFile.set(parsed.filePath, `${goPackageDir(parsed.filePath)}\0${pkgName}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -71,17 +76,6 @@ export function populateGoPackageSiblings(
|
|||
}
|
||||
}
|
||||
|
||||
function inferPackageName(sourceText: string): string | null {
|
||||
const match = sourceText.match(/^\s*package\s+([A-Za-z_][A-Za-z0-9_]*)/m);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function packageDir(filePath: string): string {
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
const idx = normalized.lastIndexOf('/');
|
||||
return idx === -1 ? '' : normalized.slice(0, idx);
|
||||
}
|
||||
|
||||
function getAugmentationBucket(
|
||||
augmentations: Map<ScopeId, Map<string, BindingRef[]>>,
|
||||
scopeId: ScopeId,
|
||||
|
|
|
|||
|
|
@ -4,9 +4,33 @@ import Go from 'tree-sitter-go';
|
|||
const GO_SCOPE_QUERY = `
|
||||
;; Scopes
|
||||
(source_file) @scope.module
|
||||
;; One Class scope per DECLARED TYPE, not per declaration (#2837).
|
||||
;;
|
||||
;; Capturing the type_declaration made a grouped declaration
|
||||
;; type (
|
||||
;; Decoy struct { ... }
|
||||
;; PickService struct { ... }
|
||||
;; )
|
||||
;; a SINGLE Class scope owning every struct in the block. Downstream that scope
|
||||
;; can name only one owner -- buildWorkspaceResolutionIndex keeps the first
|
||||
;; class-like def it finds -- so the block's structs lost their field type
|
||||
;; bindings, typeOfMemberOnClass found no scope, the compound-receiver fold
|
||||
;; declined, and every s.field.Method() site in the file emitted nothing at
|
||||
;; all. Silent, per-file, and independent of file size: exactly the split
|
||||
;; reported in #2837 that #2829's global fixes could not explain. Measured on
|
||||
;; the go-grouped-type-decl fixture: EVERY struct in a grouped block lost its
|
||||
;; edges, the first one included, and grouped interface blocks produced no
|
||||
;; IMPLEMENTS edges at all.
|
||||
;;
|
||||
;; type_spec is the node Go's own grammar gives one declared type, so one
|
||||
;; capture per type_spec is the granularity the rest of the pipeline already
|
||||
;; assumes. A plain single-type declaration is unaffected in count -- only its
|
||||
;; scope range narrows, from the type keyword to the name.
|
||||
;;
|
||||
;; NOTE: this string is a JS template literal. Backticks are a syntax error.
|
||||
(type_declaration
|
||||
(type_spec
|
||||
type: [(struct_type) (interface_type)])) @scope.class
|
||||
type: [(struct_type) (interface_type)]) @scope.class)
|
||||
(function_declaration) @scope.function
|
||||
(method_declaration) @scope.function
|
||||
(func_literal) @scope.function
|
||||
|
|
@ -22,14 +46,24 @@ const GO_SCOPE_QUERY = `
|
|||
(communication_case) @scope.block
|
||||
|
||||
;; Declarations — struct
|
||||
;;
|
||||
;; Anchored on the type_spec, in lockstep with @scope.class above (#2837). Both
|
||||
;; captures MUST name the same node: the def node and the class-scope node are
|
||||
;; paired by range, so anchoring the def on the enclosing type_declaration while
|
||||
;; the scope sits on the type_spec leaves the def strictly larger than its own
|
||||
;; scope and NOTHING is owned -- measured as every Go field-receiver edge in the
|
||||
;; fixture disappearing, plain declarations included. Keeping both on
|
||||
;; type_declaration is the original bug: a grouped block gave both structs the
|
||||
;; same capture node, so one silently displaced the other.
|
||||
(type_declaration
|
||||
(type_spec name: (type_identifier) @declaration.name
|
||||
type: (struct_type))) @declaration.struct
|
||||
type: (struct_type)) @declaration.struct)
|
||||
|
||||
;; Declarations — interface
|
||||
;; Same lockstep requirement as @declaration.struct above (#2837).
|
||||
(type_declaration
|
||||
(type_spec name: (type_identifier) @declaration.name
|
||||
type: (interface_type))) @declaration.interface
|
||||
type: (interface_type)) @declaration.interface)
|
||||
|
||||
;; Declarations — function
|
||||
(function_declaration
|
||||
|
|
|
|||
|
|
@ -58,17 +58,23 @@ const JAVA_SCOPE_QUERY = `
|
|||
(compact_constructor_declaration) @scope.function
|
||||
|
||||
;; Declarations — types
|
||||
;; Optional-quantifier capture rather than a second pattern: a separate rule
|
||||
;; would make every GENERIC declaration match twice under one def id, leaving
|
||||
;; match order to decide which twin kept the parameters.
|
||||
(class_declaration
|
||||
name: (identifier) @declaration.name) @declaration.class
|
||||
name: (identifier) @declaration.name
|
||||
type_parameters: (type_parameters)? @declaration.type-parameters) @declaration.class
|
||||
|
||||
(interface_declaration
|
||||
name: (identifier) @declaration.name) @declaration.interface
|
||||
name: (identifier) @declaration.name
|
||||
type_parameters: (type_parameters)? @declaration.type-parameters) @declaration.interface
|
||||
|
||||
(enum_declaration
|
||||
name: (identifier) @declaration.name) @declaration.enum
|
||||
|
||||
(record_declaration
|
||||
name: (identifier) @declaration.name) @declaration.record
|
||||
name: (identifier) @declaration.name
|
||||
type_parameters: (type_parameters)? @declaration.type-parameters) @declaration.record
|
||||
|
||||
(annotation_type_declaration
|
||||
name: (identifier) @declaration.name) @declaration.class
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@
|
|||
* inferred from leading JSDoc comments. A lightweight regex scanner
|
||||
* (`parseJsDocParams` / `parseJsDocReturn`) extracts `@param {T} n`
|
||||
* and `@returns {T}` tags and emits synthetic captures positioned on
|
||||
* the annotated function node.
|
||||
* the annotated function node. `@type {T}` on a class FIELD is the same
|
||||
* story one level down — it is the only way JavaScript can declare a
|
||||
* field's type at all — and emits `@type-binding.class-field` (#2833).
|
||||
*
|
||||
* 4. **Shared synthesis passes** — destructuring, for-of map-tuple, and
|
||||
* instanceof narrowing passes are duplicated from `typescript/captures.ts`
|
||||
|
|
@ -40,6 +42,7 @@ import { computeTsArityMetadata } from '../typescript/arity-metadata.js';
|
|||
import { synthesizeTsReceiverBinding } from '../typescript/receiver-binding.js';
|
||||
import { isArrayMethodCallbackArrow } from '../typescript/array-callback.js';
|
||||
import { isStaticClassFieldBinding } from '../typescript/captures.js';
|
||||
import { reducesToContainedType } from '../typescript/interpret.js';
|
||||
|
||||
/** JavaScript's spelling of a class-field declaration — the TypeScript grammar
|
||||
* calls the same construct `public_field_definition`. Named here, not in the
|
||||
|
|
@ -372,9 +375,140 @@ function parseJsDocType(text: string): string | null {
|
|||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A type REFERENCE, possibly qualified, generic, or unioned:
|
||||
* `Repo`, `Repo<User>`, `models.Repo`, `Handler<Req, Res>`, `Repo|null`,
|
||||
* `Repo<User> | null`.
|
||||
*
|
||||
* Applied only to a string already capped by {@link JSDOC_TYPE_MAX_LENGTH}:
|
||||
* the union and generic groups both nest quantifiers, so an unbounded
|
||||
* non-matching input is a backtracking hazard, and a docblock's `{…}` payload
|
||||
* is attacker-shaped text (it is whatever the file says).
|
||||
*
|
||||
* JSDoc's `{…}` payload is free text and carries shapes that are not
|
||||
* references at all — record types (`{{a: number}}`), function types
|
||||
* (`{function(string): void}`), the any-type `{*}`, parenthesized unions
|
||||
* (`{(Repo|Other)}`). None of those name a class, so a field annotated with
|
||||
* one is DECLINED rather than bound to whatever substring survives
|
||||
* normalization. (`parseJsDocType`'s `[^}]+` also truncates a record type at
|
||||
* its first `}`, which this rejects too.)
|
||||
*/
|
||||
/** Longest `@type {…}` payload considered. A type REFERENCE that names a class
|
||||
* is far shorter; past this the string is a structural type or generated
|
||||
* noise, which this pass declines anyway, and the cap is what keeps
|
||||
* {@link JSDOC_TYPE_REFERENCE_RE}'s nested quantifiers off an unbounded
|
||||
* input. */
|
||||
const JSDOC_TYPE_MAX_LENGTH = 200;
|
||||
|
||||
const JSDOC_TYPE_REFERENCE_RE =
|
||||
/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*(?:\s*<[\w$.,<>\s]*>)?(?:\s*\|\s*[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*(?:\s*<[\w$.,<>\s]*>)?)*$/;
|
||||
|
||||
/**
|
||||
* The spelling a JSDoc `@type` should bind a class FIELD to, or `null` to
|
||||
* decline.
|
||||
*
|
||||
* The as-written spelling is returned, NOT a reduced one: `interpretJsTypeBinding`
|
||||
* carries `Repo<User>` through to `TypeRef.rawName` untouched (user generics are
|
||||
* not on `stripGeneric`'s wrapper list), and `resolveClassBindingForName` erases
|
||||
* the arguments to `Repo` at lookup time. That is the same erasure every other
|
||||
* language in #2833 relies on, so generics need no code here — verified, not
|
||||
* assumed, by the capture probe in that issue.
|
||||
*
|
||||
* Two declines:
|
||||
* - `reducesToContainedType` — the container spellings whose interpretation
|
||||
* would yield the ELEMENT (`Repo[]`, `Array<Repo>`, `Promise<Repo>`). See
|
||||
* that predicate for why a field must not take its element's type.
|
||||
* - anything that is not a type reference (see JSDOC_TYPE_REFERENCE_RE).
|
||||
*
|
||||
* The leading `?` / `!` nullability sigils are JSDoc-specific decoration with no
|
||||
* bearing on which class is named, so they are peeled first — `{?Repo}` binds
|
||||
* `Repo` exactly as `{Repo|null}` does.
|
||||
*/
|
||||
function jsDocFieldTypeSpelling(rawType: string): string | null {
|
||||
const spelling = rawType
|
||||
.trim()
|
||||
.replace(/^[?!]+/, '')
|
||||
.trim();
|
||||
if (spelling === '' || spelling.length > JSDOC_TYPE_MAX_LENGTH) return null;
|
||||
if (reducesToContainedType(spelling)) return null;
|
||||
if (!JSDOC_TYPE_REFERENCE_RE.test(spelling)) return null;
|
||||
return spelling;
|
||||
}
|
||||
|
||||
/**
|
||||
* The identifier a JSDoc `@type` may bind a `field_definition` to, or `null` if
|
||||
* this field takes no docblock binding at all (#2833).
|
||||
*
|
||||
* Two refusals, and both are cheaper to answer than the docblock search they
|
||||
* gate, which is why they run before it:
|
||||
*
|
||||
* - `static` fields are dropped, exactly as the query-driven annotation path
|
||||
* drops them in `emitJsScopeCaptures` — a static member belongs to the class
|
||||
* object and would silently RETYPE an instance field of the same name. The
|
||||
* full cost of that trade, measured, is in `isStaticClassFieldBinding`
|
||||
* (#2807). Re-checked here because the synthesis pass runs outside the
|
||||
* match loop that applies it.
|
||||
* - a name that is not a plain identifier (a computed key, a string key)
|
||||
* names nothing `this.x` could look up.
|
||||
*
|
||||
* The JavaScript grammar names a field's name `property:`, not `name:`. `#priv`
|
||||
* arrives as `private_property_identifier`; TypeScript binds those under their
|
||||
* `#`-prefixed spelling, which is how `this.#priv` looks it up.
|
||||
*/
|
||||
function jsDocBindableFieldName(node: SyntaxNode): SyntaxNode | null {
|
||||
if (isStaticClassFieldBinding(node, JS_CLASS_FIELD_DEFINITION_TYPES)) return null;
|
||||
const nameNode = node.childForFieldName('property');
|
||||
if (
|
||||
nameNode === null ||
|
||||
(nameNode.type !== 'property_identifier' && nameNode.type !== 'private_property_identifier')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return nameNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit the class-FIELD type binding a JSDoc `@type {T}` block declares (#2833).
|
||||
*
|
||||
* JavaScript has no type annotations, so a docblock is the only way a field
|
||||
* can declare one — and measured before this branch, `/** @type {Repo<User>} */
|
||||
* repo;` bound NOTHING, taking down the non-generic control (`{Plain}`) with
|
||||
* it. TypeScript's equivalent `repo: Repo<User>` has always bound, via the
|
||||
* `@type-binding.annotation` rule on `public_field_definition`; this reaches the
|
||||
* same DESTINATION from the docblock — an annotation-strength binding on the
|
||||
* enclosing Class scope, which is the only place `typeOfMemberOnClass` reads a
|
||||
* field's type — so the compound-receiver resolver finds it the way it always
|
||||
* has. No resolution-side change. See the tag note on the emit below for why
|
||||
* the marker is `class-field` rather than `annotation`.
|
||||
*/
|
||||
function emitJsDocFieldBinding(
|
||||
docComment: string,
|
||||
nameNode: SyntaxNode,
|
||||
out: CaptureMatch[],
|
||||
): void {
|
||||
const rawType = parseJsDocType(docComment);
|
||||
const spelling = rawType === null ? null : jsDocFieldTypeSpelling(rawType);
|
||||
if (spelling === null) return;
|
||||
out.push({
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', nameNode, nameNode.text),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', nameNode, spelling),
|
||||
// `class-field`, not `annotation`: this is the JS provider's own
|
||||
// marker for a binding that must be HOISTED to the enclosing Class
|
||||
// scope, which is where `typeOfMemberOnClass` reads a field's type.
|
||||
// `jsBindingScopeFor` does that walk; `interpretJsTypeBinding` then
|
||||
// remaps the tag to `annotation` so the source strength is the same
|
||||
// as TypeScript's `repo: Repo<User>`. Measured: with `annotation`
|
||||
// the binding lands on the innermost scope and the field never
|
||||
// types — the same shape `synthesizeConstructorFieldBindings` needs
|
||||
// for `this.p = new Outer()`.
|
||||
'@type-binding.class-field': syntheticCapture('@type-binding.class-field', nameNode, '1'),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the AST and synthesize `@type-binding.*` captures from JSDoc
|
||||
* comments immediately preceding function declarations / expressions.
|
||||
* comments immediately preceding function declarations / expressions and class
|
||||
* field definitions.
|
||||
*
|
||||
* Only `/** … */` block comments are scanned. Line comments (`//`) are
|
||||
* intentionally excluded — JSDoc lives in block comments.
|
||||
|
|
@ -385,10 +519,23 @@ function parseJsDocType(text: string): string | null {
|
|||
* - `@type-binding.annotation` for `@type {T}` on `let`/`const`/`var`
|
||||
* declarations — covers the common `/** @type {User} */ const u = …`
|
||||
* pattern (ECMA-262 §14.3.1/§14.3.2 variable declarations).
|
||||
* - `@type-binding.class-field` for `@type {T}` on a `field_definition`
|
||||
* (#2833) — see {@link emitJsDocFieldBinding}.
|
||||
*
|
||||
* The binding is anchored on the function node so `tsBindingScopeFor`
|
||||
* can hoist method return-type bindings to Module scope (matching the
|
||||
* TypeScript path where `hoistTypeBindingsToModule: true`).
|
||||
*
|
||||
* `field_definition` is a node kind of THIS walk rather than a pass of its own,
|
||||
* even though a field's anchor and name are the field itself while every other
|
||||
* branch keys off a function-like anchor. A separate pass would be a ninth
|
||||
* full-tree traversal of `emitJsScopeCaptures`, and measured on
|
||||
* `dist/core/ingestion/workers/parse-worker.js` (2.4k lines, 17.3k nodes) one
|
||||
* `namedChildren` walk costs 14.3 ms against 7.5 ms to PARSE the whole file —
|
||||
* `node.namedChildren` materializes a fresh array of node wrappers across the
|
||||
* N-API boundary at every node. The two node kinds share this walk's preceding-
|
||||
* comment search and nothing else, so the branch below returns as soon as it
|
||||
* has emitted.
|
||||
*/
|
||||
function synthesizeJsDocBindings(root: SyntaxNode, out: CaptureMatch[]): void {
|
||||
const stack: SyntaxNode[] = [root];
|
||||
|
|
@ -404,8 +551,15 @@ function synthesizeJsDocBindings(root: SyntaxNode, out: CaptureMatch[]): void {
|
|||
const isMethodDef = node.type === 'method_definition';
|
||||
// Also check lexical_declaration containing an arrow/fn-expression
|
||||
const isLexDecl = node.type === 'lexical_declaration' || node.type === 'variable_declaration';
|
||||
const isFieldDef = node.type === 'field_definition';
|
||||
|
||||
if (!isFnDecl && !isMethodDef && !isLexDecl) continue;
|
||||
if (!isFnDecl && !isMethodDef && !isLexDecl && !isFieldDef) continue;
|
||||
|
||||
// Non-null exactly for a field that can carry a binding, so it doubles as
|
||||
// the branch selector inside the comment search below. Answered before that
|
||||
// search because an unbindable field has no reason to look for a docblock.
|
||||
const fieldNameNode = isFieldDef ? jsDocBindableFieldName(node) : null;
|
||||
if (isFieldDef && fieldNameNode === null) continue;
|
||||
|
||||
// For `export function foo() { ... }`, the JSDoc comment precedes the
|
||||
// wrapping export_statement, not the inner function_declaration.
|
||||
|
|
@ -418,6 +572,14 @@ function synthesizeJsDocBindings(root: SyntaxNode, out: CaptureMatch[]): void {
|
|||
while (sibling !== null && sibling.type === 'comment') {
|
||||
const text = sibling.text;
|
||||
if (text.startsWith('/**')) {
|
||||
// A field's docblock declares its own type and nothing else — `@param` /
|
||||
// `@returns` on a field name no callable — so this branch does not fall
|
||||
// through to the function-like tags below.
|
||||
if (fieldNameNode !== null) {
|
||||
emitJsDocFieldBinding(text, fieldNameNode, out);
|
||||
break;
|
||||
}
|
||||
|
||||
// Found a JSDoc block.
|
||||
const params = parseJsDocParams(text);
|
||||
const retType = parseJsDocReturn(text);
|
||||
|
|
|
|||
|
|
@ -107,6 +107,15 @@ export const JAVASCRIPT_SCOPE_QUERY = `
|
|||
(field_definition
|
||||
property: (property_identifier) @declaration.name) @declaration.property
|
||||
|
||||
;; Object-literal keys of a NAMED object (A1/A5) — the scope-resolution half of
|
||||
;; the same rule in TYPESCRIPT/JAVASCRIPT_QUERIES. The parse query mints the
|
||||
;; Property NODE; this mints the DEF the resolver can point a read/write at.
|
||||
(variable_declarator
|
||||
name: (identifier)
|
||||
value: (object
|
||||
(pair
|
||||
key: (property_identifier) @declaration.name) @declaration.property))
|
||||
|
||||
;; Declarations — free functions
|
||||
(function_declaration
|
||||
name: (identifier) @declaration.name) @declaration.function
|
||||
|
|
@ -589,6 +598,99 @@ export const JAVASCRIPT_SCOPE_QUERY = `
|
|||
|
||||
(object
|
||||
(shorthand_property_identifier) @reference.name @reference.property-key @reference.value-ref)
|
||||
|
||||
;; Bare-identifier reads (A2). VALUE POSITIONS ONLY — a blanket
|
||||
;; \`(identifier)\` rule would mint a site for every token in the file.
|
||||
(arguments
|
||||
(identifier) @reference.name @reference.read.identifier)
|
||||
|
||||
(assignment_pattern
|
||||
right: (identifier) @reference.name @reference.read.identifier)
|
||||
|
||||
(return_statement
|
||||
(identifier) @reference.name @reference.read.identifier)
|
||||
|
||||
;; \`const next = LIMIT\` and \`n > LIMIT\` — both plainly value reads, and both
|
||||
;; named in review as gaps between what A2 claimed and what it matched.
|
||||
(variable_declarator
|
||||
value: (identifier) @reference.name @reference.read.identifier)
|
||||
|
||||
(binary_expression
|
||||
left: (identifier) @reference.name @reference.read.identifier)
|
||||
|
||||
(binary_expression
|
||||
right: (identifier) @reference.name @reference.read.identifier)
|
||||
|
||||
;; Destructured PARAMETER keys (R2-1c). \`function exit({ exitMinAtrMult = 0 })\`
|
||||
;; reads that property off whatever the caller passes, exactly as
|
||||
;; \`cfg.exitMinAtrMult\` would — the field just never appears in a
|
||||
;; member_expression, so the read had no site at all and the function that
|
||||
;; implements the behaviour was missing from "who reads this setting?".
|
||||
;;
|
||||
;; A distinct anchor rather than @reference.read.member: that tag is filtered
|
||||
;; emit-side to matches with a member_expression ancestor (calls and writes
|
||||
;; share its shape), and a destructuring pattern has none, so it would be
|
||||
;; dropped. The \`read.\` head is what maps this to a read kind, so the new tag
|
||||
;; needs no mapping change.
|
||||
;;
|
||||
;; The object_pattern is the receiver. It is anonymous — there is no name to
|
||||
;; type — which is precisely the untyped-receiver case the name-narrowing pass
|
||||
;; exists to serve.
|
||||
;;
|
||||
;; Scoped to formal_parameters deliberately. A destructuring binding elsewhere
|
||||
;; (\`const { x } = require('m')\`) is often an import rather than a field read,
|
||||
;; and minting a property read for it would attribute module bindings to
|
||||
;; unrelated same-named keys.
|
||||
(formal_parameters
|
||||
(object_pattern
|
||||
(shorthand_property_identifier_pattern) @reference.name
|
||||
@reference.read.destructured) @reference.receiver)
|
||||
|
||||
(formal_parameters
|
||||
(object_pattern
|
||||
(object_assignment_pattern
|
||||
left: (shorthand_property_identifier_pattern) @reference.name
|
||||
@reference.read.destructured)) @reference.receiver)
|
||||
|
||||
(formal_parameters
|
||||
(object_pattern
|
||||
(pair_pattern
|
||||
key: (property_identifier) @reference.name
|
||||
@reference.read.destructured)) @reference.receiver)
|
||||
|
||||
;; Object-literal keys in RECORD CONSTRUCTION position (R2-1b). Building
|
||||
;; \`{ exitContract: { exitMinAtrMult: settings.x } }\` SETS that field, so this
|
||||
;; is the write counterpart to the destructured read above — without it
|
||||
;; "who reads this setting?" answers well and "who SETS it?" misses the code
|
||||
;; that stamps the value.
|
||||
;;
|
||||
;; A WRITE REFERENCE, deliberately not a definition. The round-1 rule already
|
||||
;; mints Property nodes for literals bound to a variable; minting more for
|
||||
;; anonymous records would add same-named competitors to the very name-narrowing
|
||||
;; that makes these reads resolvable — measured at 26 competing definitions for
|
||||
;; one field on the reporting repo. A construction site is a USE of a field, not
|
||||
;; another declaration of it.
|
||||
;;
|
||||
;; Two positions only: nested under a key, and returned. Both are records with a
|
||||
;; name attached (the key, or the function). An inline call argument
|
||||
;; (\`doThing({ id: 1 })\`) stays excluded for the same reason round 1 excluded
|
||||
;; it from definitions — it is call-site data, not a named surface.
|
||||
;;
|
||||
;; The enclosing literal is the receiver, and it is anonymous, which routes
|
||||
;; these through the same narrowing and the same refusal-to-guess as every other
|
||||
;; untyped receiver.
|
||||
(pair
|
||||
value: (object
|
||||
(pair
|
||||
key: (property_identifier) @reference.name
|
||||
@reference.write.property-key) @_r2b.nested) @reference.receiver)
|
||||
|
||||
(return_statement
|
||||
(object
|
||||
(pair
|
||||
key: (property_identifier) @reference.name
|
||||
@reference.write.property-key) @_r2b.returned) @reference.receiver)
|
||||
|
||||
`;
|
||||
|
||||
/** JSX-only suffix — appended when compiling against the JSX grammar for .jsx files. */
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
|
||||
import { KOTLIN_EXTENSIONS } from '../../import-resolvers/jvm.js';
|
||||
import { recordKotlinFileIndexBuild } from './index-stats.js';
|
||||
|
||||
export interface KotlinResolveContext {
|
||||
readonly fromFile: string;
|
||||
|
|
@ -38,20 +40,27 @@ export function resolveKotlinImportTarget(
|
|||
// export the imported name (#1759).
|
||||
// 4. Progressive prefix strip for deeper namespace aliases that
|
||||
// don't map 1:1 to directories.
|
||||
const stripped = pathLike.split('/').slice(0, -1).join('/');
|
||||
const index = getKotlinFileIndex(ctx.allFilePaths);
|
||||
const direct = findKotlinFile(index, pathLike);
|
||||
if (direct !== null) return direct;
|
||||
|
||||
// Only tiers 2 and 3 need the stripped path, and tier 1 answers most
|
||||
// imports, so it is computed here rather than above. `lastIndexOf`/`slice`
|
||||
// rather than `split`/`slice`/`join`: same result for every input, two
|
||||
// allocations fewer per import. The `li < 0` guard is load-bearing —
|
||||
// `'a'.slice(0, -1)` is `''`, which is what the split form yields for a
|
||||
// single-segment path, but only by accident of `[].join('/')`.
|
||||
const li = pathLike.lastIndexOf('/');
|
||||
const stripped = li < 0 ? '' : pathLike.slice(0, li);
|
||||
return (
|
||||
findKotlinFile(ctx.allFilePaths, pathLike) ??
|
||||
findKotlinExactOrSuffix(ctx.allFilePaths, stripped) ??
|
||||
findKotlinPackageFiles(ctx.allFilePaths, stripped) ??
|
||||
findByProgressivePrefixStrip(ctx.allFilePaths, pathLike)
|
||||
findKotlinExactOrSuffix(index, stripped) ??
|
||||
findKotlinPackageFiles(index, stripped) ??
|
||||
findByProgressivePrefixStrip(index, pathLike)
|
||||
);
|
||||
}
|
||||
|
||||
function findKotlinFile(allFilePaths: ReadonlySet<string>, pathLike: string): string | null {
|
||||
return (
|
||||
findKotlinExactOrSuffix(allFilePaths, pathLike) ??
|
||||
findKotlinDirectoryChild(allFilePaths, pathLike)
|
||||
);
|
||||
function findKotlinFile(index: KotlinFileIndex, pathLike: string): string | null {
|
||||
return findKotlinExactOrSuffix(index, pathLike) ?? findKotlinDirectoryChild(index, pathLike);
|
||||
}
|
||||
|
||||
/** Exact (`file === pathLike+ext`) or suffix (`file ends with /pathLike+ext`)
|
||||
|
|
@ -59,26 +68,15 @@ function findKotlinFile(allFilePaths: ReadonlySet<string>, pathLike: string): st
|
|||
* `pathLike/` directory. Used by the stripped-path tier in
|
||||
* `resolveKotlinImportTarget` so a package import like `models.getRepo`
|
||||
* delegates to `findKotlinPackageFiles` (multi-file fan-out) instead of
|
||||
* silently committing to the first directory child. */
|
||||
function findKotlinExactOrSuffix(
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
pathLike: string,
|
||||
): string | null {
|
||||
* silently committing to the first directory child.
|
||||
*
|
||||
* An exact match anywhere in the workspace beats a suffix match anywhere,
|
||||
* which is why the two are separate maps rather than one lookup: the old scan
|
||||
* returned on the first exact hit but only remembered the first suffix hit,
|
||||
* so an exact match found late still won. */
|
||||
function findKotlinExactOrSuffix(index: KotlinFileIndex, pathLike: string): string | null {
|
||||
if (pathLike === '') return null;
|
||||
const extensions = ['.kt', '.kts'];
|
||||
const suffix = `/${pathLike}`;
|
||||
let suffixFile: string | null = null;
|
||||
|
||||
for (const raw of allFilePaths) {
|
||||
const file = raw.replace(/\\/g, '/');
|
||||
if (!extensions.some((ext) => file.endsWith(ext))) continue;
|
||||
for (const ext of extensions) {
|
||||
if (file === `${pathLike}${ext}`) return raw;
|
||||
if (suffixFile === null && file.endsWith(`${suffix}${ext}`)) suffixFile = raw;
|
||||
}
|
||||
}
|
||||
|
||||
return suffixFile;
|
||||
return index.exactByStem.get(pathLike) ?? index.suffixByStem.get(pathLike) ?? null;
|
||||
}
|
||||
|
||||
/** First directory child of `pathLike/` — preserves the legacy single-
|
||||
|
|
@ -86,27 +84,13 @@ function findKotlinExactOrSuffix(
|
|||
* package reference (rare in real Kotlin code; some fixtures rely on
|
||||
* it). Multi-file package fan-out goes through
|
||||
* `findKotlinPackageFiles` instead. */
|
||||
function findKotlinDirectoryChild(
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
pathLike: string,
|
||||
): string | null {
|
||||
function findKotlinDirectoryChild(index: KotlinFileIndex, pathLike: string): string | null {
|
||||
if (pathLike === '') return null;
|
||||
const extensions = ['.kt', '.kts'];
|
||||
const dirPrefix = `${pathLike}/`;
|
||||
const suffixDirPrefix = `/${dirPrefix}`;
|
||||
|
||||
for (const raw of allFilePaths) {
|
||||
const file = raw.replace(/\\/g, '/');
|
||||
if (!extensions.some((ext) => file.endsWith(ext))) continue;
|
||||
const atRoot = file.startsWith(dirPrefix);
|
||||
const atNested = file.includes(suffixDirPrefix);
|
||||
if (!atRoot && !atNested) continue;
|
||||
const idx = atRoot ? 0 : file.indexOf(suffixDirPrefix) + 1;
|
||||
const after = file.slice(idx + dirPrefix.length);
|
||||
if (after.length > 0 && !after.includes('/')) return raw;
|
||||
}
|
||||
|
||||
return null;
|
||||
const children = index.dirChildren.get(pathLike);
|
||||
// "First" is first in `allFilePaths` iteration order, which the index
|
||||
// preserves by appending as it walks the set — the same file the scan
|
||||
// used to return.
|
||||
return children === undefined ? null : (children[0] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -118,41 +102,170 @@ function findKotlinDirectoryChild(
|
|||
* candidate and picks the one whose `localDefs` actually export the
|
||||
* imported name (#1759).
|
||||
*/
|
||||
function findKotlinPackageFiles(
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
dirPath: string,
|
||||
): readonly string[] | null {
|
||||
function findKotlinPackageFiles(index: KotlinFileIndex, dirPath: string): readonly string[] | null {
|
||||
if (dirPath === '') return null;
|
||||
const extensions = ['.kt', '.kts'];
|
||||
const dirPrefix = `${dirPath}/`;
|
||||
const suffixDirPrefix = `/${dirPrefix}`;
|
||||
const out: string[] = [];
|
||||
|
||||
for (const raw of allFilePaths) {
|
||||
const file = raw.replace(/\\/g, '/');
|
||||
if (!extensions.some((ext) => file.endsWith(ext))) continue;
|
||||
const atRoot = file.startsWith(dirPrefix);
|
||||
const atNested = file.includes(suffixDirPrefix);
|
||||
if (!atRoot && !atNested) continue;
|
||||
const idx = atRoot ? 0 : file.indexOf(suffixDirPrefix) + 1;
|
||||
const after = file.slice(idx + dirPrefix.length);
|
||||
// Direct children only — `models/sub/Util.kt` is a different package
|
||||
// (`models.sub`) and must not be merged with `models`.
|
||||
if (after.length === 0 || after.includes('/')) continue;
|
||||
out.push(raw);
|
||||
}
|
||||
|
||||
return out.length === 0 ? null : out;
|
||||
return index.dirChildren.get(dirPath) ?? null;
|
||||
}
|
||||
|
||||
function findByProgressivePrefixStrip(
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
pathLike: string,
|
||||
): string | null {
|
||||
function findByProgressivePrefixStrip(index: KotlinFileIndex, pathLike: string): string | null {
|
||||
const segments = pathLike.split('/').filter(Boolean);
|
||||
for (let skip = 1; skip < segments.length; skip++) {
|
||||
const found = findKotlinFile(allFilePaths, segments.slice(skip).join('/'));
|
||||
const found = findKotlinFile(index, segments.slice(skip).join('/'));
|
||||
if (found !== null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-file-set lookup tables for Kotlin import resolution, memoized on the
|
||||
* `allFilePaths` Set object (the same Set is passed for every import in a run,
|
||||
* so the index is built once and reused).
|
||||
*
|
||||
* WHY: every tier of `resolveKotlinImportTarget` used to walk the whole
|
||||
* workspace — `for (const raw of allFilePaths)` with a `replace(/\\/g, '/')`
|
||||
* and several string scans per entry — and the tiers are tried in cascade, so a
|
||||
* single unresolved import cost two to four full passes. Across a repository
|
||||
* with tens of thousands of Kotlin files that is `O(imports × files)` — on the
|
||||
* order of 10^10 string operations on one thread, which presents as `analyze`
|
||||
* sitting at exactly 1.00 core with a flat heap and no output for hours (every
|
||||
* allocation is a short-lived string, so nothing accumulates to hint at
|
||||
* progress). Small repositories hide it completely: at a few hundred files each
|
||||
* pass is free.
|
||||
*
|
||||
* The maps below make each tier O(1), so resolution cost becomes O(files) once
|
||||
* plus O(1) per import.
|
||||
*
|
||||
* - `exactByStem`: path minus its `.kt`/`.kts` extension -> raw path, for the
|
||||
* `file === pathLike+ext` tier.
|
||||
* - `suffixByStem`: every component-suffix of that stem -> raw path, for the
|
||||
* `file ends with /pathLike+ext` tier. Keyed per suffix rather than per
|
||||
* basename so a multi-segment import (`util/OneArg`) hits one bucket instead
|
||||
* of filtering a basename bucket. The basename-bucket form Python uses was
|
||||
* built and measured against this one during review: byte-identical output,
|
||||
* ~66% less memory, and 7.3x slower per query on a repeated-basename corpus
|
||||
* — enough to fail this resolver's own scaling budget at ~2.0. The memory
|
||||
* the per-suffix keying costs is small in absolute terms (~60 MiB at 100k
|
||||
* Kotlin files at depth 8), so it is not a trade worth revisiting.
|
||||
* - `dirChildren`: package directory -> its direct `.kt`/`.kts` children, in
|
||||
* set-iteration order, serving both the fan-out tier and the
|
||||
* first-child fallback.
|
||||
*
|
||||
* Both stem maps keep the FIRST path inserted for a key, because the scans they
|
||||
* replace returned the first match in set-iteration order.
|
||||
*
|
||||
* The shared `buildSuffixIndex` (`import-resolvers/utils.ts`, used by C#, Ruby,
|
||||
* Vue and TypeScript) is deliberately NOT reused — the same call Python
|
||||
* documents at `python/import-target.ts`. Run side by side against this
|
||||
* resolver, four probes out of five diverge:
|
||||
*
|
||||
* - `['deep/util/User.kt', 'util/User.kt']` for `util.User` — it conflates
|
||||
* exact and proper-suffix matches in one map, so the deep path wins where
|
||||
* the scan returned the exact one;
|
||||
* - `['deep/util/User.kt', 'util/User.kts']` for `util.User` — its keys carry
|
||||
* the extension, so a `.kt` SUFFIX beats a `.kts` EXACT;
|
||||
* - `['data/src/…/data/Repo.kt']` for `data.getRepo` — it indexes every
|
||||
* directory suffix with no first-occurrence rule, so it fans out where the
|
||||
* scan returned null;
|
||||
* - `['models/A.kts', 'models/B.kt']` for `models.getThing` — it splits the
|
||||
* package into `:kt` and `:kts` buckets instead of returning both in set
|
||||
* order.
|
||||
*
|
||||
* Each divergence is an edge that would move in every Kotlin repository, so
|
||||
* consolidating the two is a behaviour change, not a cleanup.
|
||||
*/
|
||||
interface KotlinFileIndex {
|
||||
readonly exactByStem: Map<string, string>;
|
||||
readonly suffixByStem: Map<string, string>;
|
||||
/** Buckets are frozen once the build loop finishes — see `getKotlinFileIndex`. */
|
||||
readonly dirChildren: Map<string, readonly string[]>;
|
||||
}
|
||||
|
||||
const KOTLIN_FILE_INDEX_CACHE = new WeakMap<ReadonlySet<string>, KotlinFileIndex>();
|
||||
|
||||
function getKotlinFileIndex(allFilePaths: ReadonlySet<string>): KotlinFileIndex {
|
||||
const cached = KOTLIN_FILE_INDEX_CACHE.get(allFilePaths);
|
||||
if (cached !== undefined) return cached;
|
||||
// Cache miss: materialize a fresh index. Counted so a test can assert this
|
||||
// happens once per run, not once per import.
|
||||
recordKotlinFileIndexBuild();
|
||||
|
||||
const exactByStem = new Map<string, string>();
|
||||
const suffixByStem = new Map<string, string>();
|
||||
const dirChildren: MutableDirChildren = new Map();
|
||||
|
||||
for (const raw of allFilePaths) {
|
||||
const norm = raw.replace(/\\/g, '/');
|
||||
const ext = KOTLIN_EXTENSIONS.find((e) => norm.endsWith(e));
|
||||
// Kotlin resolution only ever queries `.kt`/`.kts` paths, exactly as the
|
||||
// scans did before skipping everything else first.
|
||||
if (ext === undefined) continue;
|
||||
|
||||
const stem = norm.slice(0, norm.length - ext.length);
|
||||
if (!exactByStem.has(stem)) exactByStem.set(stem, raw);
|
||||
// Component-suffixes of the stem: one per '/' in it. `a/b/User` yields
|
||||
// `b/User` and `User`, matching `norm.endsWith('/' + key + ext)`.
|
||||
for (let i = 0; i < stem.length; i++) {
|
||||
if (stem[i] !== '/') continue;
|
||||
const suffix = stem.slice(i + 1);
|
||||
if (!suffixByStem.has(suffix)) suffixByStem.set(suffix, raw);
|
||||
}
|
||||
|
||||
const lastSlash = norm.lastIndexOf('/');
|
||||
if (lastSlash < 0) continue; // repo-root file has no package directory
|
||||
const dir = norm.slice(0, lastSlash);
|
||||
|
||||
// The file's own directory always qualifies: the old scan's `atRoot` branch
|
||||
// matched `norm.startsWith(dir + '/')` and found no '/' after it.
|
||||
addChild(dirChildren, dir, raw);
|
||||
|
||||
// A component-suffix of the directory also qualifies — but only under the
|
||||
// rule the scan actually implemented, which is narrower than "the parent
|
||||
// directory is named `s`":
|
||||
//
|
||||
// - `atRoot` was tested FIRST, so if the path *starts* with `s + '/'` the
|
||||
// scan used index 0 and the remainder still contained '/', i.e. no
|
||||
// match — even when a later directory is also named `s`.
|
||||
// - otherwise it used `indexOf`, the FIRST occurrence of `/s/`. A path
|
||||
// like `data/src/main/kotlin/com/example/data/Repo.kt` therefore does
|
||||
// NOT count as a child of `data`: the first `/data/` is not the parent,
|
||||
// and the scan never looked for a second one.
|
||||
//
|
||||
// Preserving that exactly keeps this a pure performance change. It is
|
||||
// arguably a bug — the file IS a direct child of a `data` directory — but
|
||||
// fixing it here would silently move edges in every Kotlin repository,
|
||||
// which belongs in its own change with its own fixtures.
|
||||
for (let i = 0; i < dir.length; i++) {
|
||||
if (dir[i] !== '/') continue;
|
||||
const suffix = dir.slice(i + 1);
|
||||
if (norm.startsWith(`${suffix}/`)) continue;
|
||||
if (norm.indexOf(`/${suffix}/`) === dir.length - suffix.length - 1) {
|
||||
addChild(dirChildren, suffix, raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `findKotlinPackageFiles` hands a bucket straight out of the index — the
|
||||
// same array `findKotlinDirectoryChild` reads `children[0]` from. The
|
||||
// `readonly string[]` return type does not survive the caller: the finalize
|
||||
// pass normalizes with `Array.isArray(t) ? t : [t]`, and `isArray`'s
|
||||
// `arg is any[]` predicate widens the true branch, so `tsc --strict` accepts
|
||||
// a `.sort()` or `.push()` there. A downstream sort would permanently
|
||||
// reorder the cached bucket and flip the FIRST-child tier's answer for every
|
||||
// later import in the run. Freezing makes the contract true at runtime, so a
|
||||
// future mutation is a loud TypeError instead of a silent edge move.
|
||||
for (const bucket of dirChildren.values()) Object.freeze(bucket);
|
||||
|
||||
const index: KotlinFileIndex = { exactByStem, suffixByStem, dirChildren };
|
||||
KOTLIN_FILE_INDEX_CACHE.set(allFilePaths, index);
|
||||
return index;
|
||||
}
|
||||
|
||||
function addChild(dirChildren: Map<string, string[]>, dir: string, raw: string): void {
|
||||
const bucket = dirChildren.get(dir);
|
||||
if (bucket === undefined) dirChildren.set(dir, [raw]);
|
||||
else bucket.push(raw);
|
||||
}
|
||||
|
||||
/** Mutable view of the buckets, used only while building — the index exposes
|
||||
* them as `readonly` and freezes them before it is cached. */
|
||||
type MutableDirChildren = Map<string, string[]>;
|
||||
|
|
|
|||
29
gitnexus/src/core/ingestion/languages/kotlin/index-stats.ts
Normal file
29
gitnexus/src/core/ingestion/languages/kotlin/index-stats.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* Build counter for the per-file-set Kotlin import-resolution index
|
||||
* (`getKotlinFileIndex` in `import-target.ts`).
|
||||
*
|
||||
* A "build" is a `WeakMap` cache MISS that materializes a fresh
|
||||
* `KotlinFileIndex` (O(files)). Mirrors `../python/index-stats.ts`: the counter
|
||||
* is always live rather than gated behind a profiling env var, because an index
|
||||
* build happens at most once per resolution run, so the single increment is
|
||||
* negligible and an unconditional counter avoids env-var load-order fragility
|
||||
* in tests.
|
||||
*
|
||||
* Used by `test/integration/kotlin-import-index-reuse.test.ts` to assert the
|
||||
* index is reused across imports (built once per run) rather than rebuilt per
|
||||
* import — the regression guard for the quadratic resolution this replaced.
|
||||
*/
|
||||
|
||||
let INDEX_BUILDS = 0;
|
||||
|
||||
export function recordKotlinFileIndexBuild(): void {
|
||||
INDEX_BUILDS++;
|
||||
}
|
||||
|
||||
export function getKotlinFileIndexBuildCount(): number {
|
||||
return INDEX_BUILDS;
|
||||
}
|
||||
|
||||
export function resetKotlinFileIndexBuildCount(): void {
|
||||
INDEX_BUILDS = 0;
|
||||
}
|
||||
|
|
@ -81,13 +81,22 @@ const KOTLIN_SCOPE_QUERY = `
|
|||
(lambda_literal) @scope.block
|
||||
|
||||
;; Declarations — types
|
||||
;; The Kotlin grammar puts NO named fields on \`class_declaration\`, so the
|
||||
;; parameter list is matched positionally as an optional unnamed child, exactly
|
||||
;; as the name already is.
|
||||
;;
|
||||
;; Only the INLINE bound (\`<T : Repo>\`) is read. A \`where T : Repo\` clause is a
|
||||
;; separate \`type_constraints\` sibling and is left alone, so its bound reads as
|
||||
;; absent — "unknown", not "unbounded".
|
||||
(class_declaration
|
||||
"interface"
|
||||
(type_identifier) @declaration.name) @declaration.interface
|
||||
(type_identifier) @declaration.name
|
||||
(type_parameters)? @declaration.type-parameters) @declaration.interface
|
||||
|
||||
(class_declaration
|
||||
"class"
|
||||
(type_identifier) @declaration.name) @declaration.class
|
||||
(type_identifier) @declaration.name
|
||||
(type_parameters)? @declaration.type-parameters) @declaration.class
|
||||
|
||||
(object_declaration
|
||||
(type_identifier) @declaration.name) @declaration.class
|
||||
|
|
|
|||
|
|
@ -28,6 +28,11 @@
|
|||
* a `@type-binding.alias` match binding the loop variable to the
|
||||
* element type of the iterable (resolved from PHPDoc or scopeEnv).
|
||||
*
|
||||
* 6. **PHPDoc `@var` property synthesis** — a docblock on an UNTYPED
|
||||
* property emits the `@type-binding.annotation` + `@declaration.property`
|
||||
* pair the native typed-property rules emit, which is the only way PHP
|
||||
* can declare a generic field type (#2833).
|
||||
*
|
||||
* Pure given the input source text. No I/O, no globals consulted.
|
||||
*/
|
||||
|
||||
|
|
@ -136,6 +141,17 @@ export function emitPhpScopeCaptures(
|
|||
}
|
||||
}
|
||||
|
||||
// The one full-tree walk: class/trait heritage, and PHPDoc `@var` on an
|
||||
// untyped property. Run BEFORE the match loop rather than appended after it,
|
||||
// because the property declarations the `@var` half claims must join
|
||||
// `typedPropertyAnchorIds`: it emits the same `@declaration.property` the
|
||||
// typed rule does, so without this the loose `@declaration.variable`
|
||||
// catch-all would declare the very same node a second time under its
|
||||
// `$`-sigilled name — exactly the duplicate the set above exists to suppress.
|
||||
// Its matches are still appended in the original order after the loop.
|
||||
const walked = synthesizePhpTreeWalkCaptures(tree.rootNode);
|
||||
for (const id of walked.docPropertyAnchorIds) typedPropertyAnchorIds.add(id);
|
||||
|
||||
for (const m of rawMatches) {
|
||||
// Group captures by their tag name. Tree-sitter strips the leading
|
||||
// `@`; we put it back so the central extractor's prefix lookups work.
|
||||
|
|
@ -360,21 +376,55 @@ export function emitPhpScopeCaptures(
|
|||
out.push(grouped);
|
||||
}
|
||||
|
||||
out.push(...synthesizePhpInheritanceReferences(tree.rootNode));
|
||||
out.push(...walked.inheritance);
|
||||
out.push(...walked.docProperties);
|
||||
out.push(...synthesizeCallableFlowCaptures(tree.rootNode, PHP_CALLABLE_CAPTURE_OPTIONS));
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── PHP inheritance synthesis ───────────────────────────────────────────────
|
||||
// ─── PHP whole-tree synthesis ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Synthesize `@reference.inherits` captures from PHP class/trait heritage so
|
||||
* the registry-primary scope-resolution path emits EXTENDS / IMPLEMENTS edges
|
||||
* (mirrors C# `synthesizeCsharpInheritanceReferences` / C++
|
||||
* `emitCppInheritanceCaptures`). Without this, PHP inheritance edges came only
|
||||
* from the legacy heritage-capture leg (removed in #942), which the worker
|
||||
* pipeline drops for registry-primary languages (issue #1951).
|
||||
* The single `walkNamedTree` pass of `emitPhpScopeCaptures`, dispatching every
|
||||
* synthesis that needs to see the whole tree.
|
||||
*
|
||||
* ONE walk, not one per synthesis. A tree-sitter node walk is not cheap next to
|
||||
* the work it feeds: measured on a 1.2k-line PHP source (9.6k nodes), a single
|
||||
* `walkNamedTree` pass costs 7.4 ms against 2.1 ms to PARSE the file, because
|
||||
* every step materializes node wrappers across the N-API boundary. So a new
|
||||
* node kind is a branch here rather than a pass of its own — the two below emit
|
||||
* into separate arrays, and `emitPhpScopeCaptures` appends them in the order
|
||||
* they were appended when they were two passes.
|
||||
*
|
||||
* The `@reference.inherits` half exists so the registry-primary
|
||||
* scope-resolution path emits EXTENDS / IMPLEMENTS edges (mirrors C#
|
||||
* `synthesizeCsharpInheritanceReferences` / C++ `emitCppInheritanceCaptures`).
|
||||
* Without it, PHP inheritance edges came only from the legacy heritage-capture
|
||||
* leg (removed in #942), which the worker pipeline drops for registry-primary
|
||||
* languages (issue #1951). See {@link emitPhpDocPropertyBinding} for the other.
|
||||
*/
|
||||
function synthesizePhpTreeWalkCaptures(root: SyntaxNode): {
|
||||
readonly inheritance: readonly CaptureMatch[];
|
||||
readonly docProperties: readonly CaptureMatch[];
|
||||
readonly docPropertyAnchorIds: ReadonlySet<number>;
|
||||
} {
|
||||
const inheritance: CaptureMatch[] = [];
|
||||
const docProperties: CaptureMatch[] = [];
|
||||
const docPropertyAnchorIds = new Set<number>();
|
||||
walkNamedTree(root, (node) => {
|
||||
if (node.type === 'class_declaration' || node.type === 'trait_declaration') {
|
||||
emitPhpHeritageReferences(node, inheritance);
|
||||
} else if (node.type === 'property_declaration') {
|
||||
emitPhpDocPropertyBinding(node, docProperties, docPropertyAnchorIds);
|
||||
}
|
||||
});
|
||||
return { inheritance, docProperties, docPropertyAnchorIds };
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit `@reference.inherits` for the heritage of one `class_declaration` or
|
||||
* `trait_declaration`.
|
||||
*
|
||||
* Scope matches the legacy PHP heritage query (tree-sitter-queries.ts
|
||||
* PHP_QUERIES extends / implements / trait-use captures):
|
||||
|
|
@ -396,24 +446,18 @@ export function emitPhpScopeCaptures(
|
|||
* || type === 'Trait' ? 'IMPLEMENTS' : 'EXTENDS'`), so `use Trait` resolves to
|
||||
* IMPLEMENTS on both the legacy and registry-primary paths.
|
||||
*/
|
||||
function synthesizePhpInheritanceReferences(root: SyntaxNode): CaptureMatch[] {
|
||||
const out: CaptureMatch[] = [];
|
||||
walkNamedTree(root, (node) => {
|
||||
if (node.type === 'class_declaration') {
|
||||
// extends: single base_clause child carrying one base name.
|
||||
const baseClause = findNamedChild(node, 'base_clause');
|
||||
if (baseClause !== null) emitPhpBaseNames(baseClause, out);
|
||||
// implements: class_interface_clause may list several interfaces.
|
||||
const ifaceClause = findNamedChild(node, 'class_interface_clause');
|
||||
if (ifaceClause !== null) emitPhpBaseNames(ifaceClause, out);
|
||||
// trait use: `use TraitName;` inside the class body.
|
||||
emitPhpTraitUses(node, out);
|
||||
} else if (node.type === 'trait_declaration') {
|
||||
// trait-uses-trait: `use OtherTrait;` inside a trait body.
|
||||
emitPhpTraitUses(node, out);
|
||||
}
|
||||
});
|
||||
return out;
|
||||
function emitPhpHeritageReferences(node: SyntaxNode, out: CaptureMatch[]): void {
|
||||
if (node.type === 'class_declaration') {
|
||||
// extends: single base_clause child carrying one base name.
|
||||
const baseClause = findNamedChild(node, 'base_clause');
|
||||
if (baseClause !== null) emitPhpBaseNames(baseClause, out);
|
||||
// implements: class_interface_clause may list several interfaces.
|
||||
const ifaceClause = findNamedChild(node, 'class_interface_clause');
|
||||
if (ifaceClause !== null) emitPhpBaseNames(ifaceClause, out);
|
||||
}
|
||||
// trait use: `use TraitName;` inside the class body, and trait-uses-trait:
|
||||
// `use OtherTrait;` inside a trait body.
|
||||
emitPhpTraitUses(node, out);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -648,21 +692,55 @@ const PHP_PRIMITIVES = new Set([
|
|||
]);
|
||||
|
||||
/**
|
||||
* Collect comment text from siblings immediately before `fnNode`.
|
||||
* Skips PHP 8+ attribute_list nodes.
|
||||
* The comment siblings immediately preceding `node`, in SOURCE order (the
|
||||
* nearest comment last), stopping at the first named sibling that is not a
|
||||
* comment or a PHP 8+ attribute.
|
||||
*
|
||||
* The single implementation of that chain walk. Every PHPDoc reader in this
|
||||
* file wants the same siblings under the same stop rule — `@param`/`@return` on
|
||||
* a method, `@var` for a foreach element type, `@var` for a field type — and
|
||||
* three hand-copied walks meant a fix to the stop rule (attributes between the
|
||||
* docblock and the declaration, say) could land on one reader and not the
|
||||
* others, which shows up as a field typed differently from its own foreach
|
||||
* element type.
|
||||
*/
|
||||
function collectPrecedingComments(fnNode: SyntaxNode): string {
|
||||
const texts: string[] = [];
|
||||
let sibling = fnNode.previousSibling;
|
||||
function precedingCommentSiblings(node: SyntaxNode): SyntaxNode[] {
|
||||
const comments: SyntaxNode[] = [];
|
||||
let sibling = node.previousSibling;
|
||||
while (sibling !== null) {
|
||||
if (sibling.type === 'comment') {
|
||||
texts.unshift(sibling.text);
|
||||
comments.unshift(sibling);
|
||||
} else if (sibling.isNamed && !SKIP_SIBLING_TYPES.has(sibling.type)) {
|
||||
break;
|
||||
}
|
||||
sibling = sibling.previousSibling;
|
||||
}
|
||||
return texts.join('\n');
|
||||
return comments;
|
||||
}
|
||||
|
||||
/**
|
||||
* First match of `re` over {@link precedingCommentSiblings}, searched from the
|
||||
* NEAREST comment outward — a docblock written directly above the declaration
|
||||
* wins over one further up, and an earlier comment is still reached when the
|
||||
* nearest one carries no such tag.
|
||||
*/
|
||||
function nearestPrecedingCommentMatch(node: SyntaxNode, re: RegExp): RegExpExecArray | null {
|
||||
const comments = precedingCommentSiblings(node);
|
||||
for (let i = comments.length - 1; i >= 0; i--) {
|
||||
const m = re.exec(comments[i].text);
|
||||
if (m !== null) return m;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect comment text from siblings immediately before `fnNode`.
|
||||
* Skips PHP 8+ attribute_list nodes.
|
||||
*/
|
||||
function collectPrecedingComments(fnNode: SyntaxNode): string {
|
||||
return precedingCommentSiblings(fnNode)
|
||||
.map((comment) => comment.text)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -962,8 +1040,20 @@ function findClassPropertyElementType(
|
|||
return null;
|
||||
}
|
||||
|
||||
/** Regex for PHPDoc @var: `@var Type` */
|
||||
const PHPDOC_VAR_RE = /@var\s+(\S+)/;
|
||||
/**
|
||||
* PHPDoc `@var`, with the optional variable name PHPStan/Psalm allow
|
||||
* (`@var Repo<User> $repo`). `\S+` for the type deliberately: a docblock type is
|
||||
* untyped text and everything past the first space is prose.
|
||||
*
|
||||
* ONE regex for both readings of the tag. The FIELD type
|
||||
* ({@link synthesizePhpDocPropertyBindings}) needs group 2 to tell `@var Repo
|
||||
* $other` from `@var Repo`; the foreach ELEMENT type
|
||||
* ({@link extractPropertyElementType}) ignores it — and since the trailing group
|
||||
* is optional it can never change what group 1 captures, so a second, narrower
|
||||
* copy bought nothing but the chance of the two readings of one annotation
|
||||
* drifting apart.
|
||||
*/
|
||||
const PHPDOC_VAR_RE = /@var\s+(\S+)(?:\s+\$(\w+))?/;
|
||||
|
||||
/**
|
||||
* Extract element type from a property_declaration node:
|
||||
|
|
@ -971,17 +1061,11 @@ const PHPDOC_VAR_RE = /@var\s+(\S+)/;
|
|||
* 2. PHP 7.4+ native type field (non-array)
|
||||
*/
|
||||
function extractPropertyElementType(propDecl: SyntaxNode): string | null {
|
||||
// Strategy 1: PHPDoc @var on a preceding comment sibling
|
||||
let sibling = propDecl.previousSibling;
|
||||
while (sibling !== null) {
|
||||
if (sibling.type === 'comment') {
|
||||
const m = PHPDOC_VAR_RE.exec(sibling.text);
|
||||
if (m !== null) return normalizePhpDocType(m[1]);
|
||||
} else if (sibling.isNamed && !SKIP_SIBLING_TYPES.has(sibling.type)) {
|
||||
break;
|
||||
}
|
||||
sibling = sibling.previousSibling;
|
||||
}
|
||||
// Strategy 1: PHPDoc @var on a preceding comment sibling. The `$name` group
|
||||
// is not consulted: an element type is asked for by the ONE foreach that
|
||||
// already named this property, so a mismatched name cannot mis-attribute it.
|
||||
const varTag = nearestPrecedingCommentMatch(propDecl, PHPDOC_VAR_RE);
|
||||
if (varTag !== null) return normalizePhpDocType(varTag[1]);
|
||||
// Strategy 2: native type field — skip generic 'array'
|
||||
const typeNode = propDecl.childForFieldName('type');
|
||||
if (typeNode === null) return null;
|
||||
|
|
@ -989,3 +1073,184 @@ function extractPropertyElementType(propDecl: SyntaxNode): string | null {
|
|||
if (typeName === 'array' || typeName === '') return null;
|
||||
return normalizePhpDocType(typeName);
|
||||
}
|
||||
|
||||
// ─── PHPDoc @var property synthesis ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Container spellings that base-name erasure would turn into a PHANTOM class.
|
||||
*
|
||||
* Erasing `list<User>` to `list` names nothing — PHP has no `list` type — so the
|
||||
* binding could only ever bind a user class that happens to be called `list`,
|
||||
* i.e. exactly the wrong-edge direction. Every OTHER PHPDoc container erases to
|
||||
* a name `normalizePhpType` already rejects as a primitive (`array<int,User>` →
|
||||
* `array`, `iterable<User>` → `iterable`) or to a real class whose methods are
|
||||
* what the field's receiver actually calls (`Collection<User>` → `Collection`,
|
||||
* `Generator<User>` → `Generator`), so this set holds one entry, not a
|
||||
* catalogue.
|
||||
*
|
||||
* Compared CASE-FOLDED, not by listing spellings: a deny-set that must be kept
|
||||
* in sync by vigilance drifts (#2833, the same lesson python/interpret.ts
|
||||
* records for its own reduction).
|
||||
*/
|
||||
const PHPDOC_PHANTOM_CONTAINER_BASES: ReadonlySet<string> = new Set(['list']);
|
||||
|
||||
/**
|
||||
* Erase type ARGUMENTS from a docblock type, leaving the base name:
|
||||
* `Repo<User>` → `Repo`, `Repo<Repo<User>>` → `Repo`, `Repo<User>|null` →
|
||||
* `Repo|null`. Bracket-counting rather than a regex so a nested or
|
||||
* multi-argument spelling reduces in one pass; an unbalanced `<` simply
|
||||
* swallows the tail, which is the declining direction.
|
||||
*
|
||||
* NOT the shared `stripTemplateArguments`, and the difference is the UNION:
|
||||
* that one truncates at the first `<`, so `Repo<User>|null` becomes `Repo` and
|
||||
* the nullability is lost with the arguments. A docblock type is the one place
|
||||
* a union survives to the binding — `interpretPhpTypeBinding` runs
|
||||
* `normalizePhpType` over what this returns, and that is what strips `|null`
|
||||
* exactly as it does for a native `Repo|null` property. So a PHP docblock needs
|
||||
* the arguments gone and the rest of the spelling intact, which is a different
|
||||
* operation and not a candidate for a seventh caller of the shared one.
|
||||
*/
|
||||
function erasePhpDocTypeArguments(text: string): string {
|
||||
let out = '';
|
||||
let depth = 0;
|
||||
for (const ch of text) {
|
||||
if (ch === '<') depth++;
|
||||
else if (ch === '>') {
|
||||
if (depth > 0) depth--;
|
||||
} else if (depth === 0) out += ch;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The type name a property's PHPDoc `@var` should bind the FIELD to, or `null`
|
||||
* to decline.
|
||||
*
|
||||
* Two normalizations happen here and nowhere else, and each is forced:
|
||||
*
|
||||
* 1. TYPE-ARGUMENT ERASURE (`Repo<User>` → `Repo`). Every sibling language in
|
||||
* #2833 lets the as-written spelling reach `TypeRef.rawName` and leaves the
|
||||
* erasure to `resolveClassBindingForName`. PHP cannot: `normalizePhpType`
|
||||
* reduces `X<Y>` to `Y` — the CONTAINER-ELEMENT convention, pinned by
|
||||
* `test/integration/resolvers/php.test.ts` ("normalizePhpType
|
||||
* ('Collection<User>') must yield 'User', not 'Collection'") because the
|
||||
* foreach path depends on it. Measured: passing `Repo<User>` through binds
|
||||
* the field to `User` and `$this->repo->save()` emits `User::save` — a
|
||||
* WRONG edge, not a missing one. So a field's type arguments are erased
|
||||
* HERE, before that rule can read them, and the element convention is left
|
||||
* exactly as it was for `@param` / `@return` / foreach.
|
||||
*
|
||||
* 2. ARRAY DECLINE (`Repo[]` → nothing). A field annotated `Repo[]` holds an
|
||||
* ARRAY; typing it `Repo` is a wrong field type, and the collision is real
|
||||
* rather than theoretical — a repository class with a `find` / `filter` /
|
||||
* `map` method would claim `$this->repos->find(…)`. The element type is
|
||||
* already extracted separately for the one construct that wants it:
|
||||
* `extractPropertyElementType` reads the same `@var` for `foreach
|
||||
* ($this->repos as $r)`. Declining here keeps the two readings of one
|
||||
* annotation from colliding.
|
||||
*
|
||||
* Everything else is delegated: `interpretPhpTypeBinding` applies the SAME
|
||||
* `normalizePhpType` the native typed property (`private Repo $repo;`) goes
|
||||
* through, so nullable (`?Repo`), null-union (`Repo|null`), intersection,
|
||||
* fully-qualified (`\App\Models\Repo`, kept qualified on purpose — see that
|
||||
* function) and every primitive / `mixed` / `self` / `static` rejection behave
|
||||
* identically for the two spellings by construction, not by duplication.
|
||||
*/
|
||||
function phpDocPropertyFieldType(rawType: string): string | null {
|
||||
const erased = erasePhpDocTypeArguments(rawType).trim();
|
||||
if (erased === '') return null;
|
||||
// Array-of: declined (see 2 above). Checked AFTER erasure so `Repo<User>[]`
|
||||
// is recognised as an array too.
|
||||
if (erased.endsWith('[]')) return null;
|
||||
if (PHPDOC_PHANTOM_CONTAINER_BASES.has(erased.toLowerCase())) return null;
|
||||
return erased;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit the field type-binding a PHPDoc `@var` block declares on one UNTYPED
|
||||
* property declaration (`/** @var Repo */ private $repo;`), and record its
|
||||
* anchor id in `anchorIds`.
|
||||
*
|
||||
* PHP's own type story leans on docblocks for everything its native syntax
|
||||
* cannot spell — and generics are exactly that, since `private Repo<User>
|
||||
* $repo;` is a parse error. The native TYPED property already binds via the
|
||||
* `@type-binding.annotation` rule in `query.ts`; measured before this pass, the
|
||||
* docblock form bound NOTHING, so `$this->repo->save()` lost its edge for both
|
||||
* the generic spelling and its non-generic control (#2833).
|
||||
*
|
||||
* The emitted match is byte-identical in SHAPE to what that query rule emits —
|
||||
* `@type-binding.annotation` anchored on the `property_declaration`, with
|
||||
* `@type-binding.name` carrying the `$`-sigilled variable name. That is the
|
||||
* whole design: `interpretPhpTypeBinding` strips the sigil for source
|
||||
* `'annotation'`, `phpBindingScopeFor` places it on the same scope, and the
|
||||
* compound-receiver resolver finds it in `typeBindings` the way it always has.
|
||||
* No resolution-side code changes.
|
||||
*
|
||||
* Declines, each because the annotation cannot be ATTRIBUTED rather than
|
||||
* because the type is unusable:
|
||||
* - a property that already has a native `type:` — the query rule owns it,
|
||||
* and a docblock repeating it must not emit a second, competing binding;
|
||||
* - `private $a, $b;` — one `@var` cannot say which element it types;
|
||||
* - `@var Repo $other` naming a DIFFERENT property than the one it precedes.
|
||||
*/
|
||||
function emitPhpDocPropertyBinding(
|
||||
node: SyntaxNode,
|
||||
matches: CaptureMatch[],
|
||||
anchorIds: Set<number>,
|
||||
): void {
|
||||
// A native type hint already produces the binding via query.ts.
|
||||
if (node.childForFieldName('type') !== null) return;
|
||||
|
||||
const elements = node.namedChildren.filter(
|
||||
(c): c is SyntaxNode => c !== null && c.type === 'property_element',
|
||||
);
|
||||
if (elements.length !== 1) return;
|
||||
const varNameNode = elements[0].childForFieldName('name') ?? elements[0].firstNamedChild;
|
||||
if (varNameNode === null || varNameNode.type !== 'variable_name') return;
|
||||
|
||||
const raw = findPhpDocVarTag(node);
|
||||
if (raw === null) return;
|
||||
// `@var Repo $other` on `private $repo;` types neither — decline.
|
||||
if (raw.varName !== undefined && '$' + raw.varName !== varNameNode.text) return;
|
||||
|
||||
const typeName = phpDocPropertyFieldType(raw.type);
|
||||
if (typeName === null) return;
|
||||
|
||||
anchorIds.add(node.id);
|
||||
matches.push({
|
||||
'@type-binding.annotation': nodeToCapture('@type-binding.annotation', node),
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', varNameNode, varNameNode.text),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', varNameNode, typeName),
|
||||
});
|
||||
// …and the FIELD declaration, which the native rule emits as its own
|
||||
// separate match. Without it the property stays a `@declaration.variable`
|
||||
// named `$repo` — a Variable, not a class-owned member — and the type
|
||||
// binding alone is not enough: measured, `$this->repo->save()` resolved
|
||||
// while `save` was unique to one class and went UNRESOLVED as soon as a
|
||||
// second class declared a `save`, because narrowing a same-named method
|
||||
// needs the receiver's member to be owned. The native typed property
|
||||
// resolved the identical file. The `$` is stripped for the same reason it
|
||||
// is on the native path: PHP stores field names unsigilled so `$obj->repo`
|
||||
// looks up `repo`.
|
||||
matches.push({
|
||||
'@declaration.property': nodeToCapture('@declaration.property', node),
|
||||
'@declaration.name': syntheticCapture(
|
||||
'@declaration.name',
|
||||
varNameNode,
|
||||
varNameNode.text.replace(/^\$/, ''),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The `@var` tag on the comment siblings immediately preceding `propDecl` —
|
||||
* the same chain, the same regex and the same nearest-first order
|
||||
* `extractPropertyElementType` reads the tag through, so the two readings of
|
||||
* one annotation cannot disagree about WHICH annotation they read.
|
||||
*/
|
||||
function findPhpDocVarTag(
|
||||
propDecl: SyntaxNode,
|
||||
): { readonly type: string; readonly varName?: string } | null {
|
||||
const m = nearestPrecedingCommentMatch(propDecl, PHPDOC_VAR_RE);
|
||||
return m === null ? null : { type: m[1], varName: m[2] };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -439,3 +439,76 @@ export function isPythonImportedModule(
|
|||
normalizedTarget.endsWith('/' + packageFile)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The receiver spellings `import a.b.c` makes callable, and the file each one
|
||||
* names (#2826).
|
||||
*
|
||||
* `import a.b.c` binds ONE name — `a` — but makes three attribute paths
|
||||
* reachable, and they name three different files:
|
||||
*
|
||||
* a → a/__init__.py
|
||||
* a.b → a/b/__init__.py
|
||||
* a.b.c → a/b/c.py (the edge's own target)
|
||||
*
|
||||
* The shared default keyed `a` to the LEAF, which is wrong in both directions:
|
||||
* `a.helper()` resolved into `a/b/c.py` whenever that module happened to export
|
||||
* `helper`, and `a.b.mid()` resolved to nothing.
|
||||
*
|
||||
* Returns `undefined` — meaning "use the shared default" — for every spelling
|
||||
* where the bound name is not the path's root:
|
||||
* - `import single` — no dotted path to expand;
|
||||
* - `import a.b as x` — binds only `x`; writing `a.b.f()` there is a
|
||||
* NameError, so `a.b` must NOT become a key;
|
||||
* - `from pkg import db` — reclassified to a namespace edge whose
|
||||
* importPath is the bare name `db`.
|
||||
*
|
||||
* Prefix files are proposed, not asserted: `moduleFileExists` drops any that
|
||||
* the workspace did not parse, so a PEP-420 namespace package (no
|
||||
* `__init__.py`) contributes no key rather than one pointing at a missing file.
|
||||
*/
|
||||
export function pythonNamespaceReceiverPaths(
|
||||
edge: { readonly localName: string; readonly importPath: string; readonly targetFile: string },
|
||||
moduleFileExists: (filePath: string) => boolean,
|
||||
): readonly (readonly [string, string])[] | undefined {
|
||||
const segments = edge.importPath.split('.');
|
||||
if (segments.length < 2) return undefined;
|
||||
if (segments[0] !== edge.localName) return undefined;
|
||||
|
||||
const out: (readonly [string, string])[] = [[edge.importPath, edge.targetFile]];
|
||||
|
||||
// Anchor the prefix packages on the RESOLVED leaf, never on the import
|
||||
// spelling. `resolvePythonImportTarget` resolves off-root in two of its three
|
||||
// tiers (suffix match and ancestor-relative), so `import utils.db` can land on
|
||||
// `libs/common/utils/db.py`. Building `utils/__init__.py` from the spelling
|
||||
// would then name a DIFFERENT package that merely shares the root segment —
|
||||
// a wrong edge — and in a `src/` layout it would match nothing at all,
|
||||
// silently making prefix keying inert for the most common Python layout.
|
||||
//
|
||||
// Walking back from the leaf also inherits that path's own separator, so no
|
||||
// POSIX-vs-Windows probing is needed: workspace paths are not normalized at
|
||||
// ingestion, and `moduleScopeByFile` is keyed by the raw `ParsedFile.filePath`.
|
||||
const dirs = edge.targetFile.split('/').slice(0, -1);
|
||||
// The import's leading segments name the leaf's innermost directories.
|
||||
const offset = dirs.length - (segments.length - 1);
|
||||
if (offset < 0) return out;
|
||||
|
||||
for (let i = 1; i < segments.length; i++) {
|
||||
const spelling = segments.slice(0, i).join('.');
|
||||
const packageFile = dirs.slice(0, offset + i).join('/') + '/__init__.py';
|
||||
// Package FIRST, then the leaf as a fallback — order is the whole point.
|
||||
//
|
||||
// `findExportedDef` only accepts a binding whose `origin === 'local'`, and
|
||||
// the canonical package re-exports (`from .b.c import helper` in
|
||||
// `__init__.py`) produce an IMPORT binding. Keying the prefix at the
|
||||
// package alone therefore loses `a.helper()` entirely for the most common
|
||||
// package shape — the fixtures here all define members locally in
|
||||
// `__init__.py`, which is precisely the one layout where that mistake is
|
||||
// invisible. Keeping the leaf behind the package restores that resolution
|
||||
// while still letting a real definition in `__init__.py` win over a
|
||||
// same-named decoy deeper in the package.
|
||||
if (moduleFileExists(packageFile)) out.push([spelling, packageFile]);
|
||||
out.push([spelling, edge.targetFile]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ export { pythonMergeBindings } from './merge-bindings.js';
|
|||
export { pythonArityCompatibility } from './arity.js';
|
||||
export {
|
||||
isPythonImportedModule,
|
||||
pythonNamespaceReceiverPaths,
|
||||
resolvePythonImportTarget,
|
||||
type PythonResolveContext,
|
||||
} from './import-target.js';
|
||||
|
|
|
|||
|
|
@ -147,6 +147,50 @@ function stripForwardRefQuotes(text: string): string {
|
|||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Container bases whose SINGLE type argument is the element type.
|
||||
*
|
||||
* The single source of truth for both the matcher below and the property test
|
||||
* that asserts every one of them is also declined as a user generic — the two
|
||||
* lists drifting apart is the defect this arrangement exists to make
|
||||
* impossible. Order is significant only in that it is the regex alternation
|
||||
* order; keep additions grouped with their family.
|
||||
*/
|
||||
export const SINGLE_ARG_CONTAINERS: readonly string[] = [
|
||||
'list',
|
||||
'List',
|
||||
'set',
|
||||
'Set',
|
||||
'tuple',
|
||||
'Tuple',
|
||||
'Iterable',
|
||||
'Iterator',
|
||||
'Sequence',
|
||||
'Generator',
|
||||
'AsyncIterable',
|
||||
'AsyncIterator',
|
||||
];
|
||||
|
||||
/** Container bases whose SECOND type argument is the value type. See {@link SINGLE_ARG_CONTAINERS}. */
|
||||
export const MAPPING_CONTAINERS: readonly string[] = [
|
||||
'dict',
|
||||
'Dict',
|
||||
'Mapping',
|
||||
'MutableMapping',
|
||||
'OrderedDict',
|
||||
'DefaultDict',
|
||||
];
|
||||
|
||||
const QUALIFIER = '(?:[A-Za-z_][A-Za-z0-9_]*\\.)?';
|
||||
|
||||
const SINGLE_ARG_CONTAINER_RE = new RegExp(
|
||||
`^${QUALIFIER}(?:${SINGLE_ARG_CONTAINERS.join('|')})\\[([^,\\]]+)\\]$`,
|
||||
);
|
||||
|
||||
const MAPPING_CONTAINER_RE = new RegExp(
|
||||
`^${QUALIFIER}(?:${MAPPING_CONTAINERS.join('|')})\\[[^,\\]]+,\\s*([^\\]]+)\\]$`,
|
||||
);
|
||||
|
||||
/**
|
||||
* Unwrap a single-arg generic collection wrapper — `list[User]`,
|
||||
* `set[User]`, `Iterable[User]`, `Sequence[User]`, `Iterator[User]`,
|
||||
|
|
@ -159,9 +203,7 @@ function stripForwardRefQuotes(text: string): string {
|
|||
* resolution time.
|
||||
*/
|
||||
function stripGeneric(text: string): string {
|
||||
const single = text.match(
|
||||
/^(?:[A-Za-z_][A-Za-z0-9_]*\.)?(?:list|List|set|Set|tuple|Tuple|Iterable|Iterator|Sequence|Generator|AsyncIterable|AsyncIterator)\[([^,\]]+)\]$/,
|
||||
);
|
||||
const single = text.match(SINGLE_ARG_CONTAINER_RE);
|
||||
if (single !== null) return single[1].trim();
|
||||
// dict[K, V] / Dict[K, V] / Mapping[K, V] — strip to value type V.
|
||||
// For-loop destructuring of `for k, v in d.items()` binds `v` to
|
||||
|
|
@ -170,13 +212,205 @@ function stripGeneric(text: string): string {
|
|||
// only shape worth handling. Match a top-level K up to the first
|
||||
// comma and a V to the closing bracket; nested generics in V (e.g.
|
||||
// `dict[str, list[User]]`) are left for a downstream strip pass.
|
||||
const dict = text.match(
|
||||
/^(?:[A-Za-z_][A-Za-z0-9_]*\.)?(?:dict|Dict|Mapping|MutableMapping|OrderedDict|DefaultDict)\[[^,\]]+,\s*([^\]]+)\]$/,
|
||||
);
|
||||
const dict = text.match(MAPPING_CONTAINER_RE);
|
||||
if (dict !== null) return dict[1].trim();
|
||||
|
||||
// A subscripted type the two allow-lists above did NOT claim is a
|
||||
// user-defined GENERIC, not a container: `Repo[User]`, `Handler[Req, Res]`.
|
||||
// Its base names one declaration — `Repo[User]` and `Repo[Order]` are the
|
||||
// same `class Repo(Generic[T])` — so reduce to that base, exactly as Java's
|
||||
// and Swift's interpreters already do for their `<…>` spelling (#2833).
|
||||
//
|
||||
// Guarded by a DENY set rather than reached by fallthrough, because "the two
|
||||
// rules above did not match" is NOT the same as "not a container". Two
|
||||
// measured counterexamples, both of which this branch got wrong before the
|
||||
// guard existed:
|
||||
// - `dict[str, list[User]]` — the dict rule's value group cannot span a
|
||||
// nested `]`, so it declines and the shape falls through. Reducing it to
|
||||
// `dict` destroys the value type the dict rule explicitly leaves "for a
|
||||
// downstream strip pass"; the annotation must survive intact instead.
|
||||
// - `Callable[[int], User]`, `Literal["a"]`, `Annotated[int, F()]`,
|
||||
// `Union[A, B]`, `tuple[int, ...]` — typing SPECIAL FORMS, not classes.
|
||||
// Reducing them yields a bare `Callable`/`Literal`/`Union`, which binds
|
||||
// to a workspace class of that name if one exists — a fabricated edge,
|
||||
// and those names are ordinary enough for a real codebase to declare.
|
||||
// Anything named here keeps its as-written text and resolves as it did
|
||||
// before #2833.
|
||||
//
|
||||
// Only reached for genuine annotations: every Python `@type-binding.type`
|
||||
// capture is a `(type)`, `(identifier)`, `(attribute)` or `(dotted_name)`
|
||||
// node, so a subscripted VALUE expression (`arr[0]`) never arrives here.
|
||||
//
|
||||
// The as-written spelling is not lost — `scope-extractor` keeps it on
|
||||
// `TypeRef.declaredSpelling` whenever it differs from the reduced name,
|
||||
// which is what the receiver fold's index step reads.
|
||||
const userGeneric = text.match(/^((?:[A-Za-z_][A-Za-z0-9_]*\.)*[A-Za-z_][A-Za-z0-9_]*)\[.+\]$/s);
|
||||
if (userGeneric !== null) {
|
||||
const qualified = userGeneric[1].trim();
|
||||
const base = qualified.slice(qualified.lastIndexOf('.') + 1);
|
||||
if (!isNotAUserGenericBase(base)) return qualified;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a subscripted annotation's base names a Python type-system construct
|
||||
* rather than a workspace class — see {@link NOT_A_USER_GENERIC_SPELLINGS}.
|
||||
*
|
||||
* CASE-FOLDED, and that is the load-bearing part. PEP 585 gave nearly every
|
||||
* container two spellings — the builtin/`collections` one and the `typing`
|
||||
* alias (`deque` / `typing.Deque`, `frozenset` / `typing.FrozenSet`,
|
||||
* `defaultdict` / `typing.DefaultDict`) — which differ ONLY in case. Matching
|
||||
* exactly meant each pair had to be listed twice and any half-pair was a silent
|
||||
* escape: `deque` was listed, `Deque` was not, so `self.dq: Deque[User]`
|
||||
* reduced to `Deque` and bound to a workspace `class Deque` (#2855). Folding
|
||||
* case closes that axis by construction instead of by vigilance.
|
||||
*
|
||||
* The cost is that a workspace class whose name is a case VARIANT of a stdlib
|
||||
* construct (`class deque(Generic[T])`) stops reducing. PEP 8 makes such a
|
||||
* class vanishingly rare, and the loss is a missing edge — recoverable — where
|
||||
* the gain is not minting a confident wrong one.
|
||||
*/
|
||||
function isNotAUserGenericBase(base: string): boolean {
|
||||
return NOT_A_USER_GENERIC.has(base.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Bases a subscripted annotation may carry that are NOT user-defined generics.
|
||||
*
|
||||
* SCOPE — the standard library, and deliberately nothing else. The names below
|
||||
* are the documented Python type-system surface (`typing`'s deprecated PEP 585
|
||||
* aliases and its special forms, plus the stdlib classes those aliases point
|
||||
* at); that universe is CLOSED and versioned by CPython, so the list is
|
||||
* auditable against
|
||||
* <https://docs.python.org/3/library/typing.html#deprecated-aliases>.
|
||||
*
|
||||
* Third-party generics (`Mapped[int]`, `QuerySet[User]`) are NOT listed. That
|
||||
* universe is open, so enumerating it only ever chases the last escape, and
|
||||
* denying an ordinary name like `Model` would cost real edges in the many
|
||||
* projects that legitimately declare one. Those spellings still reduce to their
|
||||
* base, and the base is now admitted only on the grounds `resolveErasedBaseName`
|
||||
* applies at resolution time — the file's scope chain binds it, the declaration
|
||||
* is in this very file, the index proves the name is a template family, or the
|
||||
* file has no cross-file class channel to be absent from. A `Mapped[User]` whose
|
||||
* base the file cannot see therefore binds nothing, which is the structural
|
||||
* answer this parse-time pass cannot give and no longer has to.
|
||||
*
|
||||
* Two distinct reasons to decline, both always-correct at this layer:
|
||||
* - CONTAINERS, including ones the two rules above do not own. Reducing
|
||||
* `deque[User]` to `deque` types a receiver as the container and retargets
|
||||
* every call in a for-loop chain, and reducing `dict[str, list[User]]` to
|
||||
* `dict` destroys the value type the dict rule leaves for a downstream pass.
|
||||
* - `typing` SPECIAL FORMS, which are not classes at all. `Callable`,
|
||||
* `Literal`, `Union` reduce to a bare name that binds to a workspace class
|
||||
* of that name if one exists — a fabricated edge.
|
||||
*
|
||||
* Members are listed ONCE per case-insensitive concept: {@link
|
||||
* isNotAUserGenericBase} folds case, so the builtin spelling covers its PEP 585
|
||||
* `typing` twin (`deque` covers `Deque`, `frozenset` covers `FrozenSet`).
|
||||
* Non-generic ABCs (`Hashable`, `Sized`) are omitted — they cannot be written
|
||||
* subscripted, so they never reach this branch.
|
||||
*
|
||||
* Exported for the property test that asserts the case-fold closure holds
|
||||
* behaviourally; nothing else should read it.
|
||||
*/
|
||||
export const NOT_A_USER_GENERIC_SPELLINGS: readonly string[] = [
|
||||
// ── builtins subscriptable since PEP 585 ──────────────────────────────────
|
||||
'list',
|
||||
'set',
|
||||
'frozenset',
|
||||
'tuple',
|
||||
'dict',
|
||||
'type',
|
||||
// ── `collections` ─────────────────────────────────────────────────────────
|
||||
'defaultdict',
|
||||
'OrderedDict',
|
||||
'ChainMap',
|
||||
'Counter',
|
||||
'deque',
|
||||
// ── `collections.abc`, the subscriptable members ──────────────────────────
|
||||
'Mapping',
|
||||
'MutableMapping',
|
||||
'Sequence',
|
||||
'MutableSequence',
|
||||
'AbstractSet',
|
||||
'MutableSet',
|
||||
'Collection',
|
||||
'Container',
|
||||
'Reversible',
|
||||
'Iterable',
|
||||
'Iterator',
|
||||
'Generator',
|
||||
'AsyncIterable',
|
||||
'AsyncIterator',
|
||||
'AsyncGenerator',
|
||||
'Awaitable',
|
||||
'Coroutine',
|
||||
'KeysView',
|
||||
'ValuesView',
|
||||
'ItemsView',
|
||||
'MappingView',
|
||||
// ── `contextlib`, and the `typing` aliases to it ──────────────────────────
|
||||
'ContextManager',
|
||||
'AsyncContextManager',
|
||||
'AbstractContextManager',
|
||||
'AbstractAsyncContextManager',
|
||||
// ── `re`, and the `typing` aliases to it ──────────────────────────────────
|
||||
// `Pattern` and `Match` ARE classes, so reducing them is not wrong the way
|
||||
// reducing `Callable` is; they are declined because in Python annotations
|
||||
// these spellings are overwhelmingly the `re` types, while a workspace class
|
||||
// of the same name is a parser's own `Pattern`/`Match` and would be bound
|
||||
// with no import evidence whatsoever. Same policy as the receiver-chain
|
||||
// resolver's: a missing edge is recoverable, a confident wrong one is not.
|
||||
'Pattern',
|
||||
'Match',
|
||||
// ── I/O streams (`typing.IO` and its two subclasses) ──────────────────────
|
||||
'IO',
|
||||
'TextIO',
|
||||
'BinaryIO',
|
||||
// ── stdlib generic classes with ordinary names ────────────────────────────
|
||||
// Same policy call as `Pattern`/`Match` above, and the sharpest instance of
|
||||
// it: `asyncio.Task[Result]` reduces to `asyncio.Task`, whose dotted-tail
|
||||
// fallback then single-matches an unrelated workspace `class Task`.
|
||||
'Queue',
|
||||
'Task',
|
||||
'Future',
|
||||
'PathLike',
|
||||
// ── `typing` special forms — not classes ──────────────────────────────────
|
||||
'Callable',
|
||||
'Literal',
|
||||
'Annotated',
|
||||
'Union',
|
||||
'Optional',
|
||||
'Final',
|
||||
'ClassVar',
|
||||
// `typing.Type` is the PEP 585 alias for the builtin `type` listed above, and
|
||||
// the case fold already covers it — see the one-entry-per-concept rule.
|
||||
'TypeGuard',
|
||||
'TypeIs',
|
||||
'Unpack',
|
||||
'Required',
|
||||
'NotRequired',
|
||||
'ReadOnly',
|
||||
'Concatenate',
|
||||
'LiteralString',
|
||||
// ── generic machinery: bases and type-parameter declarations ──────────────
|
||||
// `Generic[T]`/`Protocol[T]` are written subscripted for real. The three
|
||||
// declaration forms are not subscriptable in valid Python, but this
|
||||
// interpreter checks no grammar — it reduces whatever text the annotation
|
||||
// capture carried — so they are declined defensively.
|
||||
'Generic',
|
||||
'Protocol',
|
||||
'TypeVar',
|
||||
'ParamSpec',
|
||||
'TypeVarTuple',
|
||||
];
|
||||
|
||||
/** Case-folded lookup index over {@link NOT_A_USER_GENERIC_SPELLINGS}. */
|
||||
const NOT_A_USER_GENERIC: ReadonlySet<string> = new Set(
|
||||
NOT_A_USER_GENERIC_SPELLINGS.map((name) => name.toLowerCase()),
|
||||
);
|
||||
|
||||
/**
|
||||
* Unwrap nullable type annotations so downstream resolution treats
|
||||
* `User | None`, `None | User`, and `Optional[User]` identically to
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { indexOnlyElementType } from '../../type-extractors/shared.js';
|
|||
import { pythonProvider } from '../python.js';
|
||||
import {
|
||||
isPythonImportedModule,
|
||||
pythonNamespaceReceiverPaths,
|
||||
pythonArityCompatibility,
|
||||
pythonMergeBindings,
|
||||
resolvePythonImportTarget,
|
||||
|
|
@ -57,6 +58,12 @@ const pythonScopeResolver: ScopeResolver = {
|
|||
isNamespaceImport: (parsedImport, targetFile, fromFile) =>
|
||||
isPythonImportedModule(parsedImport, targetFile, fromFile),
|
||||
|
||||
// `import a.b.c` binds only `a`, yet makes `a`, `a.b` and `a.b.c` all
|
||||
// callable — each naming a different file. Without this the absolute-import
|
||||
// style is invisible to the call graph, and the root key points at the leaf
|
||||
// module instead of the package (#2826).
|
||||
namespaceReceiverPaths: pythonNamespaceReceiverPaths,
|
||||
|
||||
// Python LEGB precedence: local > import/namespace/reexport > wildcard.
|
||||
// The per-scope id is unused by pythonMergeBindings (tier ordering
|
||||
// is computed purely from BindingRef.origin), so we don't need to
|
||||
|
|
|
|||
|
|
@ -22,15 +22,18 @@ const RUST_SCOPE_QUERY = `
|
|||
|
||||
;; Declarations — struct
|
||||
(struct_item
|
||||
name: (type_identifier) @declaration.name) @declaration.struct
|
||||
name: (type_identifier) @declaration.name
|
||||
type_parameters: (type_parameters)? @declaration.type-parameters) @declaration.struct
|
||||
|
||||
;; Declarations — trait
|
||||
(trait_item
|
||||
name: (type_identifier) @declaration.name) @declaration.trait
|
||||
name: (type_identifier) @declaration.name
|
||||
type_parameters: (type_parameters)? @declaration.type-parameters) @declaration.trait
|
||||
|
||||
;; Declarations — enum
|
||||
(enum_item
|
||||
name: (type_identifier) @declaration.name) @declaration.enum
|
||||
name: (type_identifier) @declaration.name
|
||||
type_parameters: (type_parameters)? @declaration.type-parameters) @declaration.enum
|
||||
|
||||
;; Declarations — union
|
||||
;; Deliberately tagged @declaration.struct (→ Struct label), NOT a
|
||||
|
|
@ -42,7 +45,8 @@ const RUST_SCOPE_QUERY = `
|
|||
;; constructor, so Struct is both the resolvable and the semantically
|
||||
;; honest label here. #1934 F71.
|
||||
(union_item
|
||||
name: (type_identifier) @declaration.name) @declaration.struct
|
||||
name: (type_identifier) @declaration.name
|
||||
type_parameters: (type_parameters)? @declaration.type-parameters) @declaration.struct
|
||||
|
||||
;; Declarations — module (mod foo { ... } / mod foo;)
|
||||
;; A Rust mod is an ITEM, not just a lexical region: rustc resolves the first
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ import {
|
|||
jsMergeBindings,
|
||||
jsArityCompatibility,
|
||||
} from './javascript/index.js';
|
||||
import { extractDispatchGuardRoutes } from '../route-extractors/dispatch-guard.js';
|
||||
|
||||
/**
|
||||
* TypeScript/JavaScript: arrow_function and function_expression are
|
||||
|
|
@ -454,6 +455,10 @@ export const typescriptProvider = defineLanguage({
|
|||
receiverBinding: tsReceiverBinding,
|
||||
arityCompatibility: typescriptArityCompatibility,
|
||||
resolveImportTarget: resolveTsImportTarget,
|
||||
// A raw `node:http` server declares its routes by comparing the request path
|
||||
// to a literal; nothing else in this pipeline can see that shape. TS and JS
|
||||
// share the grammar, so they share the extractor.
|
||||
extractDecoratorRoutes: extractDispatchGuardRoutes,
|
||||
});
|
||||
|
||||
export const javascriptProvider = defineLanguage({
|
||||
|
|
@ -526,4 +531,6 @@ export const javascriptProvider = defineLanguage({
|
|||
mergeBindings: (_scope, bindings) => jsMergeBindings(bindings),
|
||||
receiverBinding: jsReceiverBinding,
|
||||
arityCompatibility: jsArityCompatibility,
|
||||
// See the TypeScript provider above.
|
||||
extractDecoratorRoutes: extractDispatchGuardRoutes,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -729,8 +729,12 @@ export function emitTsScopeCaptures(
|
|||
* matching arms (member_expression for extends, nested_type_identifier plain +
|
||||
* generic-wrapped for implements).
|
||||
*
|
||||
* `interface_declaration` / `abstract_class_declaration` heritage is NOT emitted
|
||||
* by the synth. The EXTENDS-vs-IMPLEMENTS split is decided downstream from the
|
||||
* `interface_declaration` and `abstract_class_declaration` heritage IS emitted
|
||||
* (#2842 review; both were silently skipped before, so `interface B extends A`
|
||||
* and `abstract class X implements I` produced no edge and every dispatch walk
|
||||
* dead-ended on a bodiless declaration). They reach their bases by different
|
||||
* shapes: an abstract class carries the same `class_heritage` child a concrete
|
||||
* one does, while an interface's bases hang off `extends_type_clause` directly. The EXTENDS-vs-IMPLEMENTS split is decided downstream from the
|
||||
* resolved target's symbol kind in `preEmitInheritanceEdges` (class-extends →
|
||||
* EXTENDS, implements-interface / interface-target → IMPLEMENTS), so all bases
|
||||
* are emitted with the same `inherits` kind here. The base lookup name is
|
||||
|
|
@ -746,7 +750,26 @@ function synthesizeTsInheritanceReferences(root: SyntaxNode, out: CaptureMatch[]
|
|||
if (child !== null) stack.push(child);
|
||||
}
|
||||
|
||||
if (node.type !== 'class_declaration') continue;
|
||||
// `interface B extends A, C` hangs its bases off an `extends_type_clause`
|
||||
// DIRECTLY on the interface — there is no `class_heritage` wrapper, so the
|
||||
// class path below cannot reach them (#2842 review). The clause's `type`
|
||||
// field is `multiple: true`, so `childForFieldName('type')` would silently
|
||||
// return only `A` and drop `C`; iterate the named children instead.
|
||||
if (node.type === 'interface_declaration') {
|
||||
for (const child of node.namedChildren) {
|
||||
if (child === null || child.type !== 'extends_type_clause') continue;
|
||||
for (const base of child.namedChildren) {
|
||||
emitTsInheritanceBase(base, out);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// `abstract class X implements I` carries an identical `class_heritage`
|
||||
// child, so the existing body handles it once the node type is admitted.
|
||||
// Omitting it severed the only link between an interface and the concrete
|
||||
// classes below an abstract base — a whole subtree, not a leaf.
|
||||
if (node.type !== 'class_declaration' && node.type !== 'abstract_class_declaration') continue;
|
||||
|
||||
// Find the `class_heritage` child (holds extends / implements clauses).
|
||||
let heritage: SyntaxNode | null = null;
|
||||
|
|
|
|||
|
|
@ -310,3 +310,26 @@ function stripQualifier(text: string): string {
|
|||
if (lastDot === -1) return text;
|
||||
return text.slice(lastDot + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Would this interpreter reduce `text` to the type it CONTAINS rather than to
|
||||
* the type it names? True for the array suffix (`Repo[]`) and for every
|
||||
* transparent wrapper on {@link stripGeneric}'s list (`Array<Repo>`,
|
||||
* `Promise<Repo>`, `Set<Repo>`, …).
|
||||
*
|
||||
* Exported for the ONE caller that must decline exactly what this returns true
|
||||
* for: the JavaScript provider's JSDoc `@type` FIELD binding (#2833). Element
|
||||
* reduction is right where it was built — a chain step, a `for…of` variable, an
|
||||
* awaited value — and wrong for a field, whose declared type IS the container:
|
||||
* a field annotated `{Repo[]}` reduced to `Repo` makes `this.repos.find(…)`,
|
||||
* an Array method call, resolve to a repository class's own `find`. A wrong
|
||||
* edge, which #2833 treats as strictly worse than a missing one.
|
||||
*
|
||||
* A predicate rather than a copied name list on purpose: the list lives in
|
||||
* `stripGeneric` and a second copy would drift out of sync silently, exactly
|
||||
* the failure mode `python/interpret.ts` records for its own reduction.
|
||||
*/
|
||||
export function reducesToContainedType(text: string): boolean {
|
||||
const trimmed = stripReadonly(text.trim());
|
||||
return stripArraySuffix(trimmed) !== trimmed || stripGeneric(trimmed) !== trimmed;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,20 +150,32 @@ export const TYPESCRIPT_SCOPE_QUERY = `
|
|||
value: (object_type)) @scope.class
|
||||
|
||||
;; Declarations — types
|
||||
;; The type-parameter list is captured with \`?\` rather than as a second
|
||||
;; pattern: a separate rule would make a GENERIC declaration match twice, and
|
||||
;; both matches mint the same def id (filePath+range+type+name), so which one
|
||||
;; survived — the one carrying the parameters or the one without — would be
|
||||
;; decided by match order. An optional child keeps it at one match either way.
|
||||
(class_declaration
|
||||
name: (type_identifier) @declaration.name) @declaration.class
|
||||
name: (type_identifier) @declaration.name
|
||||
type_parameters: (type_parameters)? @declaration.type-parameters) @declaration.class
|
||||
|
||||
(abstract_class_declaration
|
||||
name: (type_identifier) @declaration.name) @declaration.class
|
||||
name: (type_identifier) @declaration.name
|
||||
type_parameters: (type_parameters)? @declaration.type-parameters) @declaration.class
|
||||
|
||||
(interface_declaration
|
||||
name: (type_identifier) @declaration.name) @declaration.interface
|
||||
name: (type_identifier) @declaration.name
|
||||
type_parameters: (type_parameters)? @declaration.type-parameters) @declaration.interface
|
||||
|
||||
(enum_declaration
|
||||
name: (identifier) @declaration.name) @declaration.enum
|
||||
|
||||
;; Tagged @declaration.type_alias, NOT @declaration.type: normalizeNodeLabel
|
||||
;; accepts typealias / type_alias and has no "type" case, so the old tag mapped
|
||||
;; to no label and TypeScript aliases produced NO scope-resolution def at all.
|
||||
;; Kotlin and Dart already spell it this way.
|
||||
(type_alias_declaration
|
||||
name: (type_identifier) @declaration.name) @declaration.type
|
||||
name: (type_identifier) @declaration.name) @declaration.type_alias
|
||||
|
||||
(internal_module
|
||||
name: (identifier) @declaration.name) @declaration.namespace
|
||||
|
|
@ -498,6 +510,48 @@ export const TYPESCRIPT_SCOPE_QUERY = `
|
|||
(method_signature
|
||||
name: (property_identifier) @declaration.name) @declaration.method
|
||||
|
||||
;; Members of a declared SHAPE — interface bodies and object-type aliases both
|
||||
;; spell them as property_signature (A4). The sibling method_signature rule
|
||||
;; above declared interface METHODS, so only properties were missing: a typed
|
||||
;; receiver resolved to the shape's scope and then found no member there, and
|
||||
;; the field's consumers were unreachable. TypeScript sets
|
||||
;; fieldFallbackOnMethodLookup:false, so there is no name-based safety net
|
||||
;; here — the precise path is the only one, and it needs the declaration.
|
||||
;; ANCHORED to declared shapes — see the matching rule in TYPESCRIPT_QUERIES
|
||||
;; for why. Unanchored this matched inline parameter and return types and
|
||||
;; nested object types, whose members then collided onto the enclosing
|
||||
;; class/interface/alias.
|
||||
(interface_body
|
||||
(property_signature
|
||||
name: (property_identifier) @declaration.name) @declaration.property)
|
||||
|
||||
;; Object-literal keys of a NAMED object — the scope-resolution half of the
|
||||
;; matching rule in TYPESCRIPT_QUERIES. The parse query mints the Property NODE;
|
||||
;; this mints the DEF a precise read can resolve to.
|
||||
(variable_declarator
|
||||
name: (identifier)
|
||||
value: (object
|
||||
(pair
|
||||
key: (property_identifier) @declaration.name) @declaration.property))
|
||||
|
||||
(variable_declarator
|
||||
name: (identifier)
|
||||
value: (call_expression
|
||||
function: (member_expression
|
||||
object: (identifier) @_ts.identity.obj
|
||||
property: (property_identifier) @_ts.identity.fn)
|
||||
arguments: (arguments
|
||||
(object
|
||||
(pair
|
||||
key: (property_identifier) @declaration.name) @declaration.property)))
|
||||
(#eq? @_ts.identity.obj "Object")
|
||||
(#match? @_ts.identity.fn "^(freeze|seal|preventExtensions)$"))
|
||||
|
||||
(type_alias_declaration
|
||||
value: (object_type
|
||||
(property_signature
|
||||
name: (property_identifier) @declaration.name) @declaration.property))
|
||||
|
||||
;; Declarations — class fields
|
||||
(public_field_definition
|
||||
name: (property_identifier) @declaration.name) @declaration.property
|
||||
|
|
@ -1204,6 +1258,65 @@ export const TYPESCRIPT_SCOPE_QUERY = `
|
|||
|
||||
(object
|
||||
(shorthand_property_identifier) @reference.name @reference.property-key @reference.value-ref)
|
||||
|
||||
;; Bare-identifier reads (A2), VALUE POSITIONS ONLY — a blanket \`(identifier)\`
|
||||
;; rule would mint a site for every token in the file.
|
||||
;;
|
||||
;; These existed only in the JavaScript query, so A2 did not work for
|
||||
;; TypeScript AT ALL: a \`.ts\` module reading its own \`const\` by bare name
|
||||
;; produced no reference site, and "who uses this constant?" answered a
|
||||
;; confident zero for an entire language. Found by writing the namespace
|
||||
;; fixture below and watching it fail for the wrong reason.
|
||||
(arguments
|
||||
(identifier) @reference.name @reference.read.identifier)
|
||||
|
||||
(assignment_pattern
|
||||
right: (identifier) @reference.name @reference.read.identifier)
|
||||
|
||||
(return_statement
|
||||
(identifier) @reference.name @reference.read.identifier)
|
||||
|
||||
;; \`const next = LIMIT\` and \`n > LIMIT\` — both plainly value reads, and both
|
||||
;; named in review as gaps between what A2 claimed and what it matched.
|
||||
(variable_declarator
|
||||
value: (identifier) @reference.name @reference.read.identifier)
|
||||
|
||||
(binary_expression
|
||||
left: (identifier) @reference.name @reference.read.identifier)
|
||||
|
||||
(binary_expression
|
||||
right: (identifier) @reference.name @reference.read.identifier)
|
||||
|
||||
;; References — TYPE POSITION (R2-2). An annotation naming a declared type is
|
||||
;; the only thing that makes that type's declaration reachable from the code
|
||||
;; that depends on it, and TypeScript captured none: only cpp and csharp emitted
|
||||
;; type references at all. So an exported API-contract type owned its members
|
||||
;; (round 1) but had \`incoming: {}\`, and "what breaks if I remove this field?"
|
||||
;; — the question a contract type exists to answer — had no edge to walk.
|
||||
;;
|
||||
;; The resolution path was already complete on the other side:
|
||||
;; \`type-reference\` routes to the ClassRegistry, whose CLASS_KINDS already
|
||||
;; lists TypeAlias, Interface and Enum, and \`edges.ts\` already maps the kind to
|
||||
;; USES. Only the capture was missing.
|
||||
;;
|
||||
;; Anchored to the CONTEXTS a type is used in — annotations, type arguments,
|
||||
;; and heritage \`implements\` — never a bare \`(type_identifier)\`. A blanket rule
|
||||
;; would also match the identifier in \`type X = …\` and \`interface X\`, making
|
||||
;; every declaration a consumer of itself.
|
||||
(type_annotation
|
||||
(type_identifier) @reference.name @reference.type_reference)
|
||||
|
||||
(type_annotation
|
||||
(generic_type
|
||||
name: (type_identifier) @reference.name @reference.type_reference))
|
||||
|
||||
(type_arguments
|
||||
(type_identifier) @reference.name @reference.type_reference)
|
||||
|
||||
;; \`x as SomeType\` / \`satisfies SomeType\` — an assertion is a claim ABOUT a
|
||||
;; declared type, so the code making it depends on that declaration.
|
||||
(as_expression
|
||||
(type_identifier) @reference.name @reference.type_reference)
|
||||
`;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@
|
|||
* Detects execution flows (processes) and creates Process nodes +
|
||||
* STEP_IN_PROCESS edges. Also links Route/Tool nodes to processes.
|
||||
*
|
||||
* @deps communities, routes, tools, pruneLocalSymbols
|
||||
* @reads graph (all nodes and relationships), communityResult, routeRegistry, toolDefs
|
||||
* @deps communities, routes, tools, pruneLocalSymbols, structure, parse
|
||||
* @reads graph (all nodes and relationships), communityResult, routeRegistry,
|
||||
* toolDefs, parse's allFetchCalls + allORMQueries (R3-6 sink sites)
|
||||
* @writes graph (Process nodes, STEP_IN_PROCESS edges, ENTRY_POINT_OF edges)
|
||||
*/
|
||||
|
||||
|
|
@ -15,6 +16,7 @@ import type { CommunitiesOutput } from './communities.js';
|
|||
import type { RoutesOutput } from './routes.js';
|
||||
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 { generateId } from '../../../lib/utils.js';
|
||||
import { routeNodeKey } from '../route-extractors/route-path.js';
|
||||
|
|
@ -38,11 +40,19 @@ export function computeDynamicMaxProcesses(symbolCount: number): number {
|
|||
|
||||
export const processesPhase: PipelinePhase<ProcessesOutput> = {
|
||||
name: 'processes',
|
||||
// `structure` supplies `totalFiles` (progress counter) without the spurious
|
||||
// structural data dependency on `parse`. `pruneLocalSymbols` is declared
|
||||
// `structure` supplies `totalFiles` (progress counter), which is why this
|
||||
// phase historically avoided depending on `parse` at all — that dependency
|
||||
// was spurious for a progress number.
|
||||
//
|
||||
// It is no longer spurious. R3-6 reads `allFetchCalls` / `allORMQueries` from
|
||||
// the parse output to learn WHERE the program reaches outward, which is what
|
||||
// lets a trace end at a sink instead of only at a leaf. That is a real data
|
||||
// dependency, so it is declared rather than reached for implicitly — and the
|
||||
// read below still fails open, so a pipeline without that output detects no
|
||||
// sinks rather than failing the phase. `pruneLocalSymbols` is declared
|
||||
// explicitly so process extraction always reads the trimmed graph even if a
|
||||
// future option drops the intervening `mro`/`communities` phases.
|
||||
deps: ['communities', 'routes', 'tools', 'pruneLocalSymbols', 'structure'],
|
||||
deps: ['communities', 'routes', 'tools', 'pruneLocalSymbols', 'structure', 'parse'],
|
||||
|
||||
async execute(
|
||||
ctx: PipelineContext,
|
||||
|
|
@ -66,6 +76,32 @@ export const processesPhase: PipelinePhase<ProcessesOutput> = {
|
|||
});
|
||||
const dynamicMaxProcesses = computeDynamicMaxProcesses(symbolCount);
|
||||
|
||||
// R3-6: where the program reaches outward. Already collected by the parse
|
||||
// phase for FILE-level FETCHES/QUERIES edges; reused here at function
|
||||
// granularity so a trace can end somewhere meaningful instead of only at a
|
||||
// leaf. Absent (or an older parse output) simply yields no sinks and the
|
||||
// previous behaviour.
|
||||
//
|
||||
// Typed as `ParseOutput` rather than a locally re-declared structural shape,
|
||||
// the way every other parse consumer does it (cross-file.ts, orm.ts,
|
||||
// routes.ts, tools.ts). `getPhaseOutput` is a raw `as T` cast, so a local
|
||||
// shape does not check anything — it only severs the compile-time link, and
|
||||
// renaming `allFetchCalls` on `ParseOutput` would then still compile here and
|
||||
// silently detect zero sinks. The runtime `.filter` below is the actual
|
||||
// guard, and it stays.
|
||||
let parseOutput: ParseOutput | undefined;
|
||||
try {
|
||||
parseOutput = getPhaseOutput<ParseOutput>(deps, 'parse');
|
||||
} catch {
|
||||
// Fail open: no sinks, previous behaviour. A missing parse output is a
|
||||
// pipeline-composition question, not a reason to lose every process.
|
||||
parseOutput = undefined;
|
||||
}
|
||||
const outwardActionSites = [
|
||||
...(parseOutput?.allFetchCalls ?? []),
|
||||
...(parseOutput?.allORMQueries ?? []),
|
||||
].filter((s) => typeof s?.filePath === 'string' && typeof s?.lineNumber === 'number');
|
||||
|
||||
const processResult = await processProcesses(
|
||||
ctx.graph,
|
||||
communityResult.memberships,
|
||||
|
|
@ -79,6 +115,7 @@ export const processesPhase: PipelinePhase<ProcessesOutput> = {
|
|||
});
|
||||
},
|
||||
{ maxProcesses: dynamicMaxProcesses, minSteps: 3 },
|
||||
outwardActionSites,
|
||||
);
|
||||
|
||||
if (isDev) {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
compiledMatcherMatchesRoute,
|
||||
} from '../route-extractors/middleware.js';
|
||||
import { processNextjsFetchRoutes } from '../call-processor.js';
|
||||
import { reconcileDispatchGuardRoutes } from '../route-extractors/dispatch-guard.js';
|
||||
import {
|
||||
normalizeExtractedRoutePath,
|
||||
normalizeRouteMethod,
|
||||
|
|
@ -248,11 +249,18 @@ export const routesPhase: PipelinePhase<RoutesOutput> = {
|
|||
namedRouteRegistry.set(route.routeName, routeUrl);
|
||||
}
|
||||
}
|
||||
for (const dr of allDecoratorRoutes) {
|
||||
// A dispatch-guard route observed WITHOUT a verb is dropped when the same
|
||||
// URL is claimed WITH one anywhere in the repo — the split route-table
|
||||
// idiom, which no single file can reconcile. Framework routes are untouched;
|
||||
// their verb-less form is a declaration, not a weaker observation.
|
||||
for (const dr of reconcileDispatchGuardRoutes(allDecoratorRoutes)) {
|
||||
const url = normalizeExtractedRoutePath(dr.routePath, dr.prefix ?? null);
|
||||
addRoute(url, {
|
||||
filePath: dr.filePath,
|
||||
source: `decorator-${dr.decoratorName}`,
|
||||
// A route extracted from a file's own AST is usually a decorator; a
|
||||
// dispatch guard is the same transport with different provenance, and
|
||||
// says so (`ExtractedDecoratorRoute.source`).
|
||||
source: dr.source ?? `decorator-${dr.decoratorName}`,
|
||||
method: normalizeRouteMethod(dr.httpMethod),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue