diff --git a/.claude/skills/gitnexus-cli/SKILL.md b/.claude/skills/gitnexus-cli/SKILL.md index b73ea7ede..342e8b08f 100644 --- a/.claude/skills/gitnexus-cli/SKILL.md +++ b/.claude/skills/gitnexus-cli/SKILL.md @@ -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 ` — 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 ` — 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 diff --git a/.claude/skills/gitnexus-impact-analysis/SKILL.md b/.claude/skills/gitnexus-impact-analysis/SKILL.md index 0b81795de..2e34f86f6 100644 --- a/.claude/skills/gitnexus-impact-analysis/SKILL.md +++ b/.claude/skills/gitnexus-impact-analysis/SKILL.md @@ -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 diff --git a/.claude/skills/gitnexus/gitnexus-taint-analysis/SKILL.md b/.claude/skills/gitnexus/gitnexus-taint-analysis/SKILL.md index 9bffffdac..e4069f9a8 100644 --- a/.claude/skills/gitnexus/gitnexus-taint-analysis/SKILL.md +++ b/.claude/skills/gitnexus/gitnexus-taint-analysis/SKILL.md @@ -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 diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 87d2c4377..65f7f403e 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -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. diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 28ad56edc..849291275 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -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 }} diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index 55e3bcc9e..7127e9dba 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -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 diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index da7117d77..f511d4d51 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index b34ddef32..f4fcef0af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: ` — 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 --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. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 42160627e..03e3730b3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 = { }; ``` +### 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) diff --git a/CLAUDE.md b/CLAUDE.md index 69c77a423..8382c69ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,13 +64,14 @@ See the ` … ` 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: ` — 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 --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. diff --git a/Dockerfile.cli b/Dockerfile.cli index 633d23f5d..b42c22dad 100644 --- a/Dockerfile.cli +++ b/Dockerfile.cli @@ -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}\""] diff --git a/GUARDRAILS.md b/GUARDRAILS.md index 3fe36a875..e157ade1e 100644 --- a/GUARDRAILS.md +++ b/GUARDRAILS.md @@ -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. diff --git a/MIGRATION.md b/MIGRATION.md index 9c6c1de2d..f63d18c87 100644 --- a/MIGRATION.md +++ b/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__`), 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 diff --git a/README.md b/README.md index 7158cbd66..d6534f658 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,28 @@ That's it. `analyze` indexes the codebase, installs agent skills, registers Clau +### Deploy to Render + +Deploy GitNexus in one click: + +[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](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** | diff --git a/RUNBOOK.md b/RUNBOOK.md index e34d20c2b..0f5c8b7bb 100644 --- a/RUNBOOK.md +++ b/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. --- diff --git a/SECURITY.md b/SECURITY.md index 79ef97f6b..d1fbcd051 100644 --- a/SECURITY.md +++ b/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. diff --git a/docker-server.mjs b/docker-server.mjs index f6c9eb8f6..e3e9f68ee 100644 --- a/docker-server.mjs +++ b/docker-server.mjs @@ -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 ? `` : ''; +// 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); diff --git a/docker-server.test.mjs b/docker-server.test.mjs index ee3a4301a..80e742f7e 100644 --- a/docker-server.test.mjs +++ b/docker-server.test.mjs @@ -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'), 'spa'); + + 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'), 'spa'); + 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); + } + }); +}); diff --git a/docs/plans/2026-08-04-gitnexus-plan-python-dotted-namespace-receiver.md b/docs/plans/2026-08-04-gitnexus-plan-python-dotted-namespace-receiver.md new file mode 100644 index 000000000..bf4c175c8 --- /dev/null +++ b/docs/plans/2026-08-04-gitnexus-plan-python-dotted-namespace-receiver.md @@ -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: vs b2cd1c2ad:.' + + 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. diff --git a/gitnexus-claude-plugin/hooks/resolve-analyze-cmd.cjs b/gitnexus-claude-plugin/hooks/resolve-analyze-cmd.cjs index f6f6c2cc2..56f5235fb 100644 --- a/gitnexus-claude-plugin/hooks/resolve-analyze-cmd.cjs +++ b/gitnexus-claude-plugin/hooks/resolve-analyze-cmd.cjs @@ -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 ` --version` → { major, minor } (each null when +// One spawn of ` --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 ` 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 diff --git a/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md index 64c404688..91c1ae992 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-cli/SKILL.md @@ -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 ` — 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 ` — 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 diff --git a/gitnexus-claude-plugin/skills/gitnexus-impact-analysis/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-impact-analysis/SKILL.md index 0b81795de..2e34f86f6 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-impact-analysis/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-impact-analysis/SKILL.md @@ -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 diff --git a/gitnexus-claude-plugin/skills/gitnexus-taint-analysis/SKILL.md b/gitnexus-claude-plugin/skills/gitnexus-taint-analysis/SKILL.md index 9bffffdac..e4069f9a8 100644 --- a/gitnexus-claude-plugin/skills/gitnexus-taint-analysis/SKILL.md +++ b/gitnexus-claude-plugin/skills/gitnexus-taint-analysis/SKILL.md @@ -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 diff --git a/gitnexus-cursor-integration/skills/gitnexus-impact-analysis/SKILL.md b/gitnexus-cursor-integration/skills/gitnexus-impact-analysis/SKILL.md index 8f9f1d1e7..7a3586b29 100644 --- a/gitnexus-cursor-integration/skills/gitnexus-impact-analysis/SKILL.md +++ b/gitnexus-cursor-integration/skills/gitnexus-impact-analysis/SKILL.md @@ -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 diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index 284e94268..13c2eac5a 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -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, diff --git a/gitnexus-shared/src/scope-resolution/symbol-definition.ts b/gitnexus-shared/src/scope-resolution/symbol-definition.ts index 64fbce93a..896b0dc04 100644 --- a/gitnexus-shared/src/scope-resolution/symbol-definition.ts +++ b/gitnexus-shared/src/scope-resolution/symbol-definition.ts @@ -24,6 +24,38 @@ export interface ParameterTypeClass { templateArguments?: string[]; } +/** + * One declared generic/template TYPE PARAMETER — `T` in `class Box`, `template struct Vec`, `interface Repo`. + * + * 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']`); `typeParameters` records the parameters it was written IN TERMS + * OF. A declaration can carry both — a C++ partial specialization + * `template struct Vec` 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`, 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` 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` predicate trees, C++20 `requires` clauses). * Opaque to shared code — the producing language adapter owns the shape diff --git a/gitnexus-web/package-lock.json b/gitnexus-web/package-lock.json index b3bb6be34..ec55afb9c 100644 --- a/gitnexus-web/package-lock.json +++ b/gitnexus-web/package-lock.json @@ -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" diff --git a/gitnexus-web/package.json b/gitnexus-web/package.json index bb5860bc2..e7e3c0fb3 100644 --- a/gitnexus-web/package.json +++ b/gitnexus-web/package.json @@ -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": { diff --git a/gitnexus-web/src/components/AccessTokenPrompt.tsx b/gitnexus-web/src/components/AccessTokenPrompt.tsx new file mode 100644 index 000000000..a903e9efc --- /dev/null +++ b/gitnexus-web/src/components/AccessTokenPrompt.tsx @@ -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 ( +
+
+
+ +
+

+ {t('accessToken.title')} +

+

+ {t('accessToken.promptHint')} +

+
+ + + + + +

+ {t('accessToken.sessionNote')} +

+ + ); +}; diff --git a/gitnexus-web/src/components/DropZone.tsx b/gitnexus-web/src/components/DropZone.tsx index 389f99869..8fd8b6621 100644 --- a/gitnexus-web/src/components/DropZone.tsx +++ b/gitnexus-web/src/components/DropZone.tsx @@ -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) => { )} + {/* 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 && ( + { + // 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 && ( {displayPhase === 'onboarding' && } {displayPhase === 'analyze' && } diff --git a/gitnexus-web/src/components/SettingsPanel.tsx b/gitnexus-web/src/components/SettingsPanel.tsx index e6b933e49..0c3a22aec 100644 --- a/gitnexus-web/src/components/SettingsPanel.tsx +++ b/gitnexus-web/src/components/SettingsPanel.tsx @@ -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(loadSettings); const [showApiKey, setShowApiKey] = useState>({}); + /** 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>(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 */}
+ {/* 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. */} +
+ + +

{t('settings:accessToken.hint')}

+
+ {/* Local Server */} {backendUrl !== undefined && onBackendUrlChange && (
diff --git a/gitnexus-web/src/components/settings/SecretInput.tsx b/gitnexus-web/src/components/settings/SecretInput.tsx new file mode 100644 index 000000000..67cb3f9ce --- /dev/null +++ b/gitnexus-web/src/components/settings/SecretInput.tsx @@ -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 `