Compare commits

...
30 Commits
Author SHA1 Message Date
gitnexus-release-bot[bot] 4b63d0c959 release: v1.6.8-rc.20 2026-06-12 06:27:16 +00:00
Gergő Magyar 28f3b99822 chore(devcontainer): simplify Dockerfile and devcontainer.json by removing version args for AI CLIs (#2174) 2026-06-12 07:13:33 +01:00
azizur100389 bdb824cfe4 feat(cli): add circular import cycle check (#2166) 2026-06-12 04:53:17 +01:00
Minidoracat 10d1e47df3 fix(hooks): bound db-lock probe subprocesses and gate probe behind hook slot (#2163) (#2165)
* fix(hooks): bound db-lock probe subprocesses and gate probe behind hook slot (#2163)

The Claude PreToolUse db-lock probe leaks orphaned lsof processes when
the hook process is hard-killed mid-probe (e.g. Claude Code's 10s hook
timeout under load). Orphans accumulate, raise load, slow the next
probe, and snowball to sustained 100% CPU.

- Wrap the unix lsof/ps fallback in coreutils timeout (-k 1 2 / -k 1 1),
  resolved via a lazy self-test, so probe children self-destruct within
  ~3s even if the hook is SIGKILLed. GITNEXUS_HOOK_TIMEOUT_PATH
  overrides the guard binary; the sentinel value 'disabled' turns the
  guard off; hosts without a usable guard keep the previous behavior.
- Acquire the per-repo hook slot before probing (all three adapters),
  bounding concurrent probes to 3 per .gitnexus, with probe and augment
  inside try/finally so the slot is always released.
- Tests: source-order contract, slot-gating behavior, orphan reaping
  with a SIGTERM-immune fake lsof and a SIGKILLed parent (red on base),
  probe-copy byte parity, no-guard equivalence, broken-guard rejection.

Note: pre-commit typecheck skipped; the 62 tsc errors are pre-existing
on main (all in src/core/** and src/server/, none in files touched
here; base==head invariant verified).

* fix(hooks): address tri-review P3 findings (#2165)

- Map guard signal-death (status null + signal, no spawnSync error) to
  fail-closed at both the lsof and ps call sites, closing the freeze
  window (SIGSTOP / laptop sleep > 2s) that previously landed fail-open.
  Rewrite the exit-code comments: coreutils surfaces the -k kill as
  signal death, 124 is budget expiry (live arm), 137 covers only
  exit-code-propagating wrappers or an externally SIGKILLed child.
- Add a debug-gated 'augment skipped: hook slots saturated' stderr line
  on the slot-starved early return in all three adapters, restoring
  observability under GITNEXUS_DEBUG=1.
- GITNEXUS_HOOK_TIMEOUT_PATH now participates in candidate fall-through:
  the env candidate is tried first, then the built-ins, each behind the
  lazy self-test — an existing-but-unusable env path (directory,
  non-executable) can no longer silently disable orphan containment.
- Tests: +6 — guard exit 124 pins the live arm (CJS+Plugin), guard
  signal-death pins the new mapping (CJS+Plugin, red before the fix),
  antigravity behavioral slot-gate, env-dir fall-through still reaps a
  SIGTERM-immune orphan via a built-in guard.

Note: pre-commit typecheck skipped; the 62 tsc errors are pre-existing
on main (none in files touched here).
2026-06-11 15:38:13 +01:00
Gergő Magyar bde340a5b4 feat(cfg): intra-procedural REACHING_DEF data-dependence layer (#2082) (#2160)
* fix(cfg): route early exits through finally with target-relative threading (#2082 U2)

* feat(cfg): harvest per-statement def/use facts into the side channel (#2082 U1)

* feat(cfg): add reaching-definitions solver with GEN/KILL fixpoint + statement sweep (#2082 U3)

* feat(cfg): persist budgeted REACHING_DEF projection with RepoMeta coherence (#2082 U4)

* test(cfg): REACHING_DEF snapshot, pipeline both-sinks, and cache-seam coverage (#2082 U5)

* bench(cfg): reaching-defs scaling gates — dense-bindings + fact-fanout scenarios (#2082 U6)

* fix(mcp): exclude BasicBlock pseudo-symbols from detect_changes on pdg indexes (#2082 U7)

* style: prettier pass over M2 files

* fix(cfg): review-pass fixes — defKey overflow guard, catch-param block, class defs, intra-statement reads, graceful fact degradation (#2082)

- reaching-defs: STMT_STRIDE 2^16→2^21 + upfront aliasing bail-out; a use
  that shares its statement with a def now also sees the same-statement def
  (assign-and-test idiom was a taint false negative); drop dead posInOrder
- visitor: catch-param def gets its own once-executed block (prepending into
  a loop-header entry re-genned per iteration and killed loop-carried
  redefs); unresolved-label jumps now thread all active finallys; the
  finalizer-threading protocol moved to control-flow-context as shared
  helpers for future language visitors
- harvest: class declarations def their name (was a bogus use in JS, silent
  skip in TS); class-expression names stay internal
- emit: isEmitSafeCfg adds index==position contiguity; fact validation split
  into hasEmitSafeFacts so malformed facts degrade to CFG-only instead of
  dropping the function's whole CFG layer; facts-per-edge multiplier single
  source; lazy top-binding tally; dead solveMs removed
- run-analyze: pdgModeMismatch compares the key union structurally — new
  resolved knobs join the comparison automatically
- mcp: BasicBlock exclusion via id prefix (NULL-name rows of real symbols
  are no longer dropped) + same filter on the BM25 filePath fallback
- bench: rd ratio denominator clamped (gate no longer self-disables at fast
  small-N); PROF-gated pdg timing in run.ts

* test(run-analyze): model the M2 RepoMeta.pdg stamp in resolvePdgConfig defaults

The DEFAULTS constant lacked the maxReachingDefEdgesPerFunction field that
resolvePdgConfig resolves since the M2 stamp landed, failing two strict
toEqual expectations (the CI 'tests' job failures). Models M2 steady-state
equality; the M1-era-stamp upgrade path stays pinned in pdg-mode-flip.test.ts.

Finding P1-4 of review 4471987625 (#2160).

* test(cfg): reassign the shadowing fixture's bindings — fixes prefer-const CI errors

Both withShadowing let bindings now genuinely reassign (s = s + 1 per scope),
clearing the two prefer-const errors that failed quality/lint. Plain const
would change the binding kind the harvest test exercises; reassignment keeps
the let semantics and enriches the reaching-defs facts the snapshot pins
(snapshot + per-binding assertion updated accordingly).

Finding P2-6 of review 4471987625 (#2160).

* fix(cfg): validate entry/exit indices in the emit-safety guard

A corrupted side-channel element with an out-of-range entryIndex passed
isEmitSafeCfg and threw inside the reaching-defs RPO walk — caught by the
per-FILE try/catch, costing every sibling function's REACHING_DEF projection
instead of the one element (and logging a misleading message). entry/exit
join the guard's id-anchor checks.

Finding P3 (entryIndex) of review 4471987625 (#2160).

* fix(cfg): report the def-key stride bail-out as a distinct 'overflow' status

The STMT_STRIDE aliasing guard reused status 'truncated', so the emit warn
misnamed it as the fact-materialization limit (printing an unrelated maxFacts
value, including '(0)' when unlimited) and telemetry conflated the two. A
distinct 'overflow' status gets its own warn naming the actual cause; the
function's CFG layer is explicitly unaffected.

Finding P3 (stride-bail diagnosis) of review 4471987625 (#2160).

* perf(cfg): cache the nearest enclosing scope per node during the prescan

resolve() walked the AST parent chain per identifier — O(expression nesting
depth), quadratic on deeply-chained single-statement expressions in generated
code (not caught by any bench scenario, which scale blocks/bindings, not
expression depth). The prescan already visits every node once, so caching its
innermost scope makes phase-2 resolution O(scope-chain). Behavior-identical;
the parent-chain walk survives as fallback for prescan-unvisited nodes.

Finding P2 (resolve depth walk) of review 4471987625 (#2160).

* fix(cfg): stop harvesting initializer-less var declarators as defs

A bare `var x;` mid-function is hoisted and writes nothing at runtime, but
the harvester recorded a def — fabricating a kill of the live def in the
same block: `x = source(); var x; sink(x)` lost the source→sink fact (a
reaching-defs false negative). Defs now require an initializer for
variable_declaration declarators; let/const genuinely initialize and keep
their def.

Finding P2-5 of review 4471987625 (#2160).

* fix(cfg): unwrap parenthesized/non-null lvalue wrappers before def detection

`(x) += 1` and `(x)++` gated the def on the node type being exactly
'identifier', so the parenthesized form fell to the uses-only branch — the
def (and its kill) silently vanished. Wrappers that don't change the lvalue
(parenthesized_expression, TS non_null_expression) now unwrap at all three
lvalue sites.

Finding P3 (parenthesized lvalues) of review 4471987625 (#2160).

* fix(cfg): conditionally-evaluated defs are MAY-defs — gen without kill

A def inside a short-circuit right operand, ternary arm, logical assignment,
or switch case test was harvested as a must-def; the solver's total kill then
erased the prior def on the not-taken path — a taint false negative on core
idioms (`if (a && (x = clean())) {} sink(x)` lost source→sink;
`cached ?? (cached = load())` likewise). StatementFacts gains an optional
mayDefs field (conditional-context tracking in the harvester); the solver's
per-block GEN carries {set, kills} so a may-def UNIONS into the binding's set
instead of replacing it, in both the transfer and the statement sweep; the
emit fact-guard validates mayDefs indices; switch case tests harvest via the
conditional path.

Finding P1-1 of review 4471987625 (#2160).

* fix(cfg): model labeled statements generically — break keeps its real continuation

A break to a label the visitor didn't model (labeled non-loop block, the
OUTER label of a doubly-labeled construct) routed to EXIT, REMOVING the only
path that kept the pre-jump def live — a reaching-defs false kill the in-code
comment wrongly called sound. Loop/switch frames now carry their full label
LIST (`outer: inner: for` resolves both); a labeled non-loop statement gets
a break-target frame whose target is a synthesized join after the body; an
unlabeled break never matches a block frame; labels compose with finalizer
threading (a labeled break crossing a finally still threads it).

Finding P1-2 of review 4471987625 (#2160).

* fix(cfg): throw edges deliver ALL of a block's defs to the handler

The throw contribution was IN ∪ OUT — entry and final states only. The
intermediate defs of a multi-def coalesced block were invisible to the
handler, though they are exactly what the catch observes when a later
statement throws: `try { x = parse(a); x = normalize(x); } catch { sink(x) }`
lost the parse→sink fact (normalize throwing delivers parse's value). Throw
predecessors now contribute IN(from) ∪ allDefs(from) — a static per-block
all-def-sites map — which subsumes OUT; monotone and deterministic.

Finding P1-3 of review 4471987625 (#2160).
2026-06-11 05:49:39 +01:00
dependabot[bot]andGergő Magyar 1150eea98f chore(deps): bump actions/checkout from 6.0.2 to 6.0.3 (#2152)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.2 to 6.0.3.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...df4cb1c069e1874edd31b4311f1884172cec0e10)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-06-10 21:58:49 +01:00
dependabot[bot] ae1ec82f5f chore(deps): bump actions/attest-build-provenance from 2.4.0 to 4.1.0 (#2158)
Bumps [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) from 2.4.0 to 4.1.0.
- [Release notes](https://github.com/actions/attest-build-provenance/releases)
- [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md)
- [Commits](https://github.com/actions/attest-build-provenance/compare/v2.4.0...a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32)

---
updated-dependencies:
- dependency-name: actions/attest-build-provenance
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 21:55:17 +01:00
dependabot[bot] 5a590f052f chore(deps): bump docker/setup-qemu-action from 4.0.0 to 4.1.0 (#2159)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 4.0.0 to 4.1.0.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/ce360397dd3f832beb865e1373c09c0e9f86d70a...06116385d9baf250c9f4dcb4858b16962ea869c3)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 21:55:03 +01:00
dependabot[bot] df56d34f33 chore(deps): bump release-drafter/release-drafter from 7.3.0 to 7.3.1 (#2157)
Bumps [release-drafter/release-drafter](https://github.com/release-drafter/release-drafter) from 7.3.0 to 7.3.1.
- [Release notes](https://github.com/release-drafter/release-drafter/releases)
- [Commits](https://github.com/release-drafter/release-drafter/compare/c2e2804cc59f45f57076a99af580d0fedb697927...693d20e7c1ce1a81d3a41962f85914253b518449)

---
updated-dependencies:
- dependency-name: release-drafter/release-drafter
  dependency-version: 7.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 21:52:55 +01:00
dependabot[bot] 04aeb1f4bf chore(deps)(deps-dev): bump @vercel/node in /gitnexus-web (#2156)
Bumps [@vercel/node](https://github.com/vercel/vercel/tree/HEAD/packages/node) from 5.8.8 to 5.8.12.
- [Release notes](https://github.com/vercel/vercel/releases)
- [Changelog](https://github.com/vercel/vercel/blob/main/packages/node/CHANGELOG.md)
- [Commits](https://github.com/vercel/vercel/commits/@vercel/node@5.8.12/packages/node)

---
updated-dependencies:
- dependency-name: "@vercel/node"
  dependency-version: 5.8.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 21:52:41 +01:00
dependabot[bot] 792cd96d37 chore(deps): bump actions/setup-python from 5.6.0 to 6.2.0 (#2155)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5.6.0 to 6.2.0.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5.6.0...a309ff8b426b58ec0e2a45f0f869d46889d02405)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: 6.2.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 21:52:24 +01:00
dependabot[bot] 351edca0ca chore(deps)(deps-dev): bump @vitest/coverage-v8 in /gitnexus-web (#2153)
Bumps [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) from 4.1.5 to 4.1.8.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.8/packages/coverage-v8)

---
updated-dependencies:
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 21:51:57 +01:00
dependabot[bot] 15e141f21b chore(deps)(deps): bump sigma from 3.0.2 to 3.0.3 in /gitnexus-web (#2151)
Bumps [sigma](https://github.com/jacomyal/sigma.js) from 3.0.2 to 3.0.3.
- [Release notes](https://github.com/jacomyal/sigma.js/releases)
- [Changelog](https://github.com/jacomyal/sigma.js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/jacomyal/sigma.js/compare/sigma@3.0.2...sigma@3.0.3)

---
updated-dependencies:
- dependency-name: sigma
  dependency-version: 3.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 21:50:52 +01:00
dependabot[bot] 0b2c88518e chore(deps)(deps): bump dompurify from 3.4.7 to 3.4.8 in /gitnexus-web (#2150)
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.7 to 3.4.8.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.7...3.4.8)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 21:50:41 +01:00
dependabot[bot] 8bb2fe9128 chore(deps)(deps): bump langchain from 1.4.2 to 1.4.4 in /gitnexus-web (#2149)
Bumps [langchain](https://github.com/langchain-ai/langchainjs) from 1.4.2 to 1.4.4.
- [Release notes](https://github.com/langchain-ai/langchainjs/releases)
- [Commits](https://github.com/langchain-ai/langchainjs/compare/langchain@1.4.2...@langchain/openai@1.4.4)

---
updated-dependencies:
- dependency-name: langchain
  dependency-version: 1.4.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 21:50:30 +01:00
6424d8b09c fix(web): replace broken Browse-for-folder with upload directory picker (#1850)
* fix(web): replace broken Browse-for-folder with server-side directory picker

The "Browse for folder" button used `<input type="file" webkitdirectory>`
which only exposes relative paths via `webkitRelativePath`. The code
extracted just the folder name (e.g. `myproject`), causing the server to
reject it with "path must be an absolute path". No browser API can
expose absolute filesystem paths, so the approach was fundamentally
broken on all platforms.

- Add `GET /api/fs/list` endpoint that lists subdirectories at a given
  absolute server-side path (rate-limited, validated)
- Add `listDirectories()` client function in backend-client.ts
- Add `DirectoryPicker` modal component with breadcrumb navigation
- Replace broken `webkitdirectory` input in RepoAnalyzer with the new
  server-side directory picker
- Update i18n strings (en + zh-CN)
- Add unit tests for the new endpoint (9 tests)

Docker users can now browse `/workspace/` and other container paths
directly from the UI. Manual path entry continues to work unchanged.

Closes #1518

* test(e2e): add Playwright tests for server-side directory picker

13 Playwright e2e tests covering the full DirectoryPicker flow:
- Open/display: modal opens, shows root dirs, displays current path
- Navigation: click into dirs, breadcrumb back-nav, home button
- Selection: populates path input, returns absolute path, close without selecting
- Edge cases: empty dir, API error, manual typing still works

Also updates existing onboarding.spec.ts to match the renamed
"Browse server directories" button, and adds data-testid attributes
to DirectoryPicker and RepoAnalyzer for reliable e2e targeting.

* fix(a11y): add accessibility and UX polish to DirectoryPicker

- Add role="dialog", aria-modal, aria-label to the modal panel
- Add aria-label to close button, home button
- Add aria-hidden to decorative icons (chevrons, backdrop)
- Add role="status" to loading spinner with sr-only label
- Add role="alert" to error state
- Add aria-current="location" to active breadcrumb segment
- Wrap breadcrumb in nav landmark with aria-label
- Add Escape key handler to dismiss the modal
- Auto-focus the modal panel on open
- Add focus-visible ring styles to all interactive elements
  (matches existing focus-visible:ring-2 ring-accent/40 pattern)
- Increase breadcrumb button padding (px-1.5 py-1) for better
  touch targets
- Increase directory entry padding (py-2.5) for touch comfort
- Add active:bg-hover/70 pressed state on directory entries
- Add active:bg-accent/80 pressed state on select button

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix: skip traversal guard for bare root paths in /api/fs/list (#2109)

* fix(web): replace server-side directory picker with secure folder upload

PR #1850 review found the new GET /api/fs/list directory-browsing endpoint
enumerated any absolute server path (CodeQL js/path-injection, plus a DoS and
cross-origin enumeration via the CORS/PNA allow-list). Browsers can't hand the
server an absolute path, so rather than harden the endpoint, remove it and
upload the folder instead — webkitdirectory exposes the file contents.

- Add POST /api/analyze/upload: busboy-streamed multipart ingest into an
  mkdtemp sandbox under UPLOAD_ROOT with resolve-then-contain write
  sanitization, hard size/count/dir caps, manifest-first ordering, and
  guaranteed cleanup; promote (atomic same-filesystem rename, no EXDEV) and
  analyze via the shared job/worker machinery, never returning a server path.
- Frontend: <input webkitdirectory> upload flow with client-side filtering
  (.git/node_modules/build), XHR progress, accessibility, en/zh-CN i18n.
- Remove /api/fs/list + handleFsListRequest, DirectoryPicker, listDirectories
  and their tests.
- Harden the adjacent /api/analyze {path} route: localhost-only CORS on write
  routes + realpath/exists/isDir validation replacing the inert
  normalize!==resolve guard.
- Extend DELETE /api/repo cleanup to upload dirs (by entry.path) and add a
  startup sweep for orphaned staging dirs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(review): resolve CodeQL path-injection + CSRF introduced by the upload change

The first push surfaced two new CodeQL alerts in the newly-added code (the
upload sandbox itself passed — its resolve-then-contain sanitizer is recognized):

- HIGH js/path-injection at the analyze route: the KTD11 in-route
  `fs.realpath(repoLocalPath)` / `fs.stat` was a user-controlled filesystem
  read with no security gain (the worker already reads the path; cross-origin
  reach is closed by requireLocalhostOrigin). Drop the in-route fs calls; keep
  only the absolute-path check + the localhost-origin guard.
- MEDIUM js/client-side-request-forgery: the new raw `xhr.open` was a fresh
  request sink. Route the upload through the shared, origin-validated
  fetchWithTimeout instead (the centralized sink all other calls use). Trades
  the upload-progress percentage for an indeterminate "Uploading…" state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(review): resolve tri-review findings on the upload flow

A multi-agent review of the upload implementation surfaced a P0 plus several
P2/P3s; all are addressed here.

- P0: the upload handler took the single analysis slot (createJob) before
  validating/promoting, so any failure in that window left a queued job that
  was never failed — wedging ALL analysis until restart (trivially triggered by
  a single-segment manifest). Now: validate the folder before taking the slot,
  release it via failJob on any pre-launch error, and reject single-segment /
  multi-top manifests during ingest (also fixes a silent file-drop).
- CI: rate-limit.test's source-regex broke when Prettier wrapped the
  /api/analyze registration; made it wrapping-tolerant.
- Resource: the startup sweep now also removes stale promoted upload dirs with
  no .gitnexus index (orphans from analyses that failed before registering).
- Frontend: guard against post-unmount SSE opening, reset upload state on
  cancel/mode-change, guard concurrent uploads, fall back to the folder name,
  add aria-busy, and fix the {{count}} plural ("1 files").
- Maintainability: extract launchAnalysisWorker into analyze-launch.ts (DI +
  typed WorkerMessage IPC), move requireLocalhostOrigin to middleware.ts, share
  REPO_NAME_PATTERN, tighten UploadJobRef, name the collision-retry constant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): reset isMountedRef on mount (StrictMode double-invoke)

The mount effect set isMountedRef=false on cleanup but never back to true on
re-mount, so under React StrictMode's mount->unmount->mount the ref stayed
false for the component's lifetime — trackJob then always early-returned and
the upload never advanced past 'starting' (caught by the folder-upload e2e).
Set it true at the start of the effect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(review): de-flake upload-ingest cleanup test via injectable staging root

ingestUpload gains an IngestOptions.root override (mirroring SweepOptions.root)
so the test asserts cleanup against a per-test mkdtemp root instead of counting
global ~/.gitnexus/uploads/.staging-* entries, which raced parallel forks.
Production default stays UPLOAD_ROOT (promote rename same-filesystem invariant).

* fix(web): make stale analyze/upload requests inert after mode switch, cancel, or unmount

A folder upload (or URL analyze) still in flight when the user switched modes
could resolve later, call trackJob(), and drive the old job's SSE stream under
the new mode's form. The only guard was isMountedRef — mode change and cancel
never unmount the component.

- requestControllerRef: per-request AbortController doubling as the staleness
  token (captured per closure, checked after the await; the abort error is
  matched via signal.aborted, never error identity, since it surfaces both as
  BackendError('Request aborted') and as a raw AbortError from response.json())
- uploadFolder() now takes an optional AbortSignal; fetchWithTimeout already
  merges caller signals via AbortSignal.any
- a stale-but-created job gets a fire-and-forget cancelAnalyze(jobId) (skipped
  when a live tracking session owns the id) so the single analyze slot is freed
- handleModeChange early-returns on same-tab clicks and resets phase to input
  so an aborted request can't strand the form at 'starting'
- fixed the stale breaker comment: resilientFetch records AbortError as
  breaker-neutral (recordNeutral), not as a retryable-network penalty

* refactor(web): consolidate stale-request guard plumbing

- single invalidateRequest() helper for the abort+null pattern (4 sites)
- drop isMountedRef checks subsumed by the aborted-controller token
  (unmount aborts the controller, and unlike isMountedRef the token stays
  correct across a StrictMode unmount/remount)
- dedup the component test's render/mock scaffolding
- countStaging filters on the exported STAGING_PREFIX, not a magic string

* fix(web): scope stale-job cancellation to the upload path

Code review caught a regression in the first cut: URL analyzes dedup-alias by
repo (createJob returns the existing active job's id), so a stale resolution's
fire-and-forget cancel could kill a job another session — or the user's own
fresh resubmit — is actively watching; the jobIdRef ownership guard was
order-dependent and instance-local. Uploads always own a fresh, never-deduped
job, so the cancel is kept (unconditionally) there and dropped on the URL path,
where a same-URL resubmit re-attaches via dedup and the server's job timeout /
TTL sweep bounds the slot occupancy.

Also: remove the isMountedRef machinery outright (zero readers remain — the
aborted-controller token subsumes it and stays correct across StrictMode
remounts), make the e2e abort check ERR_ABORTED-specific, and let a broken
test root fail loudly instead of passing vacuously.

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Sparsh <73558748+prajapatisparsh@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:50:59 +01:00
Gergő MagyarandClaude Opus 4.8 5bf8a17cd5 feat(ingestion): add control-flow-graph layer for TS/JS (#2081) (#2099)
* feat(cfg): language-agnostic CFG construction core (#2081)

U1 of M1 (CFG layer). Plain JSON-serializable CFG data model (BasicBlockData/
CfgEdgeData/FunctionCfg — must survive the worker→main boundary + ParsedFile
store), a CfgBuilder accumulator (leaders→blocks→edges, synthetic ENTRY/EXIT,
idempotent edges), a ControlFlowContext (break/continue/switch + labeled-jump
target stacks), and a TraversalResult ({entry, dangling exits}). AST-agnostic
and unit-tested on the classic control-flow topologies (if/else, while back-edge,
mid-block return, labeled break/continue) the S2 spike validated; reachability
helper backs the R9 property test.

* feat(ingestion): U2 — TS/JS CFG visitor over tree-sitter AST (#2081)

Add the TS/JS CfgVisitor that walks a function's tree-sitter AST and drives
the U1 CfgBuilder to produce a serializable FunctionCfg. One visitor covers
both languages (shared grammar family).

Handles the classic CFG hazards explicitly (R2, R10):
- loops allocate a dedicated loop-exit block so `break` has a concrete target
  before the loop's successor is known; `continue`/back-edge close the loop
  (while, do-while, C-for with init-once + increment-as-continue-target,
  for-in, for-of)
- switch fallthrough falls out naturally: a non-breaking case yields exits we
  wire to the next case as `fallthrough`; a breaking case wires to the switch
  exit via ControlFlowContext
- try/catch/finally: normal completion AND exceptional flow both route through
  finally (post-domination); a conservative exceptional edge models that the
  protected region may raise to its handler (not just explicit `throw`)
- labeled break/continue resolve against the labeled loop's frame
- early return/throw wire to EXIT/handler and terminate their block

19 hazard tests (one per construct) + AC1 10-function fixture; all green.
No change to the committed U1 core or ControlFlowContext.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ingestion): U3 — worker CFG build + cfgSideChannel + cache coherence (#2081)

Run the CFG visitor in the parse worker (where the AST lives), serialize the
per-function CFG onto a new ParsedFile.cfgSideChannel, and keep it coherent
across the disk-backed store and the warm/durable parse cache (R3, R4).

- gitnexus-shared parsed-file.ts: add `cfgSideChannel?: unknown` as a DISTINCT
  field from captureSideChannel (different producer/consumer/lifecycle; plain
  JSON data — blocks/edges deliberately lack the `nodeId` the store's interning
  reviver keys on, so no mis-interning).
- cfg/types.ts + visitors/typescript.ts: add CfgVisitor.isFunction so the worker
  enumerates functions (and applies the line budget) by a cheap node-type test.
- cfg/collect.ts (new): collectFunctionCfgs walks the tree, builds one CFG per
  function (nested included), applies maxFunctionLines (over-cap = skipped).
- language-provider.ts: add `cfgVisitor?: CfgVisitor<SyntaxNode>` hook;
  typescript.ts attaches it to both the TS and JS providers (shared grammar).
- parse-worker.ts: read pdg + pdgMaxFunctionLines from workerData (read once at
  init — the worker never sees PipelineOptions), gate the build, attach
  cfgSideChannel alongside captureSideChannel.
- parse-cache.ts: bump SCHEMA_BUMP 4→5 (ParsedFile shape changed) and fold the
  pdg flag into computeChunkHash so a pdg-off cached chunk is NOT reused on a
  --pdg run (the #2038-class warm-cache trap). Default path keeps its keys.
- worker-pool.ts + parse-impl.ts + pipeline.ts: thread pdg/pdgMaxFunctionLines
  PipelineOptions → WorkerPoolOptions → workerData, and into the chunk-hash key.

9 boundary tests: collect contract, JSON round-trip identity (no AST leakage),
the pdg cache-key guard, the line-cap skip, and the no-visitor gate. Full CFG
suite (U1+U2+U3) green; build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ingestion): U4 — emit BasicBlock + CFG within scope-resolution (#2081)

Emit persisted BasicBlock nodes + CFG edges from each ParsedFile's worker-built
cfgSideChannel, INSIDE scope-resolution's Phase-4 graph emission — the last
point where the worker-built CFGs are loaded (emitParsedFiles carries the
channel; the disk store is cleared right after the orchestrator returns). This
is the architecture the doc-review corrected to: a standalone post-`mro` phase
(the issue's literal subtask) provably reads empty data (KTD1).

- cfg/emit.ts (new): pure emitFileCfgs(graph, cfgs, maxEdgesPerFunction, onWarn).
  BasicBlock id = `BasicBlock:<filePath>:<functionStartLine>:<blockIndex>`
  (KTD3 — funcStart disambiguates blocks across functions in one file; no
  `name` column). CFG edge = CodeRelation type 'CFG' with the edge KIND
  (seq/cond-true/…) in `reason` (kinds can't be their own edge type). Per-
  function edge cap stops at the cap and warns with the dropped count — no
  silent truncation (R6/KTD6).
- run.ts: pdg-gated emit pass over emitParsedFiles after emitPostResolutionEdges
  (store still live); RunScopeResolutionInput gains pdg + pdgMaxEdgesPerFunction.
- phase.ts: thread ctx.options.pdg / pdgMaxEdgesPerFunction into the call.
- pipeline.ts: PipelineOptions.pdgMaxEdgesPerFunction.

6 tests: node/edge shape (KTD3 id, no name, type='CFG', kind in reason),
cross-function id uniqueness, AC2 reachability-from-ENTRY property, the edge
cap's no-silent-truncation contract, and empty-input no-op. Flag-off
byte-identity + full runPipelineFromRepo round-trip land in U7. Build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(cli): U5 — `--pdg` opt-in plumbing (CLI + .gitnexusrc → both sinks) (#2081)

Expose the CFG/PDG substrate as an opt-in and thread it from CLI/.gitnexusrc to
the single source of truth (PipelineOptions.pdg), which fans out to BOTH sinks
already wired in U3/U4: the worker build gate (workerData.pdg) and the
scope-resolution emit gate. Off by default (R7).

- cli/index.ts: `--pdg` commander flag.
- cli/analyze.ts: AnalyzeOptions.pdg + pass `pdg` into runFullAnalysis options.
- cli/analyze-config.ts: KEY_SPECS `pdg` (boolean) so `.gitnexusrc { "pdg": true }`
  normalizes and a non-boolean value fails closed with GitNexusRcError.
- core/run-analyze.ts: AnalyzeOptions.pdg → runPipelineFromRepo({ pdg }).

(The internal PipelineOptions/WorkerPoolOptions/workerData fields + the
parse-cache key fold landed in U3/U4; this unit adds the user-facing surface.
The budget knobs stay at internal defaults for M1.)

Tests: analyze-config pdg normalization + non-boolean rejection; opt-in.test.ts
covers the CLI/file merge precedence and that pdg perturbs the chunk-dispatch
key. The full worker-build + main-emit round-trip is the U7 integration test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ingestion): U7 — CFG acceptance fixtures, parity, end-to-end + docs (#2081)

Acceptance criteria for the M1 CFG layer:
- AC1: a 10-function TS fixture's CFG node/edge set matches a committed snapshot
  (cfg-snapshot.test.ts).
- AC2: every BasicBlock is reachable from its function ENTRY (property test over
  the emitted graph; the fixture has no dead code).
- AC3: hazard fixtures lock the classic-bug coverage — try/throw/finally
  post-domination + labeled break/continue resolution.
- AC4: the existing pipeline-graph-golden test stays byte-identical with --pdg
  off (verified; no UPDATE_GOLDEN), proving the opt-in adds zero default-run
  drift.
- End-to-end (pipeline-pdg.test.ts): runPipelineFromRepo({ pdg: true }) on a
  tiny repo emits BasicBlock nodes + CFG edges with both endpoints present —
  the true both-sinks proof (worker builds → store → scope-resolution emits);
  the default run emits zero.

Docs: CHANGELOG M1 entry, ARCHITECTURE "Optional CFG/PDG emission" subsection
(why emit is in-phase, not post-mro), README CFG language-support note.

Full CFG suite (U1–U7): 56 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ingestion): drop unused helper in cfg-snapshot test (#2081)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(review): apply ce-code-review autofix feedback (#2081)

Review (10 reviewers) confirmed OFF-path byte-identity (adversarial + golden)
and found defects all within the --pdg path. Fixes:

- P1 same-line BasicBlock id collision: add a start-column disambiguator to
  FunctionCfg + the id (`BasicBlock:<file>:<line>:<col>:<idx>`) so two functions
  sharing a start line no longer collide under first-writer-wins addNode.
- P1 worker crash-cascade: per-file try/catch around collectFunctionCfgs so a
  CFG-build throw cannot escape to the language-group catch and silently drop
  every remaining file in the group.
- P2 edge-cap drop now logs unconditionally (input.onWarn is validator-gated/
  silent in prod) — upholds the no-silent-truncation guarantee.
- P2 Array.isArray guard before the cfgSideChannel cast in run.ts.
- P2 maxFunctionLines default: worker applies DEFAULT_PDG_MAX_FUNCTION_LINES=2000
  when unset; caps forwarded through run-analyze AnalyzeOptions (closes the
  server-path drop).
- P3 README duplicate paragraph removed; `0`-vs-default docstrings corrected;
  CLI --pdg flag made language-neutral; reachableBlocks JSDoc corrected.
- Documented the break-through-finally + stacked-label CFG limitations.
- Tests: same-line id-collision regression, standalone throw→EXIT, dead-code-
  after-return, async/generator/method coverage, strengthened labeled-continue.

Refuted: the HTTP-500 getNodeQuery finding — M0 already shipped the BasicBlock
branch + name-floor (R12/web-safety handled).

CFG + analyze-config suites: 95 tests green; golden parity (AC4) byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(ingestion): benchmark CFG construction + O(n) block-text accumulation (#2081)

Closes the M1 review's requires_verification perf gap ("no benchmark for
collectFunctionCfgs; a wall-time + cfgSideChannel byte-size regression gate
would catch the extendBlock concatenation before kernel scale").

- bench/cfg/measure.mjs (new): build-free tsx harness timing collectFunctionCfgs
  (parse once, reuse the tree) across three scaling scenarios — straight-line
  (extendBlock path), many-functions (collect walk), branchy (block/edge growth)
  — at 500→2000. Reports a wall-time scaling ratio AND a cfgSideChannel
  byte-size ratio, plus an order-independent sha256 over the emitted blocks/edges
  as the behavior gate. `--check` compares both ratios + the fingerprint against
  bench/cfg/baselines.json; mirrors the scope-capture / python-scope harnesses.
- .github/workflows/ci-tests.yml: run the gate on every test job (build-free,
  alongside the existing scope-capture guards) so an O(n^2) re-regression fails CI.
- cfg-builder.ts: structural fix for the one real hotspot the bench surfaced —
  accumulate basic-block text as fragments joined once in finish(), instead of
  concatenating onto a growing string per coalesced statement (O(n^2) → O(n)).
  Behavior-identical (the CFG fingerprint + the AC1 snapshot are unchanged).

Measured (post-fix): time ratios straight-line ~1.3, many-functions ~1.0,
branchy ~1.1 (all sub-quadratic; a true O(n^2) would be ~4.0). cfgSideChannel
bytes scale linearly (~1.0-1.04). 60 CFG tests green; build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(ingestion): add memory + disk growth gates to the CFG benchmark (#2081)

Extend bench/cfg/measure.mjs beyond wall-time to the two other scalability
dimensions that matter at kernel scale:

- DISK growth: utf8 byte size of the serialized cfgSideChannel — exactly what a
  --pdg run writes onto every ParsedFile shard (durable store + parse cache).
- MEMORY growth: retained JS heap of the cfgSideChannel payload, measured by the
  release-delta method (heap held minus heap after dropping it) — robust to
  pre-existing garbage and dead-stable run-to-run. Needs `node --expose-gc`;
  without it the heap metric is null and its gate is skipped (local runs still
  work). ci-tests.yml now passes --expose-gc so the heap gate runs in CI.

Both gated on linear scaling in baselines.json (disk_bytes_budget / heap_budget
1.2-1.3). Measured: disk ~1.0-1.04, retained heap ~0.87-1.0 — both linear
(~1KB/function each; ~2MB heap / 1.6MB disk at 2000 functions, --pdg only).
Bumped REPS 7->15 to stabilize the noisier time signal and widened the coarse
time tripwire budgets (the disk/heap gates carry the tight regression detection).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ingestion): address tri-review + CFG-expert findings (#2081)

Corroborated findings from the tri-review (Codex + CE personas + GitNexus swarm
+ a CFG/program-analysis domain-expert lane). The OFF-path stays byte-identical;
all fixes are within the --pdg path or the benchmark.

- [Codex+CFG-expert] Exceptional `throw` edges now wire EVERY block in a try's
  protected region to the handler, not just the body ENTRY. A branched try body
  (`try { if (x) { use(t); } } catch`) previously left interior blocks with no
  path to `catch` — a taint false-negative into the handler for the M2 PDG pass.
- [Codex+CFG-expert] An unresolved labeled jump (a stacked outer label or a
  labeled non-loop block) now routes to the function EXIT instead of leaving a
  dangling sink — restores the single-exit invariant post-dominator/PDG
  computation needs.
- [Codex] computeChunkHash now folds pdgMaxFunctionLines/pdgMaxEdgesPerFunction
  into the chunk key (not just the pdg boolean), so a warm cache built under one
  cap is never served to a run with a different cap (#2038 class, extended to
  the budgets). Adds PdgCacheKey; boolean form kept for back-compat.
- [perf] visitTry resolves catch/finally in a single namedChild pass (the double
  `namedChildren.find` allocated two throwaway arrays).
- [adversarial] The bench `straight-line` scenario now runs at 2000->8000:
  output is a constant 4 blocks so disk/heap can't see the concat path, and at
  the old N a genuine O(n²) was masked by V8 cons-strings. Verified at the new N:
  the array-join impl ~1.0, a rope-optimized `+=` ~1.0 (correctly not flagged),
  a real O(n²) (re-join-every-append) ~3.8 — budget tightened 2.0->1.5.
- [adversarial+Codex] The bench `--check` now FAILS LOUDLY when run without
  `--expose-gc` instead of silently skipping the retained-heap gate.
- Doc: re-labeled the finally-bypass as a SOUNDNESS (false-negative) limitation
  tracked for M2, not mere "precision."

3 new regression tests (branched-try interior→handler, stacked-label→EXIT,
cap-fold key). 99 CFG tests pass; build clean; bench gate green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(parse-cache): clarify that SCHEMA_BUMP still invalidates caches once (#2099 F6)

The computeChunkHash comment claimed pdg-off warm caches "survive this
change untouched" — true for the key FORMAT, but misleading as an
upgrade-behavior promise: SCHEMA_BUMP 4→5 changes PARSE_CACHE_VERSION
and both stores hard-invalidate on it. Separate the two facts so the
next cache change isn't reasoned about from a false premise.

Review finding F6 (P3) of PR #2099 tri-review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cfg): correct for-loop back-edge kinds when no increment clause (#2099 F5)

A for with a body but no increment emitted an unconditional
header→header 'loop-back' self-edge (a path that never executes the
body) while the real back-edge body→header was labeled 'seq'. Any
consumer identifying loops via reason='loop-back' picked the phantom
edge and excluded the body from the natural loop.

Gate the self-edge on the body being absent (the one case where the
header genuinely re-tests itself) and carry 'loop-back' on the body's
exits when they ARE the back-edge, matching visitWhile/visitForIn.

Review finding F5 (P3) of PR #2099 tri-review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cfg): treat an empty catch clause as a real handler (#2099 F2)

visitTry keyed handler semantics off the traversal result — null for an
empty body, since visitSeq([]) returns null — instead of the syntactic
clause. An empty `catch {}` was therefore treated as NO catch: the
swallowed exception escaped to the outer handler/EXIT, the no-catch
re-propagation misfired past finally, and code after a try whose body
always throws became unreachable from ENTRY — a hard false-negative
source for the M2 taint pass, on an extremely common pattern.

Synthesize one empty block spanning the clause (entry == sole exit)
when the catch body traverses to null, before the protected region is
walked. Exception flow lands in it and rejoins the normal continuation;
all downstream wiring (handler selection, finally routing, the !catchRes
re-propagation gate) operates on the syntactically-correct shape.

Review finding F2 (P2, reproduced) of PR #2099 tri-review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cfg): guard CFG emission per element, not just per outer array (#2099 F4)

The cfgSideChannel guard checked only Array.isArray before casting to
FunctionCfg[] — its own comment promised a wrong-shape value would
'skip emission, not throw a TypeError mid-graph-build', but a malformed
ELEMENT sailed through. Worse, the obvious-looking failure shape never
throws at all: emitFileCfgs string-templates any edge endpoint into the
BasicBlock id and graph inserts are no-throw, so a non-integer endpoint
silently became a dangling 'BasicBlock:…:undefined' edge that degrades
the DB rel-pair COPY to row-by-row fallback inserts much later.

Layered fix matching house precedents (parsedfile-store reviver,
worker-side per-file catch): a per-element shape+content predicate
(arrays + integer edge endpoints) that warns and skips malformed
elements while valid siblings still emit, plus a per-file try/catch
backstop for shapes that genuinely throw (e.g. a null inside blocks).

Review finding F4 (P3) of PR #2099 tri-review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(parse-cache): drop emit-time edge cap from the pdg chunk key (#2099 F3)

pdgMaxEdgesPerFunction is applied exclusively in emitFileCfgs during
scope-resolution on the main thread — the worker never receives it
(workerData carries only pdg + pdgMaxFunctionLines), so the cached
worker output is byte-identical across cap values. Folding it into the
chunk key (added by a prior review round) only converted a free knob
into a repo-sized cost: every cap change forced a full re-parse and a
durable-store rewrite of unchanged data.

Keep pdg + maxFunctionLines (genuinely worker-visible, shape the cached
cfgSideChannel) and document the classification test in the PdgCacheKey
doc comment so the next option gets sorted deliberately: worker-shard
inputs go in this key; persisted-graph-only inputs belong in the
RepoMeta pdg stamp (F1). Chunks written under the old ns string miss
once and prune — no migration needed.

Review finding F3 (P2) of PR #2099 tri-review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(analyze): record pdg config in RepoMeta; force full writeback on mode flip (#2099 F1)

Running --pdg against an already-indexed repo silently persisted ~zero
CFG: incremental eligibility had no pdg term, RepoMeta recorded no
mode, and extractChangedSubgraph keeps only changed-file nodes — on a
no-change --pdg re-run every freshly built BasicBlock was dropped from
the written subgraph ('Incremental: changed=0', run succeeds, zero
rows). The converse flip left zombie mixed-coverage blocks only --force
could clean. Worse, a clean-tree flip hit the alreadyUpToDate fast path
and never ran the pipeline at all.

- RepoMeta gains an additive-optional pdg stamp ({maxFunctionLines,
  maxEdgesPerFunction}, resolved values; absent ≡ pdg-off, which covers
  every legacy meta). No INCREMENTAL_SCHEMA_VERSION bump — that would
  force a one-time full rebuild for everyone. The end-of-run meta is a
  fresh literal, so omitting the field on a pdg-off run is what clears
  the stamp after an on→off flip.
- pdgModeMismatch (pure, exported) compares the resolved triple; the
  flip check sits before the fast path and always logs its notice (not
  gated on options.force — --skills implies force with no message of
  its own), naming the .gitnexusrc pdg key that pins the mode.
- The full-rebuild branch now writes the incrementalInProgress dirty
  flag (toWriteCount: 0 sentinel) before the wipe whenever a prior meta
  exists, mirroring the incremental branch. This closes the crash
  window where a rebuild dying between the bulk load and saveMeta left
  meta/DB inconsistent and the fast path certified zombie (or missing)
  CFG rows indefinitely — and incidentally closes the same pre-existing
  hole for user --force runs. Recovery log reworded accordingly.

Tests: pdg-mode-flip.test.ts (real git + LadybugDB; primary assertion
is a direct BasicBlock table count — meta.stats aggregates
nondeterministic Community/Process rows) covering off→on, steady-state
fast path, on→off zombie cleanup, cap-change rebuild, and dirty-flag +
flip composition; pure-helper tests for default resolution and the
0=unlimited carve-out.

Review finding F1 (P1) of PR #2099 tri-review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 19:26:45 +01:00
azizur100389andGergő Magyar e26002c37a fix(cpp): suppress deleted overload winners (#2094)
* fix(cpp): suppress deleted overload winners

* test(cpp): update scope capture fingerprint

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-06-10 18:41:30 +01:00
31a2b19416 fix(storage): prevent registry wipe on transient I/O errors (#2124)
* fix(storage): prevent registry wipe on transient I/O errors

listRegisteredRepos({ validate: true }) used a bare catch {} that
treated ALL fs.access() errors as 'index gone.' Under swap pressure
or I/O storms, EIO/EAGAIN/EBUSY/EACCES errors caused ALL entries to
be pruned and writeRegistry([]) was called — permanently wiping the
registry.

Fix: only prune on ENOENT (file genuinely gone) or ENOTDIR (structural
removal). Transient errors keep the entry alive.

Includes 5 regression tests covering ENOENT, ENOTDIR, EACCES, EIO,
and EAGAIN.

* test(storage): point registry transient-error test at the right PR (#2124)

The describe() title cited #2121, which is the unrelated prebuildify CI
fix (drop broken -t 22 from prebuildify), not the registry-wipe bug. No
dedicated issue exists for this fix, so reference PR #2124 instead so
git blame / bisect readers land on the actual change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(storage): remove unused os import (CodeQL alert 693)

The os import was never referenced. Removes the code-scanning
unused-import alert and the PR autofix finding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(storage): cover partial prune, on-disk persistence, and EBUSY

The original bug was about *persisting* the wrong registry list, but the
tests only checked the in-memory return value of a single-entry registry.
Add coverage for the paths that actually exercise persistence:

- mixed-batch partial prune: register two repos, fail one with ENOENT and
  the other with EIO in the same validation call, then read registry.json
  off disk and assert exactly the EIO survivor was persisted (not [] from
  over-prune, not both from a no-op). This is the off-by-one path.
- assert the on-disk registry is unchanged in the EACCES/EIO/EAGAIN keep
  tests (the keep path must not rewrite/shrink the file).
- assert the ENOENT prune is persisted ([] written) as a regression guard.
- add the EBUSY keep case named in the source comment but previously
  untested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(storage): clarify the keep-branch comment (EACCES may be permanent)

The previous comment called EACCES "transient," but EACCES is often
permanent (e.g. a chmod'd directory). Reframe the comment around the
actual decision rule — prune only when the index is provably gone
(ENOENT/ENOTDIR), keep on everything else — and note that keeping a
possibly-permanent error is still the correct conservative choice
(a stale entry is harmless and removable; an over-prune destroys data).
Comment-only; behavior unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(storage): warn when keeping a registry entry on a non-fatal fs error

The keep branch was silent, so an I/O storm that keeps entries alive (the
whole point of the fix) was invisible in logs. Emit a structured
logger.warn naming the entry and the fs.access error code on the keep
path only. Observability-only: the keep/prune decision is unchanged and
the warn cannot throw.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(storage): describe listRegisteredRepos validate semantics accurately

The doc comment said validation checks each entry's .gitnexus/ "still
exists," which no longer matches the keep-on-transient behavior. Spell
out that validation prunes only provably-gone indexes (ENOENT/ENOTDIR)
and keeps entries that are merely not provably absent — so a kept entry
is "not confirmed present," not "confirmed present."

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(storage): prettier-format the transient-error test imports

Collapse the multi-line repo-manager import to a single line per Prettier,
clearing the PR autofix formatting finding. Formatting-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: buihongduc132 <buihongduc132@gmail.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:50:08 +01:00
Gergő MagyarandClaude Opus 4.8 2870aa6248 fix(grammars): load vendored tree-sitter grammars from vendor/ by absolute path (#2111) (#2144)
* fix(grammars): load vendored tree-sitter grammars from vendor/ by absolute path (#2111)

The recurring Windows `EPERM: operation not permitted, symlink` (errno -4048)
when adding the MCP server to Antigravity is NOT the #2101/#2110 module-load
crash — it is an install-time arborist failure during the `_npx` reify that the
MCP client triggers on every `npx gitnexus` launch.

Root cause: the `postinstall` materialize step copied each vendored grammar
(`vendor/tree-sitter-{c,dart,proto,swift,kotlin}`) into
`node_modules/gitnexus/node_modules/tree-sitter-*` as a real package so runtime
`require('tree-sitter-dart')` would resolve. Those packages are in no dependency
graph, so every subsequent npm/npx reify treats them as **extraneous** and
prunes/relocates them — on Windows the relocation goes through
`@npmcli/move-file`'s symlink path and throws EPERM (symlinks need Developer
Mode/admin), and on every OS the 2nd run silently deletes the grammars. This is
the same class as #1728, which the materialize step itself claimed to have
fixed.

Fix (the prebuildify + node-gyp-build ecosystem pattern): never copy grammars
into node_modules. Load each by absolute path from `vendor/<name>` via the new
`requireVendoredGrammar` helper — the grammar's own `bindings/node` runs
`node-gyp-build(<dir>)` and loads the committed `vendor/<name>/prebuilds/
<platform>-<arch>/…` directly (all 5 ship all 6 tuples). vendor/ is inside the
package but not a node_modules subtree, so arborist never sees the grammars and
the reify is idempotent — no EPERM, no silent deletion.

- new src/core/tree-sitter/vendored-grammars.ts (requireVendoredGrammar /
  vendoredGrammarDir / VENDORED_GRAMMAR_PACKAGES; VENDOR_ROOT stable in dev+dist)
- route all consumers through it: parser-loader, parse-worker, grpc proto,
  include-extractor (C), http-patterns kotlin, cli optional-grammars probe
- postinstall drops the materialize step; build-tree-sitter-grammars.cjs builds
  in-place under vendor/ (gitignored) and deletes materialize-vendor-grammars.cjs
- tests + grammar-introspection helper load grammars from vendor/ too (single
  source of truth); new vendored-grammars.test.ts guards against reintroducing a
  bare `require('tree-sitter-<vendored>')`

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(grammars): throw on a non-vendored name in requireVendoredGrammar

Drift guard (PR #2144 review, P3): validate the argument against
VENDORED_GRAMMAR_PACKAGES and fail loudly on an unknown name, so the three
grammar lists (package set / CLI probe / build registry) drifting out of sync
surfaces as a clear error instead of a confusing absolute-path require miss.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(grammars): prepack guard against stray vendor/<g>/build/ shadowing prebuilds

Publish hygiene (PR #2144 review, P2). Now that build-tree-sitter-grammars.cjs
source-builds into vendor/<name>/build/, a stray build dir would ship in the
tarball (files:["vendor"] overrides .gitignore/.npmignore) AND shadow the
committed prebuild — node-gyp-build resolves build/Release before prebuilds/.
assert-publish-grammar-coverage.cjs (prepack) now fails `npm pack` if any
vendor/*/build exists (findStrayBuildArtifacts), with a clear `rm -rf` fix hint.
Adds unit coverage for the new pure function.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(grammars): harden the #2111 no-bare-require regression guard

PR #2144 review (P2). The guard regex missed dynamic import(), side-effect
`import 'x'`, /subpath, and backtick loads, and only scanned src/. It now covers
every node_modules-forcing form (single/double/backtick quotes, optional
subpath), scans test/ too (excluding fixtures and the guard file itself), drops
the `//`-substring false-negative (leading-comment-only heuristic), and adds a
self-test asserting every load form is caught while prose mentions and
tree-sitter-cpp are ignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(grammars): correct stale vendored-grammar comments

PR #2144 review (P3). kotlin/query.ts called tree-sitter-kotlin an
"optionalDependency" — it is vendored and loaded from vendor/ by absolute path
(#2111). proto.ts now states its remaining `_require` is only for the real
`tree-sitter` dependency, not a vendored grammar (which goes through
requireVendoredGrammar). Comment-only; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:20:42 +01:00
Gergő MagyarandClaude Opus 4.8 3d30b94c46 fix(parse): survive non-cloneable worker results so large-repo analyze doesn't crash (#2112) (#2135)
* fix(parse): survive non-cloneable worker results so large-repo analyze doesn't crash (#2112)

A parse worker delivers its accumulated result to the main thread via
postMessage, which structured-clones the payload synchronously on the
worker thread and throws a DataCloneError on the first value it can't
serialize. The reporter's case was a node `properties` value pointing at
a native `toString`. The worker re-posted the throw as {type:'error'},
the pool counted it as a worker death, and under
GITNEXUS_WORKER_POOL_SIZE=1 the same graph re-threw on every respawn
until the slot's budget was exhausted and the whole parse phase aborted
-- defeating even the conservative single-worker workaround.

Add a clone-safety net at the worker result boundary. On a clone failure
the worker isolates the offending file, strips the non-cloneable value
from a plain extraction record (keeping the record -- strictly-missing
data, never wrong) or drops a whole ParsedFile so scope-resolution
re-derives it on the main thread with intact edge data, records the
affected paths on the result, warns naming the field + file so the leak
is diagnosable, and re-posts. Healthy runs are byte-identical: the net
runs only after a real DataCloneError, so there is zero overhead on the
fast path. Skipped paths surface via the parsing processor alongside the
skipped-language telemetry. The strip drops the same values the store
path's JSON.stringify already silently removes, so store/no-store runs
converge.

Scope: PR-1 -- failure mode C, the deterministic POOL_SIZE=1 killer. The
timeout/native-abort graceful-degradation cascade (failure modes A & B)
is coupled to downstream-exclusion + a hard worker watchdog and is
tracked as follow-up work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(parse): fail-closed clone-safety recovery + bound recursion depth (#2135 review)

The clone-safety recovery path could re-arm the #2112 worker-death cascade it
was built to prevent: in postResultCloneSafe the sanitizer call and the re-post
sat outside the try/catch, and containsNonCloneable/stripNonCloneable recursed
with a cycle guard but no depth bound. A throw inside the sanitizer (a RangeError
from a deeply-nested record, reproduced at depth >=3000) escaped to the message
handler's {type:'error'}, which under GITNEXUS_WORKER_POOL_SIZE=1 is the
respawn-budget-exhaustion abort.

Wrap the sanitizer + re-post in their own try/catch so any throw fails closed to
a primitive-only {type:'error'} deliberately, and thread a MAX_CLONE_DEPTH bound
through both scan/strip functions so an over-deep subtree is treated as
non-cloneable (dropped/undefined) instead of overflowing the stack. The
isStructuredCloneable catch-all is left broad on purpose — it bounds
structuredClone's own internal recursion in the non-plain-object probe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(parse): harden clone-safety against throwing getters and detached buffers (#2135 review)

Two sanitizer-defeat vectors let the re-post throw a DataCloneError again:

- A throwing getter on a record: containsNonCloneable/stripNonCloneable read
  obj[key], so a getter that throws escaped the scan/strip pass. Read defensively
  — a throwing property read is treated as non-cloneable (scan returns true,
  strip drops the property).
- A detached ArrayBuffer/TypedArray: both passed buffers/views through
  unconditionally, but structuredClone rejects a detached one, so the re-post
  threw. Route buffers/views through the authoritative isStructuredCloneable
  probe instead. No byteLength heuristic — a legitimately empty new Uint8Array(0)
  also has byteLength 0 yet clones fine, so a length check would false-positive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(parse): memoize stripped copies so DAG-aliased records aren't over-dropped (#2135 review)

stripNonCloneable carried a shared `seen` WeakSet and returned the ORIGINAL
(un-stripped) value on revisit. When a non-cloneable was reachable via two paths
(a DAG), the second path spliced the original function-bearing object back into
the output, so the rebuilt element failed the last-resort isStructuredCloneable
guard and the whole record was dropped as "unsalvageable" — contradicting the
"record kept, value stripped" contract.

Replace the WeakSet with a Map<object, stripped-copy>: allocate the empty copy,
memoize it before recursing into children (so cycles return the in-progress
copy), and return the memoized copy on revisit. DAG-aliased subtrees now collapse
to one shared stripped copy and are kept-and-stripped, not dropped. The array
branch moves from .map() to allocate-then-push so its identity can be
pre-inserted. Object Map/Set keys aren't identity-preserved across stripping —
acceptable because parse-result Maps are primitive-keyed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(parse): single-pass clone-safety scan preserving array identity (#2135 review)

makeWorkerResultCloneSafe scanned each dirty array twice — a field-level
whole-array containsNonCloneable probe, then a per-element pass — and always
reassigned the field. Fold into one per-element pass that builds the output
array lazily (copying the clean prefix only once the first dirty element
appears) and reassigns the field only when something changed. A fully-clean
array is now scanned once and keeps its referential identity; the clean prefix
of a dirty array is copied by reference. Behavior is otherwise identical
(failure-path-only code).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(parse): drop unused generic + pin clone-safe field names to keyof (#2135 review)

makeWorkerResultCloneSafe carried a generic `<T extends Record<string,unknown>>`
that was never load-bearing (it mutates in place and returns {skipped}), and the
call site passed untyped string-literal option sets — so renaming `parsedFiles`
or `skippedPaths` would silently disable the drop-whole / skip protection.

Drop the generic (plain `Record<string,unknown>` param) and type the option sets
at the call site as `Set<keyof ParseWorkerResult>`, so a field rename is now a
compile error. The `as unknown as Record<string,unknown>` widening stays — it's
the standard cast for a no-index-signature interface (TS rejects a single-step
`as`); the function genuinely operates structurally on the result's arrays.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(parse): keep the per-file reason in the clone-safety skip log (#2135 review)

The processor's skipped-file warning logged only the paths, dropping the
per-file reason the worker already attached — losing the distinction between a
recoverable "stripped N value(s)" and a whole-record "dropped" entry. Format each
entry as `path (reason)` so the aggregate line carries the diagnostic detail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(parse): deterministic findFilePath attribution for ParsedNode (#2135 review)

findFilePath swept all child objects one level deep in Object.keys order, so a
ParsedNode could be attributed to a sibling child's path-like key instead of its
real path at properties.filePath. Check the known `properties` child first, then
fall back to the generic sweep, so node attribution is deterministic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(parse): zero skippedPaths in the slim cache result (#2135 review)

slimParseWorkerResultsForCache spread the worker result without clearing the
clone-safety skippedPaths telemetry, so a sanitized result persisted its skip
list into the on-disk parse-cache shard. Replay already ignores the field; zero
it (like calls/assignments/parsedFiles) to keep shards lean and the intent
explicit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(parse): exercise real postResultCloneSafe wiring + tighten RED control (#2135 review)

The integration GREEN worker re-implemented postResultCloneSafe inline, so the
production wiring (the {type:'warning'} post + the skippedPaths append) had no
coverage, and the RED control asserted a bare .rejects.toThrow() that any
failure would satisfy.

Extract postResultCloneSafe into a side-effect-free module (post-result.ts) —
importing it from the parse-worker entry module would construct the parser, post
ready, and attach the real handler — and have the GREEN test worker import and
call the real one. Tighten the RED matcher to the actual abort contract
(/circuit breaker|consecutive failures|respawn budget|could not be cloned/),
which also documents that the raw poison result aborts via the pool's
consecutive-failure circuit breaker under POOL_SIZE=1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(parse): recover the clone-safety net from any post failure, not only DataCloneError (#2135 review)

The V8 structured-clone research surfaced the net's one real correctness hole:
structuredClone invokes getters, and a getter that THROWS surfaces its own error
(a RangeError, etc.) — NOT a DataCloneError (confirmed against a real
MessageChannel). postResultCloneSafe gated recovery on isDataCloneError, so such
a throw re-threw past the sanitizer and re-armed, under POOL_SIZE=1, the
worker-death cascade the net exists to prevent.

Attempt the sanitize + re-post recovery for ANY first-post failure (the sanitizer
already reads properties defensively, so a throwing getter is dropped), falling
closed to a primitive-only {type:'error'} only if the re-post still fails. Adds
an integration case: a node with a throwing getter is recovered and delivered,
not re-thrown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(parse): name the exact offending key path in the clone-skip diagnostic (#2135 review)

The clone-safety net's skip reason named only the array field + file ("stripped
1 value from nodes"), not the offending property key — which is precisely why
the original #2112 leak stayed unpinned. Thread a dotted key path through
stripNonCloneable (recording each stripped value's path: properties.toString,
meta.data[3], …) and surface the first few in the reason ("from nodes:
properties.toString"). Now a single log line — or the contract/strict checks —
names the leaking property, so a residual runtime escape can be fixed at source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(parse): clone contract — a representative ParseWorkerResult is structured-cloneable (#2135 review)

Shape-regression guard: builds a representative ParseWorkerResult (typed as the
real interface) and asserts isStructuredCloneable. Typing it as ParseWorkerResult
makes adding a new boundary field a compile error here until the test is updated,
and the runtime assert catches a field whose type regresses to a non-cloneable
shape — independent of language input.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(parse): strict-mode clone gate (GITNEXUS_STRICT_CLONE) — fail loudly instead of silent sanitize (#2135 review)

The runtime net's silent recovery in production is exactly what let the original
#2112 leak stay unpinned. Add an opt-in strict mode (GITNEXUS_STRICT_CLONE=1,
inherited by workers): on a clone failure, postResultCloneSafe THROWS with the
exact offending key path instead of sanitizing + delivering, so a leak
introduced by a future provider/extractor change fails loudly at its origin
(CI/dev) rather than being quietly stripped. Off in production, where the net
keeps the run alive.

Adds a self-contained integration case (sets the flag, asserts the poison run
rejects with the key path) and skips the synthetic-poison suite under a global
strict run (its value there is running the REAL-extractor integration tests
under strict). Wiring a strict CI lane (GITNEXUS_STRICT_CLONE=1 on a vitest
integration step) is left to the maintainer — it needs a green full-suite
verification and touches the protected workflow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(server): don't ship pipelineResult across the analyze-worker IPC boundary (#2112)

The forked analyze worker reports completion to the parent over
child_process IPC, which uses Node's DEFAULT 'json' serialization
(api.ts forks with no `serialization:` option). `AnalyzeResult.pipelineResult`
is populated on every successful analysis and carries `pipelineResult.graph`
— the live KnowledgeGraph closure object. Sending the raw result is wrong
three ways: (1) the graph's nodes/relationships getters force-materialize
the entire graph into two arrays and JSON-stringify them on every analyze,
discarded immediately (a multi-hundred-MB no-op on a large repo — the #2112
scenario); (2) the graph's methods are own function properties that JSON
drops silently, so a surviving graph is a husk whose forEachNode() throws far
from the cause; (3) a BigInt/circular value anywhere in the payload makes
process.send throw TypeError synchronously — caught and re-sent as
{type:'error'}, mis-reporting a SUCCESSFUL analysis (DB already written) as a
FAILURE. This is the #2112 failure family on the server path, and unlike the
parse-worker result boundary it has no clone-safety net.

The parent (api.ts) reads only result.repoName; pipelineResult's real
consumers (CLI skill generation, cli/analyze.ts) call runFullAnalysis
in-process and never cross this fork. So project the result down to an
explicit JSON-safe allowlist of scalar fields. Typed as
Omit<AnalyzeResult,'pipelineResult'> so a future non-serializable field added
to AnalyzeResult fails to compile until handled here deliberately.

Found by the #2112 cross-process serialization-boundary audit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ingestion): Cloneable<T> + assertCloneable() compile-time clone-boundary guard (#2143)

The runtime clone-safety net is the production backstop; this is its
compile-time complement. The worker result is plain data except a few
`unknown`-typed sinks (a node's `properties` bag, the provider
`extractTemplateConstraints` / `collectCaptureSideChannel` hook returns) —
`unknown` lets a non-serializable value (a function, a leaked tree-sitter
SyntaxNode, …) cross the structured-clone boundary with no compile-time
guard. That is the structural hole #2112 leaked through.

`Cloneable<T>` is a homomorphic recursive mapped type that maps a function or
symbol member to `never`, so a struct carrying one is no longer assignable to
its own `Cloneable<T>`. `assertCloneable(value)` is a runtime identity (zero
cost) whose parameter is `T extends Cloneable<T> ? T : Cloneable<T>`, so a
clone-unsafe argument fails to compile, naming the offending key.

Because it is a homomorphic mapped type it preserves `interface` shapes and
`readonly` modifiers and needs NO index signature on the payload types — this
sidesteps the "closed interface is not assignable to a recursive
index-signature type" wall that blocked the original value-typed-`Cloneable`
attempt (the reason #2143 was deferred from PR #2135). The conditional
parameter type avoids the `T extends Cloneable<T>` circular-constraint error.

Tests: runtime identity contract, plus type-level @ts-expect-error assertions
(enforced by tsconfig.test.json) that a function/symbol member is rejected and
clean interface payloads are accepted. Applied to the real provider hooks in
the next commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(ingestion): guard provider clone-boundary hooks with assertCloneable (#2143)

Apply the compile-time guard to the provider hooks that feed the `unknown`-typed
worker-result sinks, so a future non-serializable value in their payloads is a
compile error at the source site rather than a runtime DataCloneError at the
worker post:

- C++  extractTemplateConstraints  (CppConstraintPayload)
- C++  collectCaptureSideChannel    (CppCaptureSideChannel)
- C    collectCaptureSideChannel    (CCaptureSideChannel)
- Kotlin collectCaptureSideChannel  (KotlinCaptureSideChannel)

The C++ template-constraint adapter previously returned `unknown`; it now
returns the concrete `CppConstraintPayload | undefined` and routes its payload
through `assertCloneable`. The side-channel hooks are wrapped at their provider
wiring sites. `assertCloneable` is a runtime identity, so behavior is unchanged
(C static-linkage + C++ constraint suites stay green); the guarantee is the
type-check — src tsc now proves every nested member of those real payload trees
is structured-clone safe.

Test: type-level assertions (enforced by tsconfig.test.json) that each concrete
payload type is `Cloneable<T>`, INDEPENDENT of the provider wiring — so the
regression is caught even if the assertCloneable wrapper is later removed.
Proven non-vacuous (a function-bearing type fails the same assertion).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(parse): scan an array's non-index own properties in the clone sanitizer (#2135 review)

structuredClone serializes an array's NON-index own-enumerable properties (e.g.
`arr.meta = fn`) and throws DataCloneError on a non-cloneable one. The clone
sanitizer's array branches iterated numeric indices only, so such an array was
waved through (containsNonCloneable returned false, makeWorkerResultCloneSafe
left the field unrewritten with skipped:[]) — the re-post then threw, fell
through to the fail-closed {type:'error'}, and re-armed the POOL_SIZE=1 cascade
the net exists to prevent.

Add isArrayIndexKey() and, in BOTH containsNonCloneable and stripNonCloneable
array branches (kept in lockstep), scan/strip the non-index own-enumerable keys
after the index loop. A cloneable non-index prop is carried onto the stripped
copy; a non-cloneable one is stripped and recorded. Not reachable from current
parse output (no extractor attaches non-index array props) — a defense-in-depth
hole closed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(parse): contain a throw inside the clone sanitizer instead of escaping to fail-closed (#2135 review)

findFilePath was documented "never throws" but read element properties
unguarded in its generic sweep — a throwing getter at a non-path key (or a
Proxy with a throwing ownKeys trap) threw out of makeWorkerResultCloneSafe, past
postResultCloneSafe's recovery, to the fail-closed {type:'error'} that under
POOL_SIZE=1 re-arms the cascade the net prevents. Likewise a Proxy with a
throwing getPrototypeOf trap throws inside containsNonCloneable's instanceof
checks.

- findFilePath/pathFromChild now read via safeGet (try/catch) and guard
  Object.keys, honoring the "never throws" contract.
- Each element's sanitize in makeWorkerResultCloneSafe is wrapped: a throw during
  scan/strip drops that one element (recorded as "sanitizer error") rather than
  sinking the whole result — so one pathological element can't fail-close the run.
- Corrected the makeWorkerResultCloneSafe JSDoc ("ONLY after a DataCloneError" →
  after ANY post failure, matching the caller) and documented the deliberate
  failure-path double-traversal (the non-allocating pre-scan is what preserves
  clean-element referential identity).

Tests: a throwing getter on a path-less element is stripped & delivered (not
escaped); a Proxy structural-trap element is dropped, clean siblings survive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(parse): add a final cloneable postcondition gate to the clone sanitizer (#2135 review)

makeWorkerResultCloneSafe rewrote only ARRAY result fields, so a future
non-array sink (a nested object / Map result field) carrying a non-cloneable
value — or an array field whose own non-index property the element loop didn't
reach — would survive the sanitizer and throw on the re-post. Add a final
`if (!isStructuredCloneable(result))` gate that strips any remaining offending
field in place, making "the returned result is structured-cloneable" a hard
postcondition independent of future ParseWorkerResult shape. Failure-path-only
and a no-op once the array loop already made the result clean (the per-field
probe short-circuits every clean field, so it adds no work or skip entries then).

Tests: a function on a non-array result field is stripped & the result becomes
cloneable; the gate adds no skip entry when the array loop already cleaned up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(parse): reject an `any`-typed member in the Cloneable<T> compile-time guard (#2135 review)

`Cloneable<any>` previously resolved to `any` (not `never`), so a payload with
an `any`-typed member — the most likely escape hatch, since `unknown` is already
blocked — passed `assertCloneable` with no compile error. Add an `IsAny<T>`
branch (the canonical `0 extends 1 & T` probe) as the FIRST arm so `any` resolves
to `never`, matching how `unknown` is already rejected. It must precede the
primitive arm: `any extends CloneablePrimitive` would otherwise resolve to `any`
and re-admit it.

The IsAny-first arm perturbs inference for a bare `undefined` literal argument
(T infers as `unknown` → never); real consumers pass `X | undefined` unions
(the provider hooks), which are unaffected (src tsc clean), so the runtime
identity test now uses a `string | undefined` value — the realistic shape.

Tests: an `any` member fails `assertCloneable` (@ts-expect-error, enforced by
tsconfig.test.json) and `Cloneable<any>` resolves to `never` at the type level.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(server): type the analyze-worker IPC projection as a Pick allowlist, not Omit (#2135 review)

`AnalyzeResultIpc = Omit<AnalyzeResult,'pipelineResult'>` kept every other field
in the type — including optional ones like `isPrimaryBranch?` — so the type
advertised a field the runtime allowlist never sends, and the doc-comment's
"a future field fails to compile until handled here" only held for REQUIRED
fields. Switch to `Pick<AnalyzeResult, …the six scalar fields…>`: the allowlist
IS the type, so the projection return literal is exhaustive by construction
(omitting a key is a compile error) and a new `AnalyzeResult` field is simply
absent from the wire until deliberately added here. `isPrimaryBranch` is
intentionally excluded (nothing consumes it server-side over this fork; the
parent reads only `repoName`).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(parse): remove the now-dead isDataCloneError export (#2135 review)

postResultCloneSafe recovers on ANY fast-path post failure and never inspects
the error type (a throwing getter surfaces a RangeError, not a DataCloneError —
gating on the type was the original net-gap bug). isDataCloneError has no
production caller; it was only exercised by its own unit test. Remove the
function and that test block.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(parse): use the exported SkippedPath type in parsing-processor (#2135 review)

The clone-safety telemetry accumulator inlined `Array<{path,reason}>` — a
structural duplicate of the exported `SkippedPath`. Import and use the canonical
type so a future rename of its fields is a compile error here instead of a silent
structural drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(parse): document the cloneable-return contract on the worker-boundary hooks (#2135 review)

extractTemplateConstraints and collectCaptureSideChannel return `unknown` and
feed values across the worker structured-clone boundary, but the hook contracts
didn't state the cloneability requirement — a future language implementing them
without care could leak a non-serializable value. Document that the return MUST
be structured-clone-safe and should be wrapped with assertCloneable, so the
guarantee is a compile error at the source (#2143).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(parse): assert the clone-skip telemetry surfaces in the GREEN integration case (#2135 review)

The GREEN clone-safety integration test asserted only graph content (all files
present), not that the skippedPaths / {type:'warning'} wiring its docstring
claims to cover actually fired. Capture the production logger via _captureLogger
and assert the sanitize telemetry names the offending file (poison.ts) AND the
exact stripped key path (properties.toString) — proving the worker's
skippedPaths append + the parsing-processor warning surfaced end to end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(server): cover the IPC projection against a real KnowledgeGraph (#2135 review)

The IPC projection tests used a hand-built hostile object. Add a case that puts
a real createKnowledgeGraph (whose nodes/relationships getters would materialize
the whole graph under JSON.stringify) in pipelineResult and asserts the
projection drops it entirely — the serialized payload stays under 300 bytes
(a materialized 50-node graph would be thousands), with the scalar fields intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(parse): cover the unsalvageable-drop branch and the skippedPaths merge union (#2135 review)

Two untested clone-safety branches from the tri-review:

- "dropped unsalvageable": a dirty element whose stripped copy is STILL not
  structured-cloneable must be dropped, not delivered (else the re-post throws).
  Add a deterministic test (a non-plain member with a stateful getter that the
  strip-time probe sees clean but that turns into a function on the post-strip
  verification) asserting the element is dropped and the run survives.

- mergeResult skippedPaths union across sub-batches. mergeResult (and its
  appendAll helper) was module-private in the parse-worker ENTRY module, which a
  main-thread test can't import (it runs MessagePort setup). Extract it to a
  side-effect-free result-merge.ts (mirroring post-result.ts) and unit-test the
  union (including the `??=` target-init path), the skippedLanguages sum, and
  array append. parse-worker imports it back; verified the built worker still
  parses + merges via the real-worker integration path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(parse): root-prettier format the clone-safety review-fix files (#2135 review)

Clears the failing `quality / format` CI gate (root prettier, not the
gitnexus-local config). Reformats the pre-existing #2143 wrapping lines in
c-cpp.ts + kotlin.ts plus the clone-safety review-fix files touched in this
PR-update (clone-safety.ts and the new/updated tests). Formatting-only — no
behavior change; tsc, the type-level assertions (tsconfig.test.json), and the
unit + integration suites stay green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(parse): avoid js/trivial-conditional in the type-level clone assertions (#2135 review)

CodeQL flagged the `expect(a && b && c).toBe(true)` lines in the type-level test
assertions as js/trivial-conditional: after type erasure the operands are
constant `true`, so the `&&` chain always evaluates the same. Replace the `&&`
chain with array equality (`expect([...]).toEqual([true, ...])`) — no
conditional, and the real assertions remain the `const x: …IsNever = true` /
`: IsCloneable<…> = true` annotations (enforced by tsconfig.test.json, which
fail to compile if a guard regresses).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:47:22 +01:00
Gergő MagyarandClaude Opus 4.8 7f0ab87782 fix(embeddings): resolve onnxruntime-common under pnpm-strict / pnpm dlx (#307) (#2139)
* fix(embeddings): resolve onnxruntime-common under pnpm-strict / pnpm dlx (#307)

`@huggingface/transformers` does a bare `import 'onnxruntime-common'` from its
shipped `dist/transformers.node.mjs`, but never declares onnxruntime-common in
its own `dependencies`. npm's flat node_modules (and pnpm with hoisting) place
it on transformers' resolution path by accident; pnpm's isolated store only
links a package's declared deps into its scope, so under pnpm-strict /
`pnpm dlx` / `pnpx` the import dies with ERR_MODULE_NOT_FOUND before
`analyze --embeddings` can run.

Declaring onnxruntime-common in gitnexus' own deps (#2074) does not fix this
under pnpm: Node resolves the bare specifier from transformers' module scope,
not ours, and overrides/resolutions can only re-version an existing edge, never
add the missing one (verified against a real `hoist=false` install — the
declaration only changes which version wins the hoist, never whether the import
resolves).

Fix: install a synchronous, in-thread ESM resolution hook
(`module.registerHooks`) right before the lazy transformers import that
redirects `onnxruntime-common` to the copy gitnexus depends on — but only when
the default resolver fails. On npm / hoisted layouts the default resolver
succeeds first and the hook never fires, so working setups are unchanged. The
hook only intercepts the exact `onnxruntime-common` specifier on failure, so it
can never mask an unrelated resolution error; onnxruntime-node's native binding
still loads normally from transformers' own scope.

`registerHooks` (sync, in-thread, single inline closure) is preferred over the
older `module.register` (async, off-thread, now deprecated — DEP0205, removed in
Node 26): the redirect is a one-line conditional that needs no worker thread, no
separate hook module, and no `data` marshalling. It is available on Node >= 22.15;
on older runtimes the helper is a graceful no-op (the gitnexus engines floor is
>= 22.0.0, and the import still resolves on hoisted layouts there).

Chosen over bundling transformers (the build is tsc-only, and transformers
carries native onnxruntime-node + WASM onnxruntime-web assets that bundle
poorly). Installation is idempotent, best-effort, and lazy — only on the
local-embedding path, so it never affects analysis, the parse workers, or HTTP
embedding mode.

Validated end-to-end: the compiled resolver fixes a real pnpm `hoist=false`
transformers install (ERR_MODULE_NOT_FOUND -> resolved). The separate
`@ladybugdb/core` native-binary path under pure `pnpm dlx` is unchanged (#1967
handles that gracefully).

Refs #307, #2069

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(embeddings): version-match the onnxruntime-common redirect target (#307)

Prefer the onnxruntime-common that onnxruntime-node (the native binding
transformers actually loads) depends on, so the redirected copy is version-
matched to that binding even under `pnpm dlx` — where gitnexus' npm-style
`overrides` block does not apply, because it is honoured only from a root
manifest and gitnexus is a transitive dependency there. The walk resolves
transformers' main entry (not its `exports`-blocked package.json) ->
onnxruntime-node -> its onnxruntime-common, and falls back to gitnexus' own
direct dependency when the chain can't be walked. Also corrects the doc comment
that claimed the gitnexus copy was already "version-aligned".

Addresses a PR #2139 tri-review finding (P2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(embeddings): narrow the onnxruntime-common resolve fallback to absence errors (#307)

The resolve closure's `catch` swallowed every error from `nextResolve` and
redirected, which would silently paper over a genuinely present-but-broken
onnxruntime-common install. Only substitute gitnexus' copy when the specifier is
actually absent (ERR_MODULE_NOT_FOUND, or ERR_PACKAGE_PATH_NOT_EXPORTED for an
exports-broken copy); rethrow anything else. Adds a test that an unrelated error
code rethrows.

Addresses a PR #2139 tri-review finding (P3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(embeddings): cover the onnxruntime-common resolver best-effort swallow path (#307)

The outer try/catch in ensureOnnxRuntimeCommonResolvable() was untested. A
throwing registerHooks spy drives it; the call must not throw (initEmbedder does
not guard the return, so a throw would break `analyze --embeddings`). The vitest
quirk that surfaced an earlier attempt applies to throwing mock factories, not a
throwing spy implementation, so this is testable cleanly.

Addresses a PR #2139 tri-review finding (P2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(embeddings): tighten the onnxruntime-common redirect-URL assertion (#307)

`/^file:\/\/.*onnxruntime-common/` matched the substring anywhere, so a lookalike
path (e.g. `/x/onnxruntime-common-fake/`) would pass. Require an actual
`/node_modules/onnxruntime-common/...js` segment so the assertion proves the
redirect resolves to the real package, not just a string match.

Addresses a PR #2139 tri-review finding (P3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(embeddings): drop the no-op __resetOnnxRuntimeCommonResolverForTests seam (#307)

The test helper reloads the resolver via vi.resetModules() + a fresh import(),
which already re-initialises the module-level one-shot `attempted` flag to false.
The __reset export it then called was therefore a no-op. Remove the test-only
export and its call; isolation now rests solely on vi.resetModules().

Addresses a PR #2139 tri-review finding (P3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(embeddings): correct the onnxruntime-common resolver isolation comment (#307)

The doc comment claimed the hook "never affects other tools' resolution". Once
installed, `module.registerHooks` is process-global and its resolve closure runs
for every subsequent resolution — it passes them all through untouched and only
substitutes the exact `onnxruntime-common` specifier on genuine absence, at a
cost of one string comparison. Also note `registerHooks` is @experimental and
requires Node >= 22.15 (graceful no-op below that). Comment-only.

Addresses a PR #2139 tri-review finding (P3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:00:43 +01:00
Gergő MagyarandClaude Opus 4.8 ae5ec94fd9 fix: stop impact()/route_map under-reporting blast radius (#2129, #1858, #1589/#1852) (#2136)
* fix(query): stop impact()/context() under-reporting blast radius (#2129, #1858)

Two read-side fixes to the "run impact before editing" safety workflow, both
about the tools rendering "I could not give a single confident answer" as
"no impact" — the most dangerous failure mode for a refactor-safety tool.

#2129 — ambiguous resolution no longer hides a real caller behind a bare
`impactedCount: 0`. When a bare name collides with several symbols, the resolver
returns `ambiguous`; previously the payload carried a flat `impactedCount: 0`,
so the real caller (which calls a *different* same-name node) was invisible
unless the user already knew to disambiguate. The ambiguous branch now runs a
bounded, summary-only BFS per candidate (capped at 6) and surfaces each
candidate's true count plus the top-level `maxImpactedCount` / `maxRisk`, ranked
most-impactful-first. `risk` stays `UNKNOWN` (ambiguity must not read as "safe"),
`impactedCount` stays 0 (no single resolved symbol). The BFS and edge storage
are unchanged — an empirical repro confirmed they are correct; the bug was
purely in how the ambiguous case reported. Disambiguation by uid still returns
the exact result.

#1858 — impact()/context() now carry an additive `epistemic` field. When the
queried symbol sits on an interface / indirection boundary (it implements or
extends an interface, or is one) whose consumers bind via a DI container or
dynamic dispatch, those callers are not traced to the concrete symbol, so the
count is a lower bound. The result is annotated `epistemic: 'lower-bound'` with a
human-readable `boundaries[]` note; a fully resolved leaf stays
`epistemic: 'exact'`. Aligned to the surviving numeric confidence model (the
0.85 IMPACT_RELATION_CONFIDENCE heritage floor), not the long-deleted
TIER_CONFIDENCE enum. Purely additive — no existing field or count changes.

Tests: impact-ambiguous-blast-radius (per-candidate surfacing + uid
disambiguation) and impact-epistemic-lower-bound (interface boundary →
lower-bound, resolved leaf → exact, context parity).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(routes): configurable fetch wrappers + faster consumer scan (#1589/#1852)

Closes the residual gap behind the now-merged #1852 (which fixed #1589): the
fetch-wrapper consumer scan only traced wrappers the parse phase auto-detected
as calling the bare global `fetch()`. A wrapper built on axios / a custom
client, or one named outside the built-in convention, was invisible — route_map
silently returned `consumers: []` (the exact "named outside convention → silent
zero" hole #1858 calls out as needing a backstop).

- Configurable wrappers: `.gitnexusrc` gains a `fetchWrappers: [...]` list
  (validated as identifier/member names, de-duped, capped, regex-safe), threaded
  AnalyzeOptions → PipelineOptions → routes phase. Configured names are unioned
  with the auto-detected ones; configured names alone now trigger the scan even
  when nothing was auto-detected.
- Perf (F3 from #1852's review): the cross-file scan built one RegExp per
  (file × wrapper) — O(files × wrappers). It now builds a single alternation
  regex per file (O(files)) and reuses file contents already read for handler
  extraction instead of re-reading them.

Tests: configurable-fetch-wrapper (axios-based `doRequest` wrapper — invisible
without config, traced with it) + .gitnexusrc `fetchWrappers` validation cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(review): harden the under-reporting fixes after adversarial review

Addresses findings from a reviewer-swarm pass over the two prior commits:

- CLI text false-safe (major): `formatImpactResult` (eval-server.ts) had no
  ambiguous branch, so `gitnexus impact <colliding-name>` printed "No
  dependencies found. This symbol appears isolated." for an ambiguous target —
  the exact false-safe #2129 exists to kill, defeating the JSON-layer fix at the
  text surface. Added an ambiguous branch (per-candidate blast radius +
  maxImpactedCount/maxRisk) and a lower-bound branch for both the zero-count and
  non-zero paths, mirroring the context formatter. Covered by new unit tests.
- Group fan-out dead work (major): impactByUid now passes skipEpistemic:true —
  the group cross-impact fan-out consumes only byDepth, so computing the #1858
  boundary per neighbor was wasted round-trips on the highest-volume path.
- Ambiguous all-UNKNOWN risk (minor): if every per-candidate probe fails, maxRisk
  now reports 'UNKNOWN' instead of falling to the 'LOW' seed (which would read as
  "safe").
- Candidate-probe cost (minor): the per-candidate summary BFS now sets
  skipEnrichment:true, bypassing the process/module aggregation passes it does
  not use.
- Epistemic latency (minor): computeEpistemicBoundary now runs concurrently with
  the impact BFS instead of as a trailing serial round-trip.
- Wrapper over-match (minor): the consumer-scan regex uses a `(?<![.\w$])`
  lookbehind instead of `\b`, so a bare configured name like `get` matches the
  free call `get('/x')` but not a member access `client.get(` (and `apiFetch`
  no longer matches `myApiFetch`).
- Boundary wording (nit): correct article ("a class" vs "an interface") and
  singular/plural ("1 implementation").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(lint): drop unused describe import in new impact tests

The withTestLbugDB harness wraps describe internally, so the explicit
describe import was unused — unused-imports/no-unused-imports is an error
(not a warning) in the root eslint config, failing quality/lint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(query): flag partialProbe when an ambiguous candidate probe fails (#2129 review F1)

The ambiguous-impact branch hoists maxRisk/maxImpactedCount so a colliding
name can't read as "isolated". But if a per-candidate BFS throws (e.g. DB
pool contention during the ≤6-way fan-out), it was recorded as
risk:'UNKNOWN', impactedCount:0 and silently masked by any benign sibling
success — maxRisk reduced to the benign tier and maxImpactedCount reflected
only successful probes. Track probeFailed and surface partialProbe:true
(additive, intentionally distinct from the traversal-interrupted `partial`
flag); formatImpactResult prints a lower-bound warning. Covered by a
formatter unit test (a natural in-harness probe throw is unreachable —
_runImpactBFS is fully self-catching under summaryOnly+skipEpistemic+
skipEnrichment).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(query): report the full match count when ambiguous candidates are truncated (#2129 review F11)

The ambiguous candidate list is capped at AMBIGUOUS_MAX_CANDIDATES (6), but
the CLI headline read the truncated `candidates[]` length — so a name
matching 9 symbols printed "6 symbols share this name" while the JSON message
stated the true count. Add an additive `totalCandidates` field carrying the
full match count, include a "showing N of M" clause in the message when
truncated, and have formatImpactResult report the full count. Covered by
formatter unit tests for the truncated and non-truncated cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(query): run context() epistemic probe concurrently with methodMetadata (#1858 review F2)

impact() overlaps the #1858 boundary probe with its BFS, but _contextImpl
awaited computeEpistemicBoundary serially after every other query. Start the
probe right after `symKind` is known (the earliest point it can — symKind
depends on the incoming/outgoing round-trips) so it runs concurrently with the
methodMetadata fetch, and await it at result assembly. Output is unchanged
(covered by the existing epistemic context() tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(query): flag a leaf interface as lower-bound in context() (#1858 review F3)

context() passed `symKind` to computeEpistemicBoundary, but symKind collapses
a single-resolved Interface to 'Class' (resolvedLabel is '' on the
single-candidate path), so the `symType === 'Interface'` self-boundary branch
never fired and a directly-queried leaf interface (implements nothing, but
consumed) was under-reported as 'exact'. Pass an interface-preserving type
(`resolvedLabel || sym.type || symKind`) instead — enrichCandidateLabels runs
before the single-candidate early return and patches sym.type to 'Interface',
mirroring impact()'s derivation. impact() was already unaffected. Covered by a
new context()-on-a-leaf-interface test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(query): hoist epistemic relation-type lists + add USES to the allowlist (#1858/#2129 review F4, F5)

F4: promote computeEpistemicBoundary's function-local heritage/consumer
relation-type lists to module-level readonly constants
(EPISTEMIC_HERITAGE_RELATION_TYPES / EPISTEMIC_CONSUMER_RELATION_TYPES) next to
VALID_RELATION_TYPES / IMPACT_RELATION_CONFIDENCE, so a future heritage edge
type is visible to the probe. Kept as arrays (not Sets) because they bind as
Cypher params.

F5 (latent bug): USES is emitted (emit-references.ts) and already in the
default impact relTypes + context() queries, but was missing from
VALID_RELATION_TYPES — so impact({relationTypes:['USES']}) filtered to [] and
silently ran the full default traversal. Add it (0.5 confidence fallback,
matching FETCHES/WRAPS). Updates the security.test.ts allowlist assertions
(size 15→16, USES now valid).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(query): document the _runImpactBFS enrichment skip-flag composition (#1858/#2129 review F6)

The three skip-flags (skipPerSymbolEnrichment / skipEpistemic / skipEnrichment)
suppress distinct sub-phases and compose implicitly. Add a JSDoc block at the
opts type listing what each suppresses, the three real call patterns, and the
key interaction (skipEnrichment makes skipPerSymbolEnrichment a no-op).
Comment-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(cli): genericize the shared string-array validation messages (#1589/#1852 review F7)

The shared `string-array` ValueKind hardcoded fetch-wrapper phrasing in three
messages (non-array, identifier-shape, empty-list). Since `source` already
names the config key, genericize all three so the shared normalizer carries no
fetchWrappers coupling — a future string-array config key gets sensible errors.
Test assertions updated to the new wording.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(query): type the ambiguous candidate summary + epistemicPromise (#1858/#2129 review F8)

The ambiguous per-candidate summary was read through `any`, so a rename of
_runImpactBFS's return fields would silently zero candidate counts. Name the
read shape ({impactedCount, risk, summary?.direct}) at the narrowing site, and
type epistemicPromise as the optional-epistemic union (the skip case's `{}`
subtype) — keeping computeEpistemicBoundary's own return precise (epistemic
required). Type-only; no runtime change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(routes): trust validated fetchWrappers config, drop redundant re-filter (#1589/#1852 review F9)

`ctx.options.fetchWrappers` is already trimmed/shape-validated/de-duped/capped
in analyze-config.ts, so the routes-phase re-trim/re-typeof pre-pass was
redundant. Pass it straight through; the single Set-construction filter remains
to guard the auto-detected functionName values (which don't pass through
analyze-config). No behavior change — covered by the existing fetch-wrapper
route suites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(routes): make the wrapper-call boundary Unicode-aware (#1852 review F10)

The consumer-scan lookbehind used ASCII `\w`, so a configured bare wrapper name
preceded by a non-ASCII identifier character (`caféget('/x')`) satisfied the
boundary and produced a spurious FETCHES edge. Switch to the `u` flag with
Unicode property classes (`(?<![.\p{L}\p{N}_$])`). Covered by a fixture
consumer (`cafédoRequest('/api/things')`) asserting no spurious edge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(routes): count wrapper-scan line numbers incrementally (#1852 review F12)

The wrapper consumer scan computed each match's line number via
content.substring(0, match.index).split('\n').length — an O(matchIndex)
allocation per match. Matches arrive in ascending index, so accumulate
newlines with a running counter instead. 1-based line numbers are byte-identical
(covered by the existing fetch-wrapper route suites).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(test): keep the #1858 epistemic probe from skewing the impact-pagination mock

The impact-pagination mock counts every query containing `r.type IN` as a BFS
depth level. Once the #1858 epistemic boundary probe was parallelized with the
BFS (it fires `MATCH (x)-[r]->(iface) ... r.type IN $heritage` before the
frontier loop), that query was miscounted as depth-1, shifting the real depths
so multi-depth impactedCount read 50 instead of 200. Short-circuit the
epistemic queries (uniquely aliased `iface`) to empty in both mock setups so
only frontier queries count. Test-only; production is unaffected (the epistemic
query is a separate real query there).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:25:48 +01:00
Gergő Magyar 7eaeb0a0c4 feat: multi-branch indexing and branch-scoped querying (#2106) (#2137)
* feat(git): add getCurrentBranch + resolveRefToCommit helpers (#2106)

* feat(storage): branch-scoped getStoragePaths + branchSlug + resolveBranchPlacement (#2106)

* feat(analyze): branch-aware indexing — per-branch slot, no overwrite (#2106)

* feat(registry): nest non-primary branches under one path entry (#2106)

* feat(mcp): optional branch scope on query tools + list_repos branches (#2106)

* feat(cli): --branch on analyze + query/context/impact/cypher/detect-changes (#2106)

* feat(cli): branch-aware list/status + per-branch staleness meta (#2106)

* fix(review): apply autofix feedback

- guard analyze against --branch != checked-out branch (prevents writing one
  branch's working tree into another branch's index slot)
- fix branch-handle pool reinit thrash (track observed indexedAt by lbugPath,
  since applyBranchScope returns fresh handles)
- remove dead resolveRefToCommit helper (staleness uses HEAD vs branch meta)
- RepoListing.branches -> Omit<BranchSummary,'stats'> for type cohesion
- add tests: branchSlug traversal containment, --branch mismatch reject,
  callTool branch threading, legacy-entry branch routing, status detached/stale

* fix(review): address tri-review findings (#2106)

- P1 data-loss: a detached-HEAD re-analyze (CI's actions/checkout default) no
  longer strips the primary's meta.branch stamp; preserve it so a later branch
  analyze cannot claim & overwrite the flat/primary index. +cascade integration test
- P2: capture validateBranchName's trimmed return for --branch so a
  whitespace-padded value no longer false-rejects on-branch or ghosts an index
- F1: on a lost/rebuilt registry, a branch run reconstructs the primary
  top-level entry from the flat meta, not the feature branch's meta

* fix(storage): only trust a non-empty-string flatMeta.branch (#2106 R5)

* fix(analyze): warn when the default branch is not the primary index (#2106 R8)

* fix(mcp): resolve --branch <primary> on a legacy unstamped flat index (#2106 R4)

* feat(cli): gitnexus clean --branch to remove a single branch index (#2106 R7)

* fix(mcp): evict orphaned branch pools on unregister/clean (#2106 R3)

* fix(analyze): union per-branch cache keys so a branch switch keeps shards (#2106 R6)

* fix(analyze): normalize the auto-detected branch label via sanitizeDetectedBranch (#2106 R1)

* fix(cli): skip AGENTS.md base_ref refresh for a non-primary branch fast path (#2106 R2)

* fix(storage): atomic writeRegistry + re-read-before-write to narrow the registry race (#2106 R9)

* refactor(storage): extract branch primitives to branch-index.ts (#2106 R10)
2026-06-10 10:24:40 +01:00
36ca096e75 feat(ingestion): Java Spring route annotation → Route node extraction (#2078)
* feat(ingestion): add Java Spring route annotation → Route node extraction

Previously, GitNexus only supported Route node generation for JS/TS
ecosystems (Express, Next.js, Fastify, etc.) and Python (FastAPI, Flask).
Java Spring's annotation-based routing (@RequestMapping, @GetMapping,
@PostMapping, etc.) was only supported at the group contract layer
(http-patterns/java.ts) for cross-repo matching, but NOT at the
ingestion layer for generating graph Route nodes.

This commit adds ingestion-layer support:

1. JAVA_QUERIES (tree-sitter-queries.ts):
   - Added method-level annotation captures (@GetMapping, @PostMapping,
     @PutMapping, @DeleteMapping, @PatchMapping) → @decorator captures
   - Added class-level @RequestMapping → @decorator capture (prefix)
   - Supports both positional ("/path") and named (path="/path",
     value="/path") annotation argument forms

2. parse-worker.ts:
   - Java class-level @RequestMapping is detected and stored as a prefix
     (not pushed as a standalone Route)
   - After per-file capture processing, the prefix is applied to all
     method-level routes in the same file via the existing
     ExtractedDecoratorRoute.prefix field
   - The routes phase (normalizeExtractedRoutePath) handles the prefix
     joining, producing final URLs like /api/users/list

3. Tests:
   - Unit test (worker-backed): 4 cases covering prefix joining,
     bare routes, class-level exclusion, multi-file isolation
   - Integration test (full pipeline): 6 cases covering end-to-end
     Route node + HANDLES_ROUTE edge generation

Closes the feature gap where `route_map`, `shape_check`, and
`api_impact` MCP tools returned empty results for Java Spring projects.

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix: address review findings — extract spring.ts module, fix PatchMapping, multi-class support

Addresses all P2 findings from tri-review:

1. **Architecture**: Extracted Spring route logic from parse-worker.ts into
   a dedicated `route-extractors/spring.ts` module (matching the pattern
   of `laravel.ts` and `fastapi-router-bindings.ts`). parse-worker now
   has a single dispatch line — no language-specific logic inline.

2. **PatchMapping bug**: Added `'PatchMapping'` to `ROUTE_DECORATOR_NAMES`
   (was silently dropped before).

3. **Multi-class bug**: The new `extractSpringRoutes` walks each class
   declaration independently with its own prefix — no more single-scalar
   `javaClassPrefix` last-wins issue.

4. **Test hygiene**: Unit tests now import `extractSpringRoutes` directly
   (no dist build / worker pool dependency). Tests run in all tiers.

5. **Removed JAVA_QUERIES decorator patterns**: The Spring extractor does
   its own AST walk, so the tree-sitter query captures for Java annotations
   are no longer needed (avoids duplicate route emission).

Additional test coverage:
- Multi-class in one file with independent prefixes
- @PatchMapping support
- Named annotation args (path= and value=) on class-level @RequestMapping

* refactor: move Spring route extraction to LanguageProvider hook

Addresses the second review comment: instead of an inline
`if (language === SupportedLanguages.Java)` dispatch in parse-worker,
the Spring route extraction is now wired through a new optional
`extractDecoratorRoutes` hook on LanguageProviderConfig.

- Added `extractDecoratorRoutes` to LanguageProviderConfig interface
- Java provider registers `extractSpringRoutes` as its implementation
- parse-worker calls `provider.extractDecoratorRoutes?.()` generically
- Removed direct import of spring.ts from parse-worker

This keeps parse-worker fully language-agnostic — no language names
appear in the dispatch path for route extraction.

* refactor: rewrite spring.ts with tree-sitter captures, fix inline imports

Addresses all 4 inline review comments:

1. Rewrote spring.ts to use a single predicate-free Parser.Query
   (same pattern as group-layer JAVA_ROUTE_ANNOTATION_PATTERNS).
   Two-phase loop: first pass collects class prefixes by node.id,
   second pass resolves method routes via findEnclosingClass.
   No more manual DFS / recursion.

2-3. Moved inline import(...) type references in language-provider.ts
     to proper top-level imports (Parser, ExtractedDecoratorRoute).

4. Covered by #1 — recursive helpers removed entirely.

Added 3 extra test cases: non-route named args filtering,
prefix isolation across mixed classes, line number accuracy.

* refactor: extract shared Spring route primitives + add parity test

Addresses review follow-up on #2078:

- Extract the primitives shared by the ingestion (route-extractors/spring.ts)
  and group (http-patterns/java.ts) Spring extractors into a new
  route-extractors/spring-shared.ts: METHOD_ANNOTATION_TO_HTTP,
  findEnclosingClass, isRouteMemberKey, and a safe unquoteSpringLiteral.
  Both extractors now import from it (group -> ingestion, the layer-correct
  direction) so the shared semantics can't drift apart.

- Replace spring.ts's local unquote() with the safer unquoteSpringLiteral
  (returns null for non-string nodes instead of assuming a quoted string).

- Add test/unit/spring-route-extractor-parity.test.ts: runs one shared Spring
  fixture through both extractors and asserts they surface the same provider
  method/path combinations.

The broader HttpRouteExtractor source-scan optimization is tracked in #2138.

---------

Co-authored-by: henry <zhangwei2017@unipus.cn>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-06-10 09:44:56 +01:00
Gergő MagyarandClaude Opus 4.8 292f26ece3 fix(hooks): silence MCP-owned-DB augment skip for strict hook runners (#1913) (#2134)
* fix(hooks): silence MCP-owned-DB augment skip for strict hook runners

The PreToolUse augment-skip path wrote `[GitNexus] augment skipped: MCP
server owns DB` to stderr unconditionally on a normal (non-error) skip.
Strict hook runners that validate hook output (e.g. Codex `PreToolUse`)
treat that as noisy / "invalid pre-tool-use JSON output".

Gate the diagnostic behind GITNEXUS_DEBUG via a shared `isDebugEnabled()`
helper, so normal skips are silent by default (empty stdout AND stderr,
exit 0) and the reason stays recoverable with `GITNEXUS_DEBUG=1`. Applied
consistently to all three hand-maintained hook copies (claude,
antigravity, claude-plugin).

Tests:
- Unit (claude CJS + plugin): assert default-silent and debug-on behavior
  for the MCP-owned-DB skip and for the fail-closed (lsof ETIMEDOUT) skip
  that routes through the same gated line; the owner-detection tests run
  with GITNEXUS_DEBUG=1 so the skip discriminator stays observable.
- e2e (antigravity): the antigravity adapter shares the identical gated
  skip but only runs from its install dir, so cover it through the install
  pipeline with a faked DB-owner probe (strict empty-stdout/stderr +
  debug-on). Promote the fake-probe helpers (createHookToolDir / hookEnv,
  plus a module-private writeExecutable) into shared hook-test-helpers so
  unit + e2e reuse them.

Fixes #1913

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(hooks): unify GITNEXUS_DEBUG gating in main() catch handlers

The main() catch-handler in all three hook copies still gated its crash
log on truthy `if (process.env.GITNEXUS_DEBUG)`, while the skip diagnostic
the #1913 fix added is gated on the strict `isDebugEnabled()` helper
(=== '1' || === 'true'). That split meant GITNEXUS_DEBUG=0 or =false
suppressed the skip line yet still enabled crash logging — two conflicting
contract signals in the same file.

Switch the three catch handlers to isDebugEnabled() so GITNEXUS_DEBUG has
one strict meaning everywhere: exactly '1' or 'true' enables all
diagnostics; everything else (incl. '0', 'false', empty, unset) is silent.

Add boundary tests asserting the MCP-owner skip stays silent with
GITNEXUS_DEBUG='0' and 'false' (CJS + Plugin), pinning the strict contract.

Refs #1913

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(hooks): gate antigravity stale-index hint stderr behind GITNEXUS_DEBUG

The antigravity AfterTool handler mirrored the stale-index hint to stderr
unconditionally on a normal (non-error) success path — the last ungated
stderr write of the class issue #1913 targets, and a divergence from the
claude hook, which never mirrors this hint to stderr.

Gate the stderr mirror behind isDebugEnabled(). The hint still reaches the
agent via additionalContext (stdout JSON) — parts.push(hint) stays
unconditional — so there is no functional loss; only the by-default
terminal mirror moves behind GITNEXUS_DEBUG=1. This knowingly changes the
#1730 terminal-mirror behavior in favor of strict-runner cleanliness and
parity with the claude adapter.

Split the e2e assertion into a default-silent test (hint in
additionalContext, absent from stderr) and a GITNEXUS_DEBUG=1 test (hint
mirrored to stderr).

Refs #1913

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(hooks): document GITNEXUS_DEBUG=1 for hook diagnostics

GITNEXUS_DEBUG was documented only in the cursor integration README, so
the diagnostic escape hatch for the Claude Code / Antigravity hooks was
undiscoverable. Operators hitting a silent hook skip (MCP server owns the
DB, fail-closed probe timeout, or an already-current index) had no
documented way to surface the reason.

Add a Troubleshooting subsection explaining that the hooks stay silent on
normal skip paths for strict runners, that GITNEXUS_DEBUG=1 surfaces the
reason on stderr, and that only '1'/'true' enable diagnostics (stdout JSON
the agent consumes is unaffected).

Refs #1913

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(hooks): update setup-antigravity unit test for gated stale-index hint

U2 (7995e921) gated the antigravity stale-index hint stderr mirror behind
GITNEXUS_DEBUG, but a second test — setup-antigravity.test.ts's "AfterTool
emits stale-index hint" — also asserted the hint on stderr by default and
was missed (it lives outside the two files validated locally; the full CI
matrix caught it).

Update it to the U2 contract: assert the hint via additionalContext with
stderr silent by default, plus a GITNEXUS_DEBUG=1 run asserting the
terminal mirror reappears.

Refs #1913

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:09:41 +01:00
Gergő MagyarandClaude Opus 4.8 4f9d595c73 fix(docker): ship runtime-needed published assets (hooks/, skills/) into the image (#2130) (#2132)
* fix(docker): copy hooks/ into Dockerfile.cli runtime stage (#2130)

`gitnexus analyze` inside the official image (akonlabs/gitnexus,
ghcr.io/abhigyanpatwari/gitnexus) crashed at startup with:

    Error: Cannot find module '../../hooks/claude/resolve-analyze-cmd.cjs'
    Require stack:
    - /app/gitnexus/dist/cli/resolve-invocation.js

`dist/cli/resolve-invocation.js` does
`createRequire(import.meta.url)('../../hooks/claude/resolve-analyze-cmd.cjs')`
at module load (it is the single source of truth for the npm-11 npx-crash
invocation decision, #1939), and `analyze.ts` statically imports it. The
Dockerfile.cli runtime stage copied dist/node_modules/package.json/the
duckdb script/vendor but never `hooks/`, so the require throws before the
command does any work. `hooks/` is in package.json `files`, so npm already
ships it — Docker was the only distribution dropping it.

Fix: copy `hooks/` into the runtime stage, mirroring what npm publishes.

Also add `test/unit/dockerfile-runtime-asset-parity.test.ts`: a regression
guard that derives every out-of-dist `require()`/`createRequire()` target
from source and asserts each is a runtime-stage `COPY`. Scoped to the
require family (not `fs.access`/`new URL`), so it locks the #2130 class
without false-flagging the intentionally-omitted, gracefully-degrading
`web/` and `skills/` assets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(docker): also ship skills/ into the runtime image

Follow-up to the hooks/ fix: `skills/` is another published runtime asset
(in package.json `files`) the Docker image dropped. The CLI reads the
bundled SKILL.md templates from `<pkg>/skills/` for `gitnexus analyze
--skills` (ai-context skill generation) and `gitnexus setup`/`uninstall`
(installing skills into editor configs). Unlike the hooks/ require(), these
reads degrade SILENTLY when the dir is absent — `--skills` writes minimal
placeholder content (ai-context.ts), `setup` installs zero skills
(setup.ts readdir → []) — so the image looked fine but produced wrong
output. Copy `skills/` so the image is fully usable for all CLI tooling.

`web/` (also in `files`) is intentionally NOT shipped: this image never
builds gitnexus-web (the builder doesn't copy it, build.js logs "skipping
web UI"), so it is API-only by design — the UI is the separate
Dockerfile.web image / hosted app. The duckdb script is the only runtime
asset needed from scripts/, so that stays a single-file copy.

Extends the runtime-asset-parity guard with an explicit skills/ assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(test): correct stale docstring that listed skills/ as not copied

The 2nd commit on this branch added a skills/ COPY + an it('copies skills/…')
assertion, but the top-of-file docstring still grouped skills/ with web/ as
'intentionally not copied / out of scope'. Drop skills/ from that sentence and
note it is shipped (and covered by its own test). web/ remains the sole
fs-accessed-but-uncopied example. Documentation-only; assertions unchanged.

* fix(test): make runtime-stage detection case-insensitive on AS

Docker accepts a lowercase `as runtime`; the parity guard's stage-detection
regex was case-sensitive on `AS`, so a future Dockerfile reformat would empty
the parsed COPY set and trip the named assertions. Add the /i flag.

* fix(test): stop runtime-stage COPY parsing at the next FROM

runtimeStageCopiedSources scanned from the runtime FROM to EOF. Bound the scan
to the runtime stage (start after its FROM, break on the next FROM) so a build
stage added after runtime can't have its COPY lines misattributed. No-op today
(runtime is the last stage); the copied set is unchanged.

* fix(test): assert at least one runtime COPY is parsed (no vacuous pass)

If the runtime FROM or the /app/gitnexus/ source prefix ever stops matching,
the copied set goes empty and the parity assertion passes vacuously. Add an
explicit copied.length>0 guard so that failure mode is loud and named.

* fix(test): strip line comments before require-scanning

requiredExternalAssets() regex-scanned raw source, so a future doc-comment such
as a commented-out require('../../web/x') in a shallow src file would resolve
outside dist/ and spuriously fail the parity guard. Strip // line comments
first. Block comments are deliberately not stripped (a naive block strip mangles
slash-star inside string/glob literals). Verified the real-tree scanner output
is byte-identical with and without the strip, and resolve-invocation.ts's
multi-line createRequire is still detected. (Also swaps a stray non-ASCII glyph
in the prior commit's comment for ASCII.)

* fix(test): account for aliased + computed module-load requires (fail-closed)

The parity scanner only matched string-literal require/createRequire, so it
missed module-load requires via aliased createRequire bindings and computed
paths — and already failed to see community-processor.ts's
`_require(leidenPath)` -> vendor/leiden, making the "every out-of-dist asset"
claim untrue.

Broaden the scan:
- Discover per-file createRequire bindings (requireCJS, _require, …) and match
  their literal-arg calls; keep the createRequire(...)('…') IIFE form.
- Detect COMPUTED (non-literal) requires and gate them on MODULE-LOAD position
  (brace-depth 0), so the four in-function computed requires that target
  node_modules/package.json (optional-grammars, native-check, capabilities,
  parse-cache) are correctly out of charter and ignored. A module-load computed
  require must be vetted in KNOWN_COMPUTED_REQUIRES (seed: community-processor ->
  vendor/leiden) or the test FAILS CLOSED for manual review.
- Allowlist entries are coverage-checked via isCovered, never trusted: a new
  test removes the `vendor` COPY from a fixture and asserts leiden surfaces as
  uncovered (so deleting a COPY can't silently pass — the #2130 class).
- Exclude `<id>.resolve(...)` (a path lookup, not a load).
- Upgrade the comment stripper to a string-aware pass that removes line AND
  block comments without mangling slash-star inside string/glob literals — the
  computed branch needs JSDoc requires (e.g. javascript/index.ts) gone, and the
  literal scan output stays byte-identical.

Honest claim wording: the 4th test now says coverage = resolvable + vetted
module-load requires, unrecognized computed requires fail for review. Adds
unit tests for fail-closed, aliased-literal, and in-function-ignored paths.

* fix(test): also scan shipped .cjs/.mjs assets for sibling requires

The guard only scanned src/**/*.ts, so hand-written shipped runtime files were
invisible — and they DO require siblings: hooks/claude/gitnexus-hook.cjs and
hooks/antigravity/gitnexus-antigravity-hook.cjs each require('./hook-lock.cjs'),
'./hook-db-lock-probe.cjs', './resolve-analyze-cmd.cjs'. Add a second pass over
shipped .cjs/.mjs assets (the runtime COPY set minus dep/data roots), resolving
each relative require against the asset's OWN package-relative dir and checking
COPY coverage — by prefix, NOT on-disk existence: the antigravity hook's
'./hook-lock.cjs' resolves to hooks/antigravity/hook-lock.cjs (which doesn't
physically exist; hook-lock.cjs lives under hooks/claude) yet is covered by the
whole-hooks COPY. All 6 shipped sibling requires resolve under the hooks COPY.

* fix(docker): move hooks/skills COPYs past the DuckDB FTS RUN

The hooks/ and skills/ COPYs sat between the vendor COPY and the DuckDB
FTS-extension install RUN, so any edit to hook/skill content invalidated that
RUN's cache layer — which performs a one-time network INSTALL of the extension
(~tens of seconds per affected build). The COPYs have no input dependency on the
DuckDB step; relocate them to after it (before USER node) so stable
infrastructure layers are not rebuilt on hook/skill churn. Image contents are
unchanged. The runtime-asset-parity guard still detects both (its scan covers
the whole runtime stage), and the two are consolidated under one comment.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 08:38:37 +01:00
Gergő MagyarandClaude Opus 4.8 e46651e42c fix(embeddings): create VECTOR index via conn.query, not the prepared path (#2114)
`gitnexus analyze` silently failed to create the LadybugDB VECTOR/HNSW index because `CALL CREATE_VECTOR_INDEX(...)` was run through the prepared `conn.prepare()` path, which rejects multi-statement procedures — degrading semantic search to exact-scan. Route index creation through `conn.query()` via a new adapter-owned `createVectorIndex` (mirrors `createFTSIndex`), make the previously-swallowed error visible (`{ err }` logging), add an in-process idempotency cache, and add real-`@ladybugdb/core` regression coverage.

Fixes #2114.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 07:59:37 +01:00
dependabot[bot]andGergő Magyar 43e6a3f688 chore(deps)(deps): bump js-yaml from 4.1.1 to 4.2.0 in /gitnexus (#2097)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.1 to 4.2.0.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/commits)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-06-10 06:33:39 +01:00
dependabot[bot] 581943bbe5 chore(deps)(deps-dev): bump @types/node in /gitnexus (#2128)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.9.1 to 25.9.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.9.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-10 06:32:57 +01:00
228 changed files with 20301 additions and 1131 deletions
@@ -36,6 +36,7 @@ For any task involving code understanding, debugging, impact analysis, or refact
| `context` | 360-degree symbol view — categorized refs, processes it participates in |
| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence |
| `detect_changes` | Git-diff impact — what do your current changes affect |
| `check` | Check graph invariants such as circular imports |
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
| `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) |
+16 -67
View File
@@ -22,26 +22,10 @@
# --format '{{json .Manifest.Digest}}'
FROM mcr.microsoft.com/devcontainers/typescript-node@sha256:7c2e711a4f7b02f32d2da16192d5e05aa7c95279be4ce889cff5df316f251c1d
# Build args. We deliberately set no version defaults here. devcontainer.json
# `build.args` is the single source of truth for versions. A standalone
# `docker build .devcontainer/` (for example, a CI smoke test) must pass each
# version with --build-arg. Without a default, the build fails loudly instead of
# silently drifting from the version pinned in devcontainer.json.
ARG CLAUDE_CODE_VERSION
ARG CODEX_VERSION
# Cursor is pinned by version plus a per-arch tarball sha256 hash. The install
# step below verifies that hash. All three values live in devcontainer.json
# build.args. They follow the same rule as the others: one source of truth, and
# no default so the build fails loudly if a value is missing.
ARG CURSOR_VERSION
ARG CURSOR_SHA256_X64
ARG CURSOR_SHA256_ARM64
# Bun is installed via the official remote script (bun.sh/install), pinned by
# version. UNLIKE Cursor and the npm packages, this install path runs an
# UNVERIFIED remote script — there is no tarball-hash check. Chosen explicitly
# at request time over the pin-by-sha256 alternative for install-script
# simplicity. To harden later, switch to a pinned tarball + per-arch sha256 in
# the Cursor style (release artifacts at github.com/oven-sh/bun/releases).
# version. Claude Code and Cursor also use official install scripts (no version
# to pin). To harden Bun: switch to a pinned tarball + per-arch sha256
# (release artifacts at github.com/oven-sh/bun/releases).
ARG BUN_VERSION
ARG TZ=UTC
ARG USERNAME=node
@@ -50,10 +34,7 @@ ARG USERNAME=node
# read them. We deliberately do not set CLAUDE_CONFIG_DIR here. Its one true
# value lives in devcontainer.json `containerEnv`, and the runtime value wins
# anyway.
ENV CLAUDE_CODE_VERSION=${CLAUDE_CODE_VERSION} \
CODEX_VERSION=${CODEX_VERSION} \
CURSOR_VERSION=${CURSOR_VERSION} \
BUN_VERSION=${BUN_VERSION} \
ENV BUN_VERSION=${BUN_VERSION} \
BUN_INSTALL=/home/${USERNAME}/.bun \
TZ=${TZ} \
DEVCONTAINER=true \
@@ -86,51 +67,19 @@ RUN mkdir -p \
USER ${USERNAME}
# Install Claude Code and the Codex CLI globally, as the `node` user. The base
# image sets /usr/local/share/npm-global as the npm-global prefix and makes the
# `npm` group writable by `node`. So `npm install -g` works without sudo. Both
# versions come from build args. To upgrade, bump them in devcontainer.json and
# rebuild.
RUN npm install -g \
@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} \
@openai/codex@${CODEX_VERSION}
# Install Claude Code via the official native installer. Downloads the latest
# self-contained binary for the running platform and places it at
# ~/.local/bin/claude — no Node.js runtime dependency, no version to pin.
RUN curl -fsSL https://claude.ai/install.sh | bash
# Install the Cursor CLI. It is pinned and hash-verified, and we run no remote
# script. The cursor.com/install script just detects os/arch, downloads a
# versioned tarball from
# downloads.cursor.com/lab/<version>/<os>/<arch>/agent-cli-package.tar.gz,
# extracts it, and symlinks `agent`/`cursor-agent` into ~/.local/bin. We do that
# ourselves against a PINNED version plus a per-arch sha256 hash. So the build
# runs no unverified remote code. This matches how we pin the base image and npm
# packages by digest (issue #1451). The download is fail-closed: if the hash
# does not match, the build aborts.
#
# To bump: set CURSOR_VERSION and both CURSOR_SHA256_* in devcontainer.json
# build.args. Get each arch's hash with:
# curl -fSL https://downloads.cursor.com/lab/<ver>/linux/<x64|arm64>/agent-cli-package.tar.gz | sha256sum
#
# TARGETARCH is the per-platform build arg that BuildKit sets automatically. It
# must be (re)declared in this stage to be visible. When the build is a
# non-BuildKit `docker build`, TARGETARCH is unset, so we fall back to `dpkg
# --print-architecture`.
ARG TARGETARCH
RUN set -eux; \
arch="${TARGETARCH:-$(dpkg --print-architecture)}"; \
case "$arch" in \
amd64) cursor_arch=x64; cursor_sha="${CURSOR_SHA256_X64}";; \
arm64) cursor_arch=arm64; cursor_sha="${CURSOR_SHA256_ARM64}";; \
*) echo "unsupported architecture for Cursor: $arch" >&2; exit 1;; \
esac; \
url="https://downloads.cursor.com/lab/${CURSOR_VERSION}/linux/${cursor_arch}/agent-cli-package.tar.gz"; \
curl -fSL --retry 3 --max-time 120 -o /tmp/cursor.tgz "$url"; \
echo "${cursor_sha} /tmp/cursor.tgz" | sha256sum -c -; \
dir="/home/${USERNAME}/.local/share/cursor-agent/versions/${CURSOR_VERSION}"; \
install -d "$dir" "/home/${USERNAME}/.local/bin"; \
tar --strip-components=1 -xzf /tmp/cursor.tgz -C "$dir"; \
test -x "$dir/cursor-agent"; \
ln -sf "$dir/cursor-agent" "/home/${USERNAME}/.local/bin/agent"; \
ln -sf "$dir/cursor-agent" "/home/${USERNAME}/.local/bin/cursor-agent"; \
rm -f /tmp/cursor.tgz
# Install the Codex CLI globally via npm. No version pinned — @latest at build
# time. (Codex has no native binary installer; npm is the canonical method.)
RUN npm install -g @openai/codex
# Install the Cursor agent CLI via the official install script. Downloads the
# latest agent-cli-package for the running platform and places `cursor-agent`
# and `agent` into ~/.local/bin — no version or hash to pin.
RUN curl -fsSL https://cursor.com/install | bash
# Install Bun via the official remote installer, pinned by version. The first
# positional arg to `bash` is the release tag (`bun-vX.Y.Z`), so a specific
-21
View File
@@ -14,16 +14,6 @@
"dockerfile": "Dockerfile",
"context": ".",
"args": {
"CLAUDE_CODE_VERSION": "2.1.156",
"CODEX_VERSION": "0.134.0",
// Cursor: a pinned version plus one sha256 hash per CPU arch. The
// Dockerfile checks the tarball against the hash at build time, so it
// never runs a remote install script. Bump all three values together.
// Re-hash each arch with:
// curl -fSL https://downloads.cursor.com/lab/<ver>/linux/<x64|arm64>/agent-cli-package.tar.gz | sha256sum
"CURSOR_VERSION": "2026.05.28-a70ca7c",
"CURSOR_SHA256_X64": "7f8b6a09393e0b84b288cc6952b292fc98d15775f644cc01b0b9aa4f04b268df",
"CURSOR_SHA256_ARM64": "05a0ab361e038729aba25fe7f407531b3e8432912e499d0bffdf1dda0e7833e9",
// Bun: pinned by version. Installed by the official bun.sh/install
// script, which accepts the release tag as its first positional arg
// (`bash -s bun-vX.Y.Z`). UNLIKE Cursor, the install path runs an
@@ -324,17 +314,6 @@
// dependency explicit instead of silently following the default.
"containerEnv": {
"CODEX_HOME": "/home/node/.codex",
"DISABLE_AUTOUPDATER": "1",
// post-create.sh removes `installMethod` from the seeded ~/.claude.json so
// the npm-global binary detects its own install method. This is a backup
// safeguard for Claude Code issue #17289. The install-checks routine probes
// ~/.local/bin/claude just because that directory EXISTS. It does exist
// here, because Cursor drops agent and cursor-agent symlinks there. So even
// when installMethod is non-native, the routine reports a false "claude
// command not found at ~/.local/bin/claude". DISABLE_AUTOUPDATER does NOT
// turn that routine off. DISABLE_INSTALLATION_CHECKS is its dedicated kill
// switch.
"DISABLE_INSTALLATION_CHECKS": "1",
"HISTFILE": "/commandhistory/.zsh_history"
},
+6 -9
View File
@@ -141,15 +141,12 @@ sync_from_host /host/.codex/config.toml /home/node/.codex/config.toml 644
# Seed $HOME/.claude.json from the host, but NOT as a straight copy. That file
# mixes two kinds of state. Some is portable account and onboarding state we
# want to keep: hasCompletedOnboarding, oauthAccount, userID, projects,
# tipsHistory. The rest describes how Claude is installed on the host, and that
# part is never valid here. This image installs Claude with `npm install -g`,
# but the host's `installMethod` (for example "native") makes Claude look for
# ~/.local/bin/claude and fail with
# "claude command not found at /home/node/.local/bin/claude". The fix strips the
# machine-specific fields and forces hasCompletedOnboarding, while handling a
# host file that isn't a JSON object. That logic lives in seed-claude-config.cjs
# so it can be unit-tested and prettier-checked
# (translate-plugin-registries.test.cjs).
# tipsHistory. The rest describes how Claude is installed on the HOST, and that
# part is never valid here — for example the host's `installMethod` value only
# makes sense for the host's binary. The fix strips the machine-specific fields
# and forces hasCompletedOnboarding, while handling a host file that isn't a
# JSON object. That logic lives in seed-claude-config.cjs so it can be
# unit-tested and prettier-checked (translate-plugin-registries.test.cjs).
node "$SCRIPT_DIR/seed-claude-config.cjs"
# Codex auth. Some hosts store credentials in the OS keyring instead of on disk
@@ -102,7 +102,7 @@ jobs:
matrix: ${{ steps.decide.outputs.matrix }}
release_app: ${{ steps.relapp.outputs.configured }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0 # need base history to diff recorded versions
persist-credentials: false
@@ -270,7 +270,7 @@ jobs:
# and compiling them under emulation on the arm runners is slow.
timeout-minutes: 45
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false # this job uploads artifacts (artipacked)
@@ -280,7 +280,7 @@ jobs:
- name: Ensure Python (arm64 Windows only)
if: matrix.platform_arch == 'win32-arm64'
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
@@ -431,7 +431,7 @@ jobs:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
token: ${{ steps.app-token.outputs.token }}
persist-credentials: false
@@ -477,7 +477,7 @@ jobs:
NODE
- name: Attest build provenance (SLSA)
uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-path: 'gitnexus/vendor/tree-sitter-*/prebuilds/**/*.node'
+2 -2
View File
@@ -36,7 +36,7 @@ jobs:
# persist-credentials: false — this job only reads (tests and syntax
# checks) and never pushes. The setting keeps GITHUB_TOKEN out of
# .git/config, which zizmor flags as the "artipacked" issue.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -57,7 +57,7 @@ jobs:
# persist-credentials: false — this is a read-only build smoke that
# never pushes. The setting keeps GITHUB_TOKEN out of .git/config,
# which zizmor flags as the "artipacked" issue.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+2 -2
View File
@@ -14,7 +14,7 @@ jobs:
outputs:
web_changed: ${{ steps.filter.outputs.web }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v3
id: filter
with:
@@ -29,7 +29,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Configure e2e GitNexus home
run: echo "GITNEXUS_HOME=${RUNNER_TEMP}/gitnexus-home" >> "$GITHUB_ENV"
+5 -5
View File
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
@@ -37,7 +37,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: ./.github/actions/setup-gitnexus
- run: npx tsc --noEmit
working-directory: gitnexus
@@ -46,7 +46,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: ./.github/actions/setup-gitnexus-web
- run: npx tsc -b --noEmit
working-directory: gitnexus-web
@@ -67,7 +67,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Validate workflow concurrency convention
shell: bash
run: |
+1 -1
View File
@@ -125,7 +125,7 @@ jobs:
- name: Checkout (for vitest config)
if: steps.meta.outputs.skip != 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
sparse-checkout: gitnexus/vitest.config.ts
sparse-checkout-cone-mode: false
+15 -5
View File
@@ -16,7 +16,7 @@ jobs:
# test-reports artifact (if: always()). The default-persisted token in
# .git/config must not be capturable through that upload (zizmor
# credential-persistence / artipacked audit). The job never pushes.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- uses: ./.github/actions/setup-gitnexus
@@ -80,7 +80,7 @@ jobs:
steps:
# persist-credentials: false — runs tests only, never pushes (zizmor
# credential-persistence / artipacked audit).
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- uses: ./.github/actions/setup-gitnexus
@@ -106,7 +106,7 @@ jobs:
runs-on: ${{ matrix.os }}
timeout-minutes: 20
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: ./.github/actions/setup-gitnexus
with:
build: 'true'
@@ -138,7 +138,7 @@ jobs:
# from a tarball and never pushes back; the token in .git/config would
# be at risk of leaking through any future artifact-upload step
# (zizmor artipacked audit). Disable upfront.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- uses: ./.github/actions/setup-gitnexus
@@ -246,7 +246,7 @@ jobs:
# and never pushes; the default-persisted token in .git/config would be at
# risk of leaking through an artifact upload (zizmor credential-persistence
# / artipacked audit). Mirrors the packaged-install-smoke job below.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- uses: ./.github/actions/setup-gitnexus
@@ -266,6 +266,16 @@ jobs:
run: node --import tsx bench/scope-capture/measure.mjs --check
working-directory: gitnexus
- name: CFG construction time / disk / memory guards (#2081 M1)
# Build-free: asserts collectFunctionCfgs output is unchanged
# (fingerprint) and that wall-time, cfgSideChannel disk bytes, AND
# retained heap all stay sub-quadratic for the straight-line /
# many-functions / branchy scenarios. Catches an O(n^2) re-regression in
# the per-function CFG builder (e.g. an extendBlock concat chain) and a
# memory/disk blow-up. --expose-gc enables the retained-heap measurement.
run: node --expose-gc --import tsx bench/cfg/measure.mjs --check
working-directory: gitnexus
- name: Cross-language pipeline benchmarks (GITNEXUS_BENCH, serial)
env:
GITNEXUS_BENCH: '1'
+1 -1
View File
@@ -129,7 +129,7 @@ jobs:
core.setOutput('code_review', isCodeReview ? 'true' : 'false');
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
repository: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.repo || github.repository }}
ref: ${{ steps.pr.outputs.is_pr == 'true' && steps.pr.outputs.sha || '' }}
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
# Don't leave GITHUB_TOKEN in .git/config for downstream steps to read.
persist-credentials: false
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
+2 -2
View File
@@ -101,7 +101,7 @@ jobs:
# When triggered by workflow_call the caller passes the RC tag as an input;
# we check out that tag so the Dockerfile and package.json match the built image.
# For tag-push events github.ref is already the tag ref — no override needed.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ inputs.tag || github.ref }}
@@ -138,7 +138,7 @@ jobs:
# Required for multi-platform (linux/arm64) emulation.
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
# Full history needed for the on-push full-history scan; on PRs the
# action diffs against the base ref so the cost is bounded by the PR.
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
+1 -1
View File
@@ -336,7 +336,7 @@ jobs:
# Push auth is provided inline at push time via the URL.
- name: Checkout PR head
if: steps.locate.outputs.found == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5.0.4
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v5.0.4
with:
repository: ${{ steps.locate.outputs.head_repo }}
ref: ${{ steps.locate.outputs.head_sha }}
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
# PR head commit (not the synthetic merge ref) — we need the
# exact tree the contributor pushed so suggestions line up.
+1 -1
View File
@@ -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@c2e2804cc59f45f57076a99af580d0fedb697927 # v7.3.0
- uses: release-drafter/release-drafter@693d20e7c1ce1a81d3a41962f85914253b518449 # v7.3.1
with:
config-name: release-drafter.yml
dry-run: true
+3 -3
View File
@@ -162,7 +162,7 @@ jobs:
should_run: ${{ steps.decide.outputs.should_run }}
head_sha: ${{ steps.decide.outputs.head_sha }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
fetch-tags: true
@@ -332,7 +332,7 @@ jobs:
# on the RC path.
- name: Checkout (RC)
if: needs.route.outputs.mode == 'rc'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
fetch-tags: true
@@ -349,7 +349,7 @@ jobs:
- name: Checkout (stable)
if: needs.route.outputs.mode == 'stable'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# No `token:` — actions/checkout uses GITHUB_TOKEN by default. Stable
# path performs no git pushes; the default scope is sufficient.
with:
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@@ -37,7 +37,7 @@ jobs:
# Needed to open/update the tracking issue on scheduled runs.
issues: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: ./.github/actions/setup-gitnexus
with:
+1 -1
View File
@@ -59,7 +59,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
sparse-checkout: .github/scripts/triage
sparse-checkout-cone-mode: false
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
+2 -2
View File
@@ -31,7 +31,7 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@@ -53,7 +53,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
+4
View File
@@ -204,6 +204,10 @@ Language-agnostic scope-resolution resolver. This is the resolution path for eve
Orchestrator: `runScopeResolution(input, provider)` in `scope-resolution/pipeline/run.ts`.
Pipeline phase: `scopeResolutionPhase` in `scope-resolution/pipeline/phase.ts` — iterates the registered `SCOPE_RESOLVERS` over the worker-serialized `ParsedFile`s. (Per-language `emitScopeCaptures` hooks may reuse a cached Tree via the orchestrator's `treeCache`, but in worker-pool runs that cache is empty — Trees can't cross MessageChannels — so they consume the pre-extracted `ParsedFile` instead; § Performance notes.)
### Optional CFG/PDG emission (`--pdg`, #2081 M1)
On a `--pdg` run, the parse worker builds a per-function control-flow graph from the tree-sitter AST (`LanguageProvider.cfgVisitor`; TypeScript/JavaScript in M1) and serializes it onto `ParsedFile.cfgSideChannel` as plain data. Scope-resolution then emits `BasicBlock` nodes + `CFG` edges from that side-channel **inside Phase 4 of `runScopeResolution`, while the disk-backed ParsedFile store is still live** — the only window where the worker-built CFGs are loaded (the store is cleared right after the phase returns). A standalone post-`mro` phase would read an empty store, so the CFG emit deliberately lives in-phase, mirroring the `applyCaptureSideChannel` pattern. The opt-in is off by default (graph byte-identical), folded into the parse-cache key (a pdg-off warm cache is never reused on a `--pdg` run), and bounded by a per-function edge cap that logs any dropped edges. Edge *kind* (`seq`/`cond-true`/`loop-back`/…) rides in the `CFG` relationship's `reason` (CFG is a single `CodeRelation` type, not one type per kind). See `core/ingestion/cfg/`.
### `ScopeResolver` contract
Single interface a language implements to plug into the pipeline. Contract fully documented in `scope-resolution/contract/scope-resolver.ts`.
+4
View File
@@ -4,6 +4,10 @@ All notable changes to GitNexus will be documented in this file.
## [Unreleased]
### Fixed
- **Hook db-lock probe no longer strands unkillable `lsof`/`ps` orphans** — the probe's `lsof`/`ps` subprocesses are now wrapped in a self-tested coreutils `timeout`/`gtimeout` (`timeout -k 1 …`), so a hook SIGKILLed by the runner's 10s timeout can no longer leave `lsof` running forever (orphan lifetime bounded at ~3s); `acquireHookSlot` now also gates the probe itself, capping concurrent probes at 3 per repo. Opt out with `GITNEXUS_HOOK_TIMEOUT_PATH=disabled`. (#2163)
### Changed
- Migrated from KuzuDB to LadybugDB v0.15 (`@ladybugdb/core`, `@ladybugdb/wasm-core`)
- Renamed all internal paths from `kuzu` to `lbug` (storage: `.gitnexus/kuzu` → `.gitnexus/lbug`)
+16
View File
@@ -100,6 +100,22 @@ ENV HOME=/home/node \
RUN su node -s /bin/sh -c "HOME=/home/node node /app/gitnexus/scripts/install-duckdb-extension.mjs fts" \
&& su node -s /bin/sh -c "HOME=/home/node node /app/gitnexus/scripts/install-duckdb-extension.mjs fts --verify-only"
# Published runtime assets (in package.json `files`). Placed AFTER the DuckDB
# FTS-extension RUN above so editing hook/skill content does not invalidate that
# network-fetching cache layer; they have no input dependency on it.
# `hooks/`: dist/cli/resolve-invocation.js does
# `require('../../hooks/claude/resolve-analyze-cmd.cjs')` at module load — the
# single source of truth for the npm-11 npx-crash invocation decision (#1939).
# Without it, `gitnexus analyze` inside the image crashes with MODULE_NOT_FOUND
# before it does any work (#2130). `skills/`: the CLI reads the bundled SKILL.md
# templates from `<pkg>/skills/` for `gitnexus analyze --skills` and `gitnexus
# setup`/`uninstall`; absent, those degrade silently (placeholder content / zero
# skills installed). (The web UI bundle `web/`, also in `files`, is deliberately
# NOT shipped: this builder never builds gitnexus-web, so the image is API-only;
# the UI is the separate Dockerfile.web image / hosted app.)
COPY --from=builder --chown=node:node /app/gitnexus/hooks ./gitnexus/hooks
COPY --from=builder --chown=node:node /app/gitnexus/skills ./gitnexus/skills
USER node
# The web UI defaults to http://localhost:4747 - keep that contract.
+2
View File
@@ -703,6 +703,8 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
**Imports** — cross-file import resolution · **Named Bindings** — `import { X as Y }` / re-export tracking · **Exports** — public/exported symbol detection · **Heritage** — class inheritance, interfaces, mixins · **Type Annotations** — explicit type extraction for receiver resolution · **Constructor Inference** — infer receiver type from constructor calls (`self`/`this` resolution included for all languages) · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics
**Control flow (CFG, opt-in `--pdg`)** — per-function control-flow graphs (`BasicBlock` nodes + `CFG` edges) feeding the PDG/taint substrate, currently **TypeScript & JavaScript** (#2081 M1); other languages planned. Off by default.
---
## Tool Examples
+35 -7
View File
@@ -110,10 +110,20 @@ function hasGitNexusServerOwner(gitNexusDir) {
return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid);
}
/**
* Whether opt-in diagnostics should be written to the hook's stderr. Strict
* hook runners (e.g. Codex `PreToolUse`) validate hook output, so normal,
* non-error skip paths must stay silent unless the operator explicitly asks
* for diagnostics via GITNEXUS_DEBUG. See issue #1913.
*/
function isDebugEnabled() {
return process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
}
function extractAugmentContext(stderr) {
const output = (stderr || '').trim();
const marker = output.indexOf('[GitNexus]');
const debug = process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
const debug = isDebugEnabled();
if (debug && output.length > 0) {
// Emit the FULL discarded prefix (everything before the marker, or all of
// it when no marker is present) so suppressed diagnostics — LadybugDB lock
@@ -266,16 +276,34 @@ function handlePreToolUse(input) {
const pattern = extractPattern(toolName, toolInput);
if (!pattern || pattern.length < 3) return;
if (hasGitNexusServerOwner(gitNexusDir)) {
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
// Acquire the per-repo slot BEFORE the DB-owner probe (#2163): the probe
// itself spawns lsof/ps, so it must be bounded by the same ≤3-per-repo cap
// as the augment, or concurrent sessions fan out unbounded probe
// subprocesses. Keep the acquire right after the cheap guards above —
// moving it earlier would churn slot files on tool calls that never probe.
const release = acquireHookSlot(gitNexusDir);
if (!release) {
// Normal skip path: all per-repo hook slots are held by concurrent
// sessions. Stay silent for strict hook runners (issue #1913); surface
// the reason only when diagnostics are explicitly requested.
if (isDebugEnabled()) {
process.stderr.write('[GitNexus] augment skipped: hook slots saturated\n');
}
return;
}
const release = acquireHookSlot(gitNexusDir);
if (!release) return;
let result = '';
try {
if (hasGitNexusServerOwner(gitNexusDir)) {
// Normal skip path: the MCP server owns the DB, so the CLI augment would
// contend on the lock. Stay silent for strict hook runners (issue #1913);
// surface the reason only when diagnostics are explicitly requested.
if (isDebugEnabled()) {
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
}
return;
}
const child = runGitNexusCli(['augment', '--', pattern], cwd, 7000);
if (!child.error && child.status === 0) {
result = extractAugmentContext(child.stderr || '');
@@ -366,7 +394,7 @@ function main() {
const handler = handlers[input.hook_event_name || ''];
if (handler) handler(input);
} catch (err) {
if (process.env.GITNEXUS_DEBUG) {
if (isDebugEnabled()) {
console.error('GitNexus hook error:', (err.message || '').slice(0, 200));
}
}
@@ -11,6 +11,22 @@
*
* Fail-open on most errors; fail-closed only on lsof ETIMEDOUT (Unix) or
* PowerShell ETIMEDOUT (Windows), matching the hook contract.
*
* Unix subprocess containment contract (#2163):
* - lsof/ps are wrapped in coreutils `timeout`/`gtimeout` when a working
* wrapper is found (`timeout -k 1 <budget> lsof ...`). If this hook process
* is itself SIGKILLed (e.g. by the runner's 10s hook timeout) the wrapper
* survives, SIGTERMs its child at the budget (2s lsof / 1s ps) and SIGKILLs
* it 1s later — orphan lifetime is bounded at ~3s instead of unbounded.
* - GITNEXUS_HOOK_TIMEOUT_PATH: the sentinel value `disabled` switches the
* wrapper off deterministically; any other value is adopted only when it
* exists AND passes a one-shot `-k` self-test — otherwise resolution FALLS
* THROUGH to the built-in candidate list (first self-test pass wins), so
* no malformed value of any shape can silently disable orphan containment.
* - The gitnexus server is lazy-open + sticky-hold: an idle MCP server holds
* ZERO lbug fds until the repo's first MCP query, then keeps the fd open.
* A probe before that first query is therefore always false — a known,
* pre-existing race, not a bug in this probe.
*/
const fs = require('fs');
@@ -46,6 +62,80 @@ function resolveHookBinary(tool) {
return tool;
}
// Sentinel:
// undefined = not resolved yet (resolve lazily, on first lsof/ps fallback)
// string = self-tested coreutils timeout/gtimeout path (use as wrapper)
// null = no usable wrapper (disabled, none found, or self-test failed)
let unixGuardTimeoutCache;
/**
* Resolve a coreutils `timeout`/`gtimeout` binary to wrap lsof/ps with
* (#2163). Dead code on Windows (the win32 dispatch returns earlier).
*
* GITNEXUS_HOOK_TIMEOUT_PATH semantics: the sentinel `disabled` turns the
* wrapper off; any other value is only a CANDIDATE — an existing file path
* is tried first, but it must pass the `-k` self-test to be adopted. On any
* failure (non-existent path, directory, non-executable file, wrapper
* without `-k` support, …) resolution falls through to the built-in
* candidates below, tried in order, first self-test pass wins. This is
* strictly stronger than the sibling GITNEXUS_HOOK_LSOF_PATH /
* GITNEXUS_HOOK_PS_PATH overrides (which only check existence): no bad env
* value of ANY shape can silently disable orphan containment.
*
* Lazy self-test: candidates are probed only when the lsof/ps fallback is
* first reached, and the result is memoized. A candidate is adopted only
* when `timeout -k 1 1 /bin/sh -c :` exits 0. This rejects wrappers that do
* not support the coreutils `-k` flag — busybox <1.34, toybox, broken
* symlinks — which would otherwise exit with a usage error without ever
* running lsof, silently converting the lsof-ETIMEDOUT fail-closed contract
* into fail-open (#1492 regression). Only when EVERY candidate fails does
* the probe fall back to the unwrapped status quo (memoized null).
* busybox ≥1.34 passes the test and is fully usable (capability, not
* identity, decides).
*/
function passesGuardSelfTest(guard) {
try {
const selfTest = spawnSync(guard, ['-k', '1', '1', '/bin/sh', '-c', ':'], {
encoding: 'utf-8',
timeout: 3000,
stdio: ['ignore', 'ignore', 'ignore'],
windowsHide: true,
});
return !selfTest.error && selfTest.status === 0;
} catch {
return false;
}
}
function resolveUnixGuardTimeout() {
if (unixGuardTimeoutCache !== undefined) return unixGuardTimeoutCache;
unixGuardTimeoutCache = null;
const fromEnv = process.env.GITNEXUS_HOOK_TIMEOUT_PATH;
const trimmed = fromEnv ? String(fromEnv).trim() : '';
if (trimmed === 'disabled') return unixGuardTimeoutCache;
const candidates = [];
if (trimmed && fs.existsSync(trimmed)) candidates.push(trimmed);
for (const builtin of [
'/usr/bin/timeout',
'/bin/timeout',
'/opt/homebrew/bin/gtimeout',
'/usr/local/bin/gtimeout',
]) {
try {
if (fs.existsSync(builtin)) candidates.push(builtin);
} catch {
/* ignore */
}
}
for (const candidate of candidates) {
if (passesGuardSelfTest(candidate)) {
unixGuardTimeoutCache = candidate;
break;
}
}
return unixGuardTimeoutCache;
}
function resolveWindowsPowerShellPath() {
const fromEnv = process.env.GITNEXUS_HOOK_POWERSHELL_PATH;
if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv).trim())) {
@@ -188,20 +278,46 @@ function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) {
}
function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
const guard = resolveUnixGuardTimeout();
const lsofPath = resolveHookBinary('lsof');
const lsof = spawnSync(lsofPath, ['-nP', '-t', '--', dbPathAbs], {
// The spawnSync timeouts below (lsof 1000ms / ps 500ms) are deliberately
// SHORTER than the wrapper budgets (2s / 1s): on the supervised path Node's
// SIGTERM always fires first, so `error.code === 'ETIMEDOUT'` and the
// fail-closed contract are untouched. The wrapper only matters once this
// hook process has been SIGKILLed and can no longer deliver that SIGTERM.
const [lsofCmd, lsofArgs] = guard
? [guard, ['-k', '1', '2', lsofPath, '-nP', '-t', '--', dbPathAbs]]
: [lsofPath, ['-nP', '-t', '--', dbPathAbs]];
const lsof = spawnSync(lsofCmd, lsofArgs, {
encoding: 'utf-8',
timeout: 1000,
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
});
if (lsof.error) return lsof.error.code === 'ETIMEDOUT';
// Guard-mediated deaths map to "unresponsive holder" (fail-closed). Three
// result shapes, verified against coreutils 9.1:
// - signal-death: when `-k` escalates to SIGKILL, coreutils timeout
// SELF-RAISES the signal, so spawnSync reports {status: null, signal}
// with no .error (spawnSync's own ETIMEDOUT was handled above). The
// same shape appears when this hook is frozen >2s (SIGSTOP, laptop
// suspend) and the guard expires while it sleeps. By construction, a
// guard-wrapped probe that died by signal without spawnSync ETIMEDOUT
// is a budget/kill outcome.
// - 124: budget expired and the child exited after the plain SIGTERM.
// - 137: NOT the coreutils -k path — only exit-code-propagating wrappers,
// or a child SIGKILLed externally (e.g. the OOM killer).
if (guard && lsof.status === null && lsof.signal) return true;
if (guard && (lsof.status === 124 || lsof.status === 137)) return true;
const pids = (lsof.stdout || '').split(/\s+/).filter(Boolean);
const psPath = resolveHookBinary('ps');
for (const pid of pids) {
if (Number(pid) === myPid) continue;
const ps = spawnSync(psPath, ['-p', pid, '-o', 'command='], {
const [psCmd, psArgs] = guard
? [guard, ['-k', '1', '1', psPath, '-p', pid, '-o', 'command=']]
: [psPath, ['-p', pid, '-o', 'command=']];
const ps = spawnSync(psCmd, psArgs, {
encoding: 'utf-8',
timeout: 500,
stdio: ['ignore', 'pipe', 'ignore'],
@@ -211,6 +327,11 @@ function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
if (ps.error.code === 'ETIMEDOUT') return true;
continue;
}
// Same guard-mediated-death mapping as the lsof call above (signal-death
// from the -k escalation or a frozen hook; 124 budget expiry; 137 only
// for exit-code-propagating wrappers / external SIGKILL).
if (guard && ps.status === null && ps.signal) return true;
if (guard && (ps.status === 124 || ps.status === 137)) return true;
if (isGitNexusServerCommand(ps.stdout || '')) return true;
}
return false;
@@ -98,4 +98,26 @@ export interface ParsedFile {
* side effects — the contract default) leave this undefined.
*/
readonly captureSideChannel?: unknown;
/**
* Per-function control-flow graphs for this file (#2081 M1, PDG/taint
* substrate). A DISTINCT field from {@link captureSideChannel} — different
* producer, consumer, and lifecycle: the worker builds it from the
* tree-sitter AST via `LanguageProvider.cfgVisitor` (only on a `--pdg` run),
* and scope-resolution emits BasicBlock nodes + CFG edges from it while the
* disk-backed ParsedFile store is still live (it is NOT a capture-time
* marker the resolver restores into module maps). Kept separate so a future
* change to either channel's shape invalidates independently.
*
* Shared / ingestion code treats this as opaque (`unknown`) per AGENTS.md.
* Concretely it is a `readonly FunctionCfg[]` (see
* `core/ingestion/cfg/types.ts`) — plain JSON-serializable data (no AST
* refs, no class instances) so it round-trips through the parse cache and
* the `parsedfile-store` (whose interning reviver keys on `nodeId`, which
* these blocks/edges deliberately lack).
*
* Optional: `undefined` on non-`--pdg` runs and for languages with no
* `cfgVisitor` — the default for every run today.
*/
readonly cfgSideChannel?: unknown;
}
@@ -57,6 +57,10 @@ export interface SymbolDefinition {
* Currently used by C++ overload ranking to exclude explicit constructors
* from implicit user-defined conversion candidates. */
isExplicit?: boolean;
/** True when the callable is declared unavailable (for example C++ `= delete`).
* Unavailable callables still participate in overload selection, but a
* selected unavailable target must suppress edge emission. */
isDeleted?: boolean;
/** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */
ownerId?: string;
/** #1982/#1993: bridge-held enclosing-namespace path (e.g. `NS1`, `Outer.Inner`)
+112
View File
@@ -0,0 +1,112 @@
import { test, expect } from '@playwright/test';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
/**
* E2E for the browser folder-upload flow (replaces the removed server-side
* directory picker). Mocks the backend so no live gitnexus server is needed.
*/
const BACKEND_URL = 'http://localhost:4747';
let fixtureDir: string;
test.beforeAll(() => {
// A tiny "repo" folder; Playwright sets webkitRelativePath = <folder>/<file>.
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-upload-e2e-'));
fixtureDir = path.join(root, 'myrepo');
fs.mkdirSync(path.join(fixtureDir, 'src'), { recursive: true });
fs.writeFileSync(path.join(fixtureDir, 'README.md'), '# hi\n');
fs.writeFileSync(path.join(fixtureDir, 'src', 'index.ts'), 'export const x = 1;\n');
});
test.beforeEach(async ({ page }) => {
await page.route(`${BACKEND_URL}/api/repos`, (route) => route.fulfill({ json: [] }));
await page.route(`${BACKEND_URL}/api/info`, (route) =>
route.fulfill({ json: { version: '1.0.0', launchContext: 'npx', nodeVersion: 'v22.0.0' } }),
);
await page.route(`${BACKEND_URL}/api/heartbeat`, (route) =>
route.fulfill({
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
body: ':ok\n\n',
}),
);
});
test('uploading a folder posts a multipart upload and starts analysis', async ({ page }) => {
let uploadContentType = '';
await page.route(`${BACKEND_URL}/api/analyze/upload`, async (route) => {
uploadContentType = route.request().headers()['content-type'] ?? '';
await route.fulfill({ json: { jobId: 'job-e2e', status: 'analyzing' } });
});
// SSE progress → immediately complete.
await page.route(`${BACKEND_URL}/api/analyze/job-e2e/progress`, (route) =>
route.fulfill({
status: 200,
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
body: 'event: complete\ndata: {"repoName":"myrepo"}\n\n',
}),
);
await page.goto('/');
await expect(page.getByRole('tab', { name: 'Local Folder' })).toBeVisible({ timeout: 20_000 });
await page.getByRole('tab', { name: 'Local Folder' }).click();
await expect(page.locator('[data-testid="upload-folder"]')).toBeVisible();
// Select the fixture folder via the hidden webkitdirectory input.
await page.locator('[data-testid="folder-upload-input"]').setInputFiles(fixtureDir);
// The upload endpoint should be hit with a multipart body, and the UI should
// leave the input phase (upload button no longer shown).
await expect.poll(() => uploadContentType).toContain('multipart/form-data');
await expect(page.locator('[data-testid="upload-folder"]')).toBeHidden({ timeout: 10_000 });
});
test('switching modes mid-upload aborts it and never shows progress', async ({ page }) => {
// Hold the upload response until the test releases it, so the mode switch
// happens while the POST is in flight (the review 4470339833 repro).
let releaseUpload!: () => void;
const uploadGate = new Promise<void>((res) => (releaseUpload = res));
let uploadAborted = false;
let progressOpened = false;
// The client-side AbortController kills the POST at mode-switch time; that
// surfaces as a failed request (net::ERR_ABORTED), not as a response.
page.on('requestfailed', (req) => {
if (req.url().includes('/api/analyze/upload') && /ABORTED/.test(req.failure()?.errorText ?? ''))
uploadAborted = true;
});
await page.route(`${BACKEND_URL}/api/analyze/upload`, async (route) => {
await uploadGate;
await route.fulfill({ json: { jobId: 'job-stale', status: 'analyzing' } }).catch(() => {}); // the request may already be gone — that's the point
});
await page.route(`${BACKEND_URL}/api/analyze/job-stale/progress`, (route) => {
progressOpened = true;
return route.fulfill({
status: 200,
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
body: 'event: complete\ndata: {"repoName":"myrepo"}\n\n',
});
});
await page.goto('/');
await expect(page.getByRole('tab', { name: 'Local Folder' })).toBeVisible({ timeout: 20_000 });
await page.getByRole('tab', { name: 'Local Folder' }).click();
await page.locator('[data-testid="folder-upload-input"]').setInputFiles(fixtureDir);
await expect(page.locator('[data-testid="upload-progress"]')).toBeVisible();
// Switch back to GitHub while the upload POST is still pending, then let
// the (now-stale) route handler finish.
await page.getByRole('tab', { name: 'GitHub URL' }).click();
await expect.poll(() => uploadAborted, { timeout: 10_000 }).toBe(true);
releaseUpload();
// The GitHub form stays clean (no error, immediately usable), and no SSE
// progress stream is ever opened by the stale upload.
await expect(page.getByPlaceholder('https://github.com/owner/repo')).toBeEditable();
await expect(page.locator('[data-testid="upload-progress"]')).toBeHidden();
expect(progressOpened).toBe(false);
});
+2 -2
View File
@@ -218,8 +218,8 @@ test.describe('Flow 3: Analyze form', () => {
// Switch to Local Folder tab
await page.getByRole('tab', { name: 'Local Folder' }).click();
// Browse button should be visible
await expect(page.getByText('Browse for folder')).toBeVisible();
// Upload-a-folder button should be visible (browser folder upload)
await expect(page.locator('[data-testid="upload-folder"]')).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('local-folder-tab.png') });
});
+75 -75
View File
@@ -18,7 +18,7 @@
"@tailwindcss/vite": "^4.3.0",
"axios": "^1.16.1",
"d3": "^7.9.0",
"dompurify": "^3.4.7",
"dompurify": "^3.4.8",
"gitnexus-shared": "file:../gitnexus-shared",
"graphology": "^0.26.0",
"graphology-indices": "^0.17.0",
@@ -28,7 +28,7 @@
"graphology-utils": "^2.3.0",
"i18next": "^26.3.0",
"i18next-browser-languagedetector": "^8.2.1",
"langchain": "^1.4.2",
"langchain": "^1.4.4",
"lru-cache": "^11.2.4",
"lucide-react": "^1.16.0",
"mermaid": "^11.15.0",
@@ -41,7 +41,7 @@
"react-syntax-highlighter": "^16.1.1",
"react-zoom-pan-pinch": "^4.0.3",
"remark-gfm": "^4.0.1",
"sigma": "^3.0.2",
"sigma": "^3.0.3",
"tailwindcss": "^4.2.4",
"uuid": "^14.0.0",
"zod": "^4.4.3"
@@ -57,9 +57,9 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13",
"@vercel/node": "^5.8.8",
"@vercel/node": "^5.8.12",
"@vitejs/plugin-react": "^5.1.4",
"@vitest/coverage-v8": "^4.1.5",
"@vitest/coverage-v8": "^4.1.8",
"jsdom": "^29.1.1",
"tree-sitter-wasms": "^0.1.13",
"typescript": "^5.4.5",
@@ -2930,9 +2930,9 @@
}
},
"node_modules/@vercel/build-utils": {
"version": "13.26.4",
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-13.26.4.tgz",
"integrity": "sha512-0g3ZxtZUJZbt4y0Vu4pkHtu1UN58FbVF9cqGT8T6jHp0EHdLGFj5TVCiME8ALeK4tjPImSmxnKZvvB5yb2hqEw==",
"version": "13.27.1",
"resolved": "https://registry.npmjs.org/@vercel/build-utils/-/build-utils-13.27.1.tgz",
"integrity": "sha512-BD9H2U8I/IPGS1c1stSIkdPxBRu6bkCQFqtRjcT2dcdnBawyHXvzTsnnBQhgB3fJVQtgJT47gVZe1oHDmv0Ktg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -2949,9 +2949,9 @@
"license": "MIT"
},
"node_modules/@vercel/error-utils": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@vercel/error-utils/-/error-utils-2.1.0.tgz",
"integrity": "sha512-DiJcXBOB9N6QM4d7hYPM9Ck/AUjzBl58XNQPxS74o7CuvIanjzrGgygP/70VsyEASeIJMazk1LrhwcNTR/eZGQ==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@vercel/error-utils/-/error-utils-2.2.0.tgz",
"integrity": "sha512-WFWiRxfPzoYWYifaj4thSKvAaZZwUOqD4k5GINRIgZgCiS2E3iAJbWbIsIZmkQdTecWFHcWGA6q48CjisgpOBA==",
"dev": true,
"license": "Apache-2.0"
},
@@ -2983,9 +2983,9 @@
}
},
"node_modules/@vercel/node": {
"version": "5.8.8",
"resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.8.8.tgz",
"integrity": "sha512-+uRT9evnGWUE6klrJJED4fCvlSxNShbIc/UY4FeUzt2sdcy5a5b1IoYlo94RJd7tAY9Jg2lR2cVfGfsnWH81ZA==",
"version": "5.8.12",
"resolved": "https://registry.npmjs.org/@vercel/node/-/node-5.8.12.tgz",
"integrity": "sha512-XK2ML9YVdAlZ3BmGTW4jQL0D55ZHeRWKS+CLPSWReDyOBKaC4tTnTL2tp3z76bAs0pfgbT55nplMiX2mneSbLA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -2993,8 +2993,8 @@
"@edge-runtime/primitives": "4.1.0",
"@edge-runtime/vm": "3.2.0",
"@types/node": "20.11.0",
"@vercel/build-utils": "13.26.4",
"@vercel/error-utils": "2.1.0",
"@vercel/build-utils": "13.27.1",
"@vercel/error-utils": "2.2.0",
"@vercel/nft": "1.10.0",
"@vercel/static-config": "3.4.0",
"async-listen": "3.0.0",
@@ -3101,14 +3101,14 @@
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz",
"integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==",
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz",
"integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.5",
"@vitest/utils": "4.1.8",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
@@ -3122,8 +3122,8 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.5",
"vitest": "4.1.5"
"@vitest/browser": "4.1.8",
"vitest": "4.1.8"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@@ -3132,16 +3132,16 @@
}
},
"node_modules/@vitest/expect": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
"integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz",
"integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.5",
"@vitest/spy": "4.1.8",
"@vitest/utils": "4.1.8",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -3150,13 +3150,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
"integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz",
"integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.5",
"@vitest/spy": "4.1.8",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -3187,9 +3187,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
"integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz",
"integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3200,13 +3200,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
"integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz",
"integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.5",
"@vitest/utils": "4.1.8",
"pathe": "^2.0.3"
},
"funding": {
@@ -3214,14 +3214,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
"integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz",
"integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.5",
"@vitest/utils": "4.1.5",
"@vitest/pretty-format": "4.1.8",
"@vitest/utils": "4.1.8",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -3230,9 +3230,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
"integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz",
"integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==",
"dev": true,
"license": "MIT",
"funding": {
@@ -3240,13 +3240,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
"integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz",
"integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.5",
"@vitest/pretty-format": "4.1.8",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -4461,9 +4461,9 @@
"peer": true
},
"node_modules/dompurify": {
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz",
"integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==",
"version": "3.4.8",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz",
"integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -5752,9 +5752,9 @@
"integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="
},
"node_modules/langchain": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/langchain/-/langchain-1.4.2.tgz",
"integrity": "sha512-SLGipy0r4nqQD0aiUOBYLMeGFfB/QiYnMndfZ8sGN89vXDCIXbYqcE7G/4QDDX3nZsM7/emQpoScmlxEX6sDnQ==",
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/langchain/-/langchain-1.4.4.tgz",
"integrity": "sha512-tepOCwUDaIZOYJ9Eo0O6o5dXEN/0KJheiFDnHHFL8Tx8rfkDLL4cOTSTln4Vpn9LpWzXYkjQ8lkHnnNDQWZPeg==",
"license": "MIT",
"dependencies": {
"@langchain/langgraph": "^1.3.2",
@@ -8153,9 +8153,9 @@
"license": "ISC"
},
"node_modules/sigma": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sigma/-/sigma-3.0.2.tgz",
"integrity": "sha512-/BUbeOwPGruiBOm0YQQ6ZMcLIZ6tf/W+Jcm7dxZyAX0tK3WP9/sq7/NAWBxPIxVahdGjCJoGwej0Gdrv0DxlQQ==",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/sigma/-/sigma-3.0.3.tgz",
"integrity": "sha512-5H0zFlx6/NTQpqBg4Rm569ZOpnBOXMaS25UQThIWMU3XyzI5AhmorK/gnl87BvJBLhQd0tW4C0LIp3enWzMoNw==",
"license": "MIT",
"dependencies": {
"events": "^3.3.0",
@@ -8822,19 +8822,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
"integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz",
"integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.5",
"@vitest/mocker": "4.1.5",
"@vitest/pretty-format": "4.1.5",
"@vitest/runner": "4.1.5",
"@vitest/snapshot": "4.1.5",
"@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.5",
"@vitest/expect": "4.1.8",
"@vitest/mocker": "4.1.8",
"@vitest/pretty-format": "4.1.8",
"@vitest/runner": "4.1.8",
"@vitest/snapshot": "4.1.8",
"@vitest/spy": "4.1.8",
"@vitest/utils": "4.1.8",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -8862,12 +8862,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.5",
"@vitest/browser-preview": "4.1.5",
"@vitest/browser-webdriverio": "4.1.5",
"@vitest/coverage-istanbul": "4.1.5",
"@vitest/coverage-v8": "4.1.5",
"@vitest/ui": "4.1.5",
"@vitest/browser-playwright": "4.1.8",
"@vitest/browser-preview": "4.1.8",
"@vitest/browser-webdriverio": "4.1.8",
"@vitest/coverage-istanbul": "4.1.8",
"@vitest/coverage-v8": "4.1.8",
"@vitest/ui": "4.1.8",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+5 -5
View File
@@ -28,7 +28,7 @@
"@tailwindcss/vite": "^4.3.0",
"axios": "^1.16.1",
"d3": "^7.9.0",
"dompurify": "^3.4.7",
"dompurify": "^3.4.8",
"gitnexus-shared": "file:../gitnexus-shared",
"graphology": "^0.26.0",
"graphology-indices": "^0.17.0",
@@ -38,7 +38,7 @@
"graphology-utils": "^2.3.0",
"i18next": "^26.3.0",
"i18next-browser-languagedetector": "^8.2.1",
"langchain": "^1.4.2",
"langchain": "^1.4.4",
"lru-cache": "^11.2.4",
"lucide-react": "^1.16.0",
"mermaid": "^11.15.0",
@@ -51,7 +51,7 @@
"react-syntax-highlighter": "^16.1.1",
"react-zoom-pan-pinch": "^4.0.3",
"remark-gfm": "^4.0.1",
"sigma": "^3.0.2",
"sigma": "^3.0.3",
"tailwindcss": "^4.2.4",
"uuid": "^14.0.0",
"zod": "^4.4.3"
@@ -67,9 +67,9 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13",
"@vercel/node": "^5.8.8",
"@vercel/node": "^5.8.12",
"@vitejs/plugin-react": "^5.1.4",
"@vitest/coverage-v8": "^4.1.5",
"@vitest/coverage-v8": "^4.1.8",
"jsdom": "^29.1.1",
"tree-sitter-wasms": "^0.1.13",
"typescript": "^5.4.5",
+172 -40
View File
@@ -21,9 +21,11 @@ import {
startAnalyze,
cancelAnalyze,
streamAnalyzeProgress,
uploadFolder,
type JobProgress,
} from '../services/backend-client';
import { AnalyzeProgress } from './AnalyzeProgress';
import { filterRepoFiles } from '@/lib/upload-filter';
import { useTranslation } from 'react-i18next';
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -165,8 +167,11 @@ export interface RepoAnalyzerProps {
export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProps) => {
const { t } = useTranslation(['common', 'errors', 'onboarding']);
const inputId = useId();
const folderInputRef = useRef<HTMLInputElement>(null);
const [mode, setMode] = useState<InputMode>('github');
const [uploading, setUploading] = useState(false);
const [uploadSummary, setUploadSummary] = useState<{ count: number; dropped: number } | null>(
null,
);
const [githubUrl, setGithubUrl] = useState('');
const [gitlabUrl, setGitlabUrl] = useState('');
const [localPath, setLocalPath] = useState('');
@@ -181,28 +186,73 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
const jobIdRef = useRef<string | null>(null);
const sseControllerRef = useRef<AbortController | null>(null);
// Owns the in-flight analyze/upload request. The controller doubles as the
// staleness token: each request captures its own controller in a closure and
// bails after the await when that controller was aborted, so a resolution
// arriving after a mode switch / cancel / unmount can never drive state.
const requestControllerRef = useRef<AbortController | null>(null);
const completeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const folderInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
return () => {
sseControllerRef.current?.abort();
requestControllerRef.current?.abort();
if (completeTimerRef.current) clearTimeout(completeTimerRef.current);
};
}, []);
// Abort any in-flight analyze/upload request so its settlement can't drive
// state. Aborting is load-bearing: once a mode switch resets `uploading`,
// the `uploading || isLoading` re-entry guard no longer covers the stale
// request — only its aborted signal does.
const invalidateRequest = (): void => {
requestControllerRef.current?.abort();
requestControllerRef.current = null;
};
// Invalidate the previous request and hand the caller a fresh controller.
const renewRequestController = (): AbortController => {
invalidateRequest();
const controller = new AbortController();
requestControllerRef.current = controller;
return controller;
};
// An upload that resolved after invalidation has still created a server-side
// job; cancel it so the single analyze slot isn't held for the job's full
// duration. Upload-path only: every upload owns a fresh job (the server
// stages each upload into a unique dir, never dedup-aliasing), whereas URL
// analyzes dedup-alias by repo — the returned jobId may belong to a job
// another session (or this user's own resubmit) is actively watching, so
// cancelling on that path could kill a live analysis. A stale URL job is
// left to finish: a same-URL resubmit re-attaches to it via dedup, and the
// server's job timeout/TTL sweep bounds the slot occupancy.
const cancelStaleUploadJob = (jobId: string): void => {
void cancelAnalyze(jobId).catch(() => {});
};
const handleModeChange = (m: InputMode) => {
// ModeTabs fires onChange on every click, including the already-active
// tab — never abort the user's own in-flight request for a no-op click.
if (m === mode) return;
invalidateRequest();
setMode(m);
setGithubUrl('');
setGitlabUrl('');
setLocalPath('');
setValidationError(null);
setUploadSummary(null);
setUploading(false);
// An aborted request no longer resolves to move `phase` off 'starting';
// reset so the new mode's form is immediately usable (also clears a stale
// 'error' phase). Only reachable while showInput is true.
setPhase('input');
};
// Use the browser's native directory picker (webkitdirectory doesn't give paths,
// so we use a text input + a "Browse" button that opens a standard file input
// to let users pick files from the folder — the path is typed manually since
// browsers don't expose absolute paths for security reasons).
// For local paths, the user types or pastes the absolute path.
// Local-folder mode uploads the selected folder's files (the browser never
// exposes an absolute path, so the old typed-path/browse approach couldn't
// work — see handleFolderUpload). A typed server path is also still accepted.
const canSubmit =
mode === 'github'
@@ -228,6 +278,10 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
setValidationError(null);
setPhase('starting');
// Staleness guard only (no wire abort): the POST is short-lived and
// self-terminates, but its resolution must not drive state after a mode
// switch / cancel / unmount invalidated this request.
const controller = renewRequestController();
try {
const request =
mode === 'github'
@@ -236,8 +290,9 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
? { url: gitlabUrl.trim() }
: { path: localPath.trim() };
const { jobId } = await startAnalyze(request);
jobIdRef.current = jobId;
setPhase('analyzing');
// Stale resolution: return without cancelling — URL jobIds may be
// dedup-aliased to a job another session owns (see cancelStaleUploadJob).
if (controller.signal.aborted) return;
const nameSource =
mode === 'github'
@@ -245,29 +300,84 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
: mode === 'gitlab'
? gitlabUrl.trim()
: localPath.trim();
const controller = streamAnalyzeProgress(
jobId,
(p) => setProgress(p),
(data) => {
const name =
data.repoName ??
nameSource.split(/[/\\]/).filter(Boolean).at(-1) ??
t('onboarding:repoAnalyzer.defaultRepoName');
setCompletedRepoName(name);
setPhase('done');
sseControllerRef.current = null;
completeTimerRef.current = setTimeout(() => {
completeTimerRef.current = null;
onComplete(name);
}, 1200);
},
(errMsg) => {
setValidationError(errMsg || t('errors:analysisFailed'));
setPhase('error');
},
);
sseControllerRef.current = controller;
trackJob(jobId, nameSource);
} catch (err) {
// Unmount aborts the controller, so this also covers the unmounted case.
if (controller.signal.aborted) return;
setValidationError(err instanceof Error ? err.message : t('errors:startAnalysisFailed'));
setPhase('error');
}
};
// Drive an already-created analysis job through the SSE progress stream to
// completion. Shared by the path/URL analyze flow and the folder-upload flow.
const trackJob = (jobId: string, fallbackNameSource: string | null) => {
// Callers reach here only with a live (non-aborted) request controller, so
// the component is mounted — unmount aborts the controller.
jobIdRef.current = jobId;
setPhase('analyzing');
const controller = streamAnalyzeProgress(
jobId,
(p) => setProgress(p),
(data) => {
const name =
data.repoName ??
(fallbackNameSource
? fallbackNameSource.split(/[/\\]/).filter(Boolean).at(-1)
: undefined) ??
t('onboarding:repoAnalyzer.defaultRepoName');
setCompletedRepoName(name);
setPhase('done');
sseControllerRef.current = null;
completeTimerRef.current = setTimeout(() => {
completeTimerRef.current = null;
onComplete(name);
}, 1200);
},
(errMsg) => {
setValidationError(errMsg || t('errors:analysisFailed'));
setPhase('error');
},
);
sseControllerRef.current = controller;
};
// Upload a browser-selected folder (webkitdirectory) and start analysis. The
// upload endpoint returns a jobId, which then joins the normal SSE flow.
const handleFolderUpload = async (fileList: FileList) => {
if (uploading || isLoading) return; // guard against a concurrent upload
const { files, manifest, droppedCount } = filterRepoFiles(fileList);
if (files.length === 0) {
setValidationError(t('onboarding:repoAnalyzer.upload.empty'));
return;
}
setValidationError(null);
setUploadSummary({ count: files.length, dropped: droppedCount });
setUploading(true);
setPhase('starting');
// The selected folder's name (manifest entries are `<folder>/<rest>`) is a
// sensible fallback if the server's complete event omits repoName.
const folderName = manifest[0]?.split('/')[0] ?? null;
const controller = renewRequestController();
try {
const { jobId } = await uploadFolder(files, manifest, controller.signal);
if (controller.signal.aborted) {
// The abort raced the response: the server already created the job.
// (Unmount aborts the controller, so this also covers unmounted.)
cancelStaleUploadJob(jobId);
return;
}
setUploading(false);
trackJob(jobId, folderName);
} catch (err) {
// An abort surfaces in two shapes — BackendError('Request aborted')
// when it lands during fetch, raw AbortError when it lands during the
// response-body read — so branch on the closure controller's signal,
// never on the error identity. In the second shape the server may have
// already launched a job whose id we never learn; that orphan is bounded
// by the server's job timeout and terminal-job TTL sweep.
if (controller.signal.aborted) return;
setUploading(false);
setValidationError(err instanceof Error ? err.message : t('errors:startAnalysisFailed'));
setPhase('error');
}
@@ -276,6 +386,10 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
const handleCancel = async () => {
sseControllerRef.current?.abort();
sseControllerRef.current = null;
// Defensive: no UI path can reach handleCancel while a request is in
// flight (the cancel affordance renders only at phase === 'analyzing'),
// but invalidate it anyway so the guard topology has no holes.
invalidateRequest();
if (jobIdRef.current) {
try {
await cancelAnalyze(jobIdRef.current);
@@ -284,6 +398,8 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
}
setPhase('input');
setProgress({ phase: 'queued', percent: 0, message: t('common:analyzePhases.queued') });
setUploading(false);
setUploadSummary(null);
};
const isLoading = phase === 'starting';
@@ -443,35 +559,51 @@ export const RepoAnalyzer = ({ variant, onComplete, onCancel }: RepoAnalyzerProp
<Check className="h-3.5 w-3.5 shrink-0 text-emerald-400" />
)}
</div>
{/* Native folder picker + Browse button — below the input */}
{/* Upload a folder from your computer — no server path or mount needed.
The browser can't expose an absolute path, so we upload the files. */}
<input
ref={folderInputRef}
type="file"
// @ts-expect-error -- webkitdirectory is non-standard but widely supported
webkitdirectory=""
multiple
className="hidden"
data-testid="folder-upload-input"
onChange={(e) => {
const files = e.target.files;
if (files && files.length > 0) {
const rel = files[0].webkitRelativePath;
const folderName = rel.split('/')[0];
if (folderName) {
setLocalPath(folderName);
setValidationError(null);
}
if (e.target.files && e.target.files.length > 0) {
handleFolderUpload(e.target.files);
}
e.target.value = '';
}}
/>
<button
type="button"
data-testid="upload-folder"
onClick={() => folderInputRef.current?.click()}
disabled={isLoading}
className="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg border border-border-subtle bg-elevated px-3 py-2 text-xs font-medium text-text-secondary transition-all duration-150 hover:bg-hover hover:text-text-primary disabled:opacity-50"
>
<FolderOpen className="h-3.5 w-3.5" />
{t('onboarding:repoAnalyzer.browseForFolder')}
{t('onboarding:repoAnalyzer.upload.button')}
</button>
{uploading && (
<div role="status" aria-busy="true" data-testid="upload-progress" className="space-y-1">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-elevated">
<div className="h-full w-1/3 animate-pulse rounded-full bg-accent" />
</div>
<p className="text-xs text-text-muted">
{t('onboarding:repoAnalyzer.upload.uploading')}
</p>
</div>
)}
{uploadSummary && !uploading && phase !== 'error' && (
<p className="text-xs text-text-muted" data-testid="upload-summary">
{t('onboarding:repoAnalyzer.upload.selected', {
fileCount: uploadSummary.count,
dropped: uploadSummary.dropped,
})}
</p>
)}
</div>
)}
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { filterRepoFiles, MAX_FILE_BYTES } from './upload-filter';
type FileLike = { name: string; size: number; webkitRelativePath?: string };
function f(webkitRelativePath: string, size = 10): FileLike {
const name = webkitRelativePath.split('/').pop() ?? webkitRelativePath;
return { name, size, webkitRelativePath };
}
describe('filterRepoFiles', () => {
it('keeps source files and builds an order-aligned manifest', () => {
const input = [f('repo/src/index.ts', 100), f('repo/README.md', 50)];
const r = filterRepoFiles(input);
expect(r.files).toHaveLength(2);
expect(r.manifest).toEqual(['repo/src/index.ts', 'repo/README.md']);
expect(r.totalBytes).toBe(150);
expect(r.droppedCount).toBe(0);
});
it('excludes .git / node_modules / build dirs anywhere in the path', () => {
const input = [
f('repo/.git/HEAD'),
f('repo/node_modules/x/index.js'),
f('repo/dist/bundle.js'),
f('repo/src/app.ts'),
f('repo/.gitnexus/meta.json'),
];
const r = filterRepoFiles(input);
expect(r.manifest).toEqual(['repo/src/app.ts']);
expect(r.droppedCount).toBe(4);
});
it('drops files over the per-file size cap', () => {
const input = [f('repo/big.bin', MAX_FILE_BYTES + 1), f('repo/small.ts', 10)];
const r = filterRepoFiles(input);
expect(r.manifest).toEqual(['repo/small.ts']);
expect(r.droppedCount).toBe(1);
});
it('falls back to name when webkitRelativePath is absent', () => {
const r = filterRepoFiles([{ name: 'lone.ts', size: 5 }]);
expect(r.manifest).toEqual(['lone.ts']);
});
});
+73
View File
@@ -0,0 +1,73 @@
/**
* Client-side pre-filter for a webkitdirectory folder upload.
*
* Drops VCS metadata, dependency/build directories, and oversized files before
* upload — `.git` alone is often larger than the working tree — so payloads
* stay small and the upload matches what the analyzer actually needs. Produces
* an order-aligned `manifest` of webkitRelativePaths (the server keys on this,
* not the multipart filename, which browsers rewrite).
*/
/** Directory names excluded anywhere in a file's path. */
export const EXCLUDED_DIRS = new Set([
'.git',
'.hg',
'.svn',
'node_modules',
'vendor',
'.venv',
'__pycache__',
'target',
'dist',
'build',
'out',
'.next',
'.nuxt',
'.cache',
'coverage',
'.idea',
'.gitnexus',
]);
/** Per-file size cap; matches the server's per-file limit. */
export const MAX_FILE_BYTES = 25 * 1024 * 1024;
export interface FilterResult {
files: File[];
manifest: string[];
droppedCount: number;
totalBytes: number;
}
type FileLike = Pick<File, 'name' | 'size'> & { webkitRelativePath?: string };
/**
* Filter a webkitdirectory `FileList` (or array) into the files to upload plus
* their relative-path manifest.
*/
export function filterRepoFiles(input: ArrayLike<FileLike>): FilterResult {
const files: File[] = [];
const manifest: string[] = [];
let droppedCount = 0;
let totalBytes = 0;
for (let i = 0; i < input.length; i++) {
const f = input[i];
const rel =
f.webkitRelativePath && f.webkitRelativePath.length > 0 ? f.webkitRelativePath : f.name;
const segments = rel.split('/');
if (segments.some((s) => EXCLUDED_DIRS.has(s))) {
droppedCount++;
continue;
}
if (f.size > MAX_FILE_BYTES) {
droppedCount++;
continue;
}
files.push(f as File);
manifest.push(rel);
totalBytes += f.size;
}
return { files, manifest, droppedCount, totalBytes };
}
+7 -2
View File
@@ -61,7 +61,12 @@
"gitlabRepositoryUrl": "GitLab Repository URL",
"gitlabSupported": "Supports GitLab.com and self-hosted GitLab instances.",
"localFolderPath": "Local Folder Path",
"browseForFolder": "Browse for folder",
"hideBackground": "Hide (analysis continues in background)"
"hideBackground": "Hide (analysis continues in background)",
"upload": {
"button": "Upload a folder",
"uploading": "Uploading…",
"selected": "{{fileCount}} files ready ({{dropped}} skipped: .git, node_modules, build output)",
"empty": "No analyzable files found in that folder."
}
}
}
@@ -61,7 +61,12 @@
"gitlabRepositoryUrl": "GitLab 仓库 URL",
"gitlabSupported": "支持 GitLab.com 和自托管 GitLab 实例。",
"localFolderPath": "本地文件夹路径",
"browseForFolder": "浏览文件夹",
"hideBackground": "隐藏(分析继续在后台进行)"
"hideBackground": "隐藏(分析继续在后台进行)",
"upload": {
"button": "上传文件夹",
"uploading": "上传中…",
"selected": "已准备 {{fileCount}} 个文件(已跳过 {{dropped}} 个:.git、node_modules、构建产物)",
"empty": "该文件夹中未找到可分析的文件。"
}
}
}
+34 -6
View File
@@ -283,12 +283,11 @@ const fetchWithTimeout = async (
): Promise<Response> => {
// Merge the external caller signal (if any) with an
// `AbortSignal.timeout()` so a timer-fired abort produces a
// `DOMException` with `name === 'TimeoutError'` — which
// `resilientFetch` correctly classifies as terminal-network (no
// retry, no breaker hit). A manual `AbortController.abort()` would
// produce `name === 'AbortError'` and route through the
// retryable-network branch, which mis-penalizes the breaker for
// user-side network slowness.
// `DOMException` with `name === 'TimeoutError'`. Both shapes are
// breaker-safe: `resilientFetch` classifies TimeoutError AND a manual
// `AbortController.abort()`'s AbortError as terminal-network (no
// retry, breaker-neutral via recordNeutral), so caller-driven
// cancellation never penalizes the breaker.
const timeoutSignal = AbortSignal.timeout(timeoutMs);
const externalSignal = init.signal;
const signal = externalSignal ? AbortSignal.any([timeoutSignal, externalSignal]) : timeoutSignal;
@@ -755,6 +754,35 @@ export const fetchClusterDetail = async (repo: string, name: string): Promise<un
return response.json();
};
// ── Upload API ─────────────────────────────────────────────────────────────
/**
* Upload a folder (selected via `<input webkitdirectory>`) and start analysis.
* Sends the file blobs plus a JSON `manifest` of their relative paths — the
* multipart filename can't carry the path (browsers strip separators), so the
* manifest is the source of truth. Routed through fetchWithTimeout (the shared,
* origin-validated request path) rather than a raw XHR; returns the analysis
* jobId, which the caller drives through the normal SSE flow.
*/
export const uploadFolder = async (
files: File[],
manifest: string[],
signal?: AbortSignal,
): Promise<{ jobId: string; status: string }> => {
const form = new FormData();
// Manifest MUST precede the file parts (the server enforces this).
form.append('manifest', JSON.stringify(manifest));
for (const f of files) form.append('files', f);
const response = await fetchWithTimeout(
`${_backendUrl}/api/analyze/upload`,
{ method: 'POST', body: form, signal },
5 * 60_000, // up to 5 min for large repos
);
await assertOk(response);
return response.json() as Promise<{ jobId: string; status: string }>;
};
// ── Analyze API ────────────────────────────────────────────────────────────
/** Start a server-side analysis job. */
@@ -0,0 +1,218 @@
/**
* Stale-request guards in RepoAnalyzer (PR #1850 review 4470339833).
*
* An analyze request (folder upload or URL analyze) that is still in flight
* when the user switches modes, cancels, or unmounts must not drive state
* when it later settles: no SSE stream, no phase/error flip — and a
* stale-but-created server job gets a fire-and-forget cancel so the single
* analyze slot is freed.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { RepoAnalyzer } from '../../src/components/RepoAnalyzer';
import { i18nReady } from '../../src/i18n';
import {
cancelAnalyze,
startAnalyze,
streamAnalyzeProgress,
uploadFolder,
} from '../../src/services/backend-client';
vi.mock('../../src/services/backend-client', () => ({
startAnalyze: vi.fn(),
cancelAnalyze: vi.fn(),
streamAnalyzeProgress: vi.fn(),
uploadFolder: vi.fn(),
}));
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (err: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
const JOB = { jobId: 'job-1', status: 'queued' };
/** Gate uploadFolder on a deferred promise and expose the signal it received. */
function mockUploadWith(d: { promise: Promise<typeof JOB> }) {
let captured: AbortSignal | undefined;
vi.mocked(uploadFolder).mockImplementation((_files, _manifest, signal) => {
captured = signal;
return d.promise;
});
return { signal: () => captured };
}
/** Render, switch to Local Folder mode, and fire a folder selection. */
function startUpload() {
const view = render(<RepoAnalyzer variant="onboarding" onComplete={vi.fn()} />);
fireEvent.click(screen.getByRole('tab', { name: 'Local Folder' }));
fireEvent.change(screen.getByTestId('folder-upload-input'), {
target: { files: [new File(['x'], 'a.ts')] },
});
return view;
}
beforeEach(async () => {
await i18nReady;
vi.clearAllMocks();
vi.mocked(cancelAnalyze).mockResolvedValue(undefined as never);
vi.mocked(streamAnalyzeProgress).mockImplementation(() => new AbortController());
});
describe('folder upload', () => {
it('a mode switch mid-upload makes the resolution inert and cancels the job', async () => {
const d = deferred<typeof JOB>();
const upload = mockUploadWith(d);
startUpload();
fireEvent.click(screen.getByRole('tab', { name: 'GitHub URL' }));
// The wire abort happened at mode-switch time, not at resolution time.
expect(upload.signal()?.aborted).toBe(true);
await act(async () => {
d.resolve(JOB);
});
expect(streamAnalyzeProgress).not.toHaveBeenCalled();
expect(cancelAnalyze).toHaveBeenCalledWith('job-1');
// The GitHub form is clean and submittable (phase back to 'input').
expect(screen.getByRole('textbox')).toBeEnabled();
expect(screen.queryByTestId('upload-progress')).not.toBeInTheDocument();
});
it.each([
['BackendError shape', new Error('Request aborted')],
['raw AbortError shape', new DOMException('The operation was aborted.', 'AbortError')],
])('an aborted rejection is silent — %s', async (_label, err) => {
const d = deferred<typeof JOB>();
vi.mocked(uploadFolder).mockReturnValue(d.promise);
startUpload();
fireEvent.click(screen.getByRole('tab', { name: 'GitHub URL' }));
await act(async () => {
d.reject(err);
});
expect(screen.queryByText('Request aborted')).not.toBeInTheDocument();
expect(screen.queryByText('The operation was aborted.')).not.toBeInTheDocument();
expect(screen.getByRole('textbox')).toBeEnabled();
});
it('a same-tab click does not abort the in-flight upload', async () => {
const d = deferred<typeof JOB>();
const upload = mockUploadWith(d);
startUpload();
fireEvent.click(screen.getByRole('tab', { name: 'Local Folder' }));
expect(upload.signal()?.aborted).toBe(false);
await act(async () => {
d.resolve(JOB);
});
expect(streamAnalyzeProgress).toHaveBeenCalledTimes(1);
expect(cancelAnalyze).not.toHaveBeenCalled();
});
it('an unmount mid-upload makes the resolution inert', async () => {
const d = deferred<typeof JOB>();
const upload = mockUploadWith(d);
const { unmount } = startUpload();
unmount();
expect(upload.signal()?.aborted).toBe(true);
await act(async () => {
d.resolve(JOB);
});
expect(streamAnalyzeProgress).not.toHaveBeenCalled();
});
it('the happy path still tracks the job', async () => {
const d = deferred<typeof JOB>();
vi.mocked(uploadFolder).mockReturnValue(d.promise);
startUpload();
await act(async () => {
d.resolve(JOB);
});
expect(streamAnalyzeProgress).toHaveBeenCalledTimes(1);
expect(vi.mocked(streamAnalyzeProgress).mock.calls[0][0]).toBe('job-1');
expect(cancelAnalyze).not.toHaveBeenCalled();
});
it('a genuine error still surfaces', async () => {
const d = deferred<typeof JOB>();
vi.mocked(uploadFolder).mockReturnValue(d.promise);
startUpload();
await act(async () => {
d.reject(new Error('upload exploded'));
});
expect(screen.getByText('upload exploded')).toBeInTheDocument();
expect(streamAnalyzeProgress).not.toHaveBeenCalled();
});
});
describe('URL analyze', () => {
function startGithubAnalyze() {
render(<RepoAnalyzer variant="onboarding" onComplete={vi.fn()} />);
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'https://github.com/owner/repo' },
});
fireEvent.click(screen.getByRole('button', { name: /Analyze Repository/ }));
}
it('a mode switch mid-analyze makes the resolution inert without cancelling', async () => {
const d = deferred<typeof JOB>();
vi.mocked(startAnalyze).mockReturnValue(d.promise);
startGithubAnalyze();
fireEvent.click(screen.getByRole('tab', { name: 'Local Folder' }));
await act(async () => {
d.resolve({ jobId: 'job-2', status: 'queued' });
});
expect(streamAnalyzeProgress).not.toHaveBeenCalled();
// No cancel on the URL path: the jobId may be dedup-aliased to a job
// another session owns, so cancelling could kill a live analysis.
expect(cancelAnalyze).not.toHaveBeenCalled();
});
it('a stale rejection is silent', async () => {
const d = deferred<typeof JOB>();
vi.mocked(startAnalyze).mockReturnValue(d.promise);
startGithubAnalyze();
fireEvent.click(screen.getByRole('tab', { name: 'Local Folder' }));
await act(async () => {
d.reject(new Error('analyze exploded'));
});
expect(screen.queryByText('analyze exploded')).not.toBeInTheDocument();
expect(screen.getByTestId('upload-folder')).toBeEnabled();
});
it('the happy path still tracks the job', async () => {
const d = deferred<typeof JOB>();
vi.mocked(startAnalyze).mockReturnValue(d.promise);
startGithubAnalyze();
await act(async () => {
d.resolve({ jobId: 'job-3', status: 'queued' });
});
expect(streamAnalyzeProgress).toHaveBeenCalledTimes(1);
expect(vi.mocked(streamAnalyzeProgress).mock.calls[0][0]).toBe('job-3');
expect(cancelAnalyze).not.toHaveBeenCalled();
});
});
+20
View File
@@ -436,6 +436,26 @@ After scope resolution, analyze prunes inert block-local value symbols (a functi
Programmatic callers can pass `keepLocalValueSymbols: true` in `PipelineOptions` instead of setting the env var.
### Hook augmentation/notifications are silently skipped
The Claude Code / Antigravity hooks intentionally stay **silent** on normal skip
paths so strict hook runners (e.g. Codex `PreToolUse`) never see unexpected
output. A search may not be augmented — or a stale-index reminder may not appear
on stderr — when the GitNexus MCP server owns the repo DB, when the DB-lock probe
times out and fails closed, or when the index is already current.
To see why a hook skipped, set `GITNEXUS_DEBUG=1` and re-run the action — the hook
writes the reason (e.g. `[GitNexus] augment skipped: MCP server owns DB`) and the
stale-index hint to its stderr:
```bash
GITNEXUS_DEBUG=1 <your command> # surfaces hook skip/diagnostic reasons on stderr
```
Only `GITNEXUS_DEBUG=1` and `GITNEXUS_DEBUG=true` enable diagnostics; every other
value (including `0` and `false`) is treated as off. Diagnostics go to stderr
only — the hook's structured stdout (the JSON the agent consumes) is unaffected.
## Privacy
- All processing happens locally on your machine
+44
View File
@@ -0,0 +1,44 @@
{
"straight-line": {
"fingerprint": "792229965a726d2c6b527f9ee65440a2b3023839ee71cb51522fc30e2f2cb454",
"scaling_budget": 1.5,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 2.0,
"disk_bytes_large_max": 1309481,
"_note": "#2081 M1 / #2082 M2: ONE function, N coalescing statements (extendBlock text accumulation + per-statement fact harvest). Runs at 2000->8000. M2 REWROTE the old 'output is constant 4 blocks' note: statement facts make disk/heap LINEAR in N (a free gate on the harvest payload); TIME still guards the concat path (array-join ~1.0; a genuine O(n^2) re-join accumulation is ~3.8). M2 adds rd_scaling_budget (measured ~0.74) and disk_bytes_large_max -- an ABSOLUTE ceiling ~1.35x the measured indexed-encoding bytes (969,986 at N=8000, ~121 B/stmt); a named-record encoding regression (~4x facts bytes) blows it. Re-baseline the fingerprint only on an intentional CFG/harvest-shape change (the canon now includes statements+bindings)."
},
"many-functions": {
"fingerprint": "f3bcc5e6ef4cf58aefe4e7d801a8fea0215494b9688833e501c2afc6df029c1b",
"scaling_budget": 1.5,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 2.0,
"_note": "#2081 M1 / #2082 M2: N small branchy functions (collect walk + per-function build + per-function solve). Time ~1.0, disk ~1.01, heap ~1.0, rd ~0.86 (solver is per-function; N functions scale linearly)."
},
"branchy": {
"fingerprint": "5b5886521ab21604df8f78af98c8c28a6be8e64c24f3d67b165c2d96ba2a3d52",
"scaling_budget": 1.8,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 2.0,
"_note": "#2081 M1 / #2082 M2: ONE function, N sequential ifs (block/edge growth in one CFG). Time ~1.1-1.25 (noisiest scenario; budget 1.8 absorbs noise, catches ~4.0 quadratic), disk ~1.03, heap ~1.0, rd ~0.7."
},
"dense-bindings": {
"fingerprint": "e4d7eb3c7e8b3772423af25cef391e0e6b68067b554819e81b543439a487403f",
"scaling_budget": 1.8,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 10.0,
"_note": "#2082 M2: N bindings live across ~N blocks in one loop -- bindings x blocks scale JOINTLY (the solver-lattice stressor). The overlay design measures rd ~5.2 normalized: the OUT spine copy on genning blocks is O(V) per block, which is quadratic when V scales with B (bounded in prod by maxFunctionLines; real functions have V~10-40). Budget 10 deliberately tolerates that known shape and exists to catch the repo's recurring per-item-rescan class (a per-use scan over all defs is O(n^3) here, ratio >=16). If rd drops well below 5, tighten."
},
"fact-fanout": {
"fingerprint": "488e63e072d514a9229e21872615e32c7b099ccbd65ec8c045ba517568fd3e5d",
"scaling_budget": 1.8,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 3.0,
"facts_large_max": 16000,
"_note": "#2082 M2: N switch-arm defs of one variable + N later uses -- facts are O(defs x uses) BY SPEC, so the gate is BOUNDEDNESS, not linearity: with the production fact limit engaged (DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION=16000) the materialized fact count stays pinned at the limit as N grows (facts_large_max), and rd time stays bounded (measured ~1.4). Losing the maxFacts early-stop shows as facts_large exploding quadratically."
}
}
+388
View File
@@ -0,0 +1,388 @@
/**
* Build-free CFG-construction measurement harness (#2081 M1).
*
* Times `collectFunctionCfgs` (the per-function CFG builder the parse worker
* runs on a `--pdg` run) on synthetic TS sources at two sizes, in three
* scenarios that each stress a distinct cost dimension:
* - `straight-line`: ONE function with N coalescing statements — stresses the
* basic-block text accumulation (the `extendBlock` path);
* - `many-functions`: N small branchy functions — stresses the collect walk +
* per-function build + the tree-sitter `namedChildren` accesses;
* - `branchy`: ONE function with N sequential `if`s — stresses block/edge
* growth within a single CFG.
*
* For each scenario it reports three scaling ratios at small→large
* (`(metric_large/metric_small)/(N_large/N_small)`: ~1.0 is linear, ~4.0 is the
* O(n²) shape the M1 perf review flagged for `extendBlock`'s concat chain):
* - TIME — wall-clock of `collectFunctionCfgs` (median of reps);
* - DISK — utf8 byte size of the serialized `cfgSideChannel` (what a `--pdg`
* run writes onto every ParsedFile shard);
* - MEMORY — retained JS heap of the `cfgSideChannel` payload, by the
* release-delta method (heap held minus heap after dropping it). Requires
* `node --expose-gc`; without it the heap metric is null and its gate skips.
* It also computes an order-independent sha256 fingerprint over the emitted
* blocks/edges of a fixed-size source — the correctness gate that a structural
* speedup must leave behavior-identical.
*
* Build-free: imports the `.ts` hotpaths through tsx
* (`node --expose-gc --import tsx bench/cfg/measure.mjs`). Parsing happens ONCE
* per size and the tree is reused across reps so the time measurement isolates
* CFG build cost, not tree-sitter parse time. `maxFunctionLines` is 0 (no cap)
* here on purpose — the bench measures the algorithm; the production default cap
* is a separate safety net (and would otherwise skip the large straight-line fn).
*
* Without args: prints one JSON object per scenario.
* With `--check`: asserts each scenario's fingerprint == its committed baseline
* (baselines.json) AND each of the time / disk / heap ratios is below its
* recorded budget; exits non-zero on any drift/regression.
*/
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
import Parser from 'tree-sitter';
import TypeScript from 'tree-sitter-typescript';
import { collectFunctionCfgs } from '../../src/core/ingestion/cfg/collect.ts';
import { computeReachingDefs } from '../../src/core/ingestion/cfg/reaching-defs.ts';
import { DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION } from '../../src/core/ingestion/cfg/emit.ts';
import { createTypeScriptCfgVisitor } from '../../src/core/ingestion/cfg/visitors/typescript.ts';
import { getTreeSitterBufferSize } from '../../src/core/ingestion/constants.ts';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
const visitor = createTypeScriptCfgVisitor();
const parser = new Parser();
parser.setLanguage(TypeScript.typescript);
// Large synthetic sources exceed tree-sitter's default read buffer; size it
// from the content exactly as the parse worker does (getTreeSitterBufferSize).
const parse = (src) => parser.parse(src, undefined, { bufferSize: getTreeSitterBufferSize(src) });
// ---- synthetic generators (one cost dimension each) ----
const SCENARIOS = [
{
name: 'straight-line',
// One function, N coalescing simple statements → all fold into one basic
// block whose text is accumulated statement-by-statement (extendBlock).
// Uses LARGER sizes than the other scenarios: this scenario's only cost
// dimension is text accumulation (output size is constant — 4 blocks at any
// N — so the disk/heap ratios can't see it), so the TIME ratio is the sole
// guard against an extendBlock O(n²)-concat re-regression. At small N a
// quadratic is masked by V8 cons-strings + the linear tree-walk and slips
// under the budget; these larger sizes make a real quadratic separate
// cleanly (verified: a `+=` regression here exceeds the budget, the
// array-join impl stays ~1).
small: 2000,
large: 8000,
gen: (n) => {
let s = 'function f() {\n';
for (let i = 0; i < n; i++) s += ` let v${i} = ${i} + 1;\n`;
return s + ' return v0;\n}\n';
},
},
{
name: 'many-functions',
// N independent small functions with a branch + return → stresses the
// tree walk in collectFunctionCfgs and the per-function build.
gen: (n) => {
let s = '';
for (let i = 0; i < n; i++) {
s += `function f${i}(x: number) { if (x > ${i}) { a(); } else { b(); } return x + ${i}; }\n`;
}
return s;
},
},
{
name: 'branchy',
// One function, N sequential `if`s → N condition blocks + 2N+ edges in a
// single CFG; stresses block/edge growth and namedChildren on the body.
gen: (n) => {
let s = 'function f(x: number) {\n';
for (let i = 0; i < n; i++) s += ` if (x > ${i}) { s${i}(); }\n`;
return s + '}\n';
},
},
{
name: 'dense-bindings',
// #2082 M2: N bindings live across ~N blocks inside one loop — bindings ×
// blocks scale JOINTLY, the discriminator for solver-lattice quadratics.
// The overlay design (KTD2: sets shared by reference, OUT spine-copied
// only on gen) is expected to scale ~linearly-with-a-spine-copy here
// (normalized ratio low single digits); the regression this scenario
// exists to catch is the repo's recurring per-item-rescan shape — a
// per-use scan over all defs (O(n³) here) blows the ratio past ~16.
// rd time is the gated metric (rd_scaling_budget).
rdMaxFacts: 0, // measure the algorithm, not the cap
gen: (n) => {
let s = 'function f(c: number) {\n';
for (let i = 0; i < n; i++) s += ` let v${i} = ${i};\n`;
s += ' while (c > 0) {\n';
for (let i = 0; i < n; i++) s += ` if (c > ${i}) { v${i} = v${(i + 1) % n} + 1; }\n`;
return s + ' c = c - 1;\n }\n return v0;\n}\n';
},
},
{
name: 'fact-fanout',
// #2082 M2: N parallel case-arm defs of one variable + N later uses —
// facts are O(defs×uses) BY SPEC, so a linearity ratio gate is the wrong
// shape. The gate here is BOUNDEDNESS: with the production fact limit
// engaged, the materialized fact count stays FLAT (== limit) as N grows
// past it (facts_large_max), and rd time stays bounded. An unbounded
// materialization regression (losing the maxFacts early-stop) shows as
// facts_large exploding quadratically.
rdMaxFacts: DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION,
gen: (n) => {
let s = 'function f(c: number) {\n let x = 0;\n switch (c) {\n';
for (let i = 0; i < n; i++) s += ` case ${i}: x = ${i}; break;\n`;
s += ' }\n';
for (let i = 0; i < n; i++) s += ` u${i}(x);\n`;
return s + '}\n';
},
},
];
const SMALL = 500;
const LARGE = 2000; // 4× — O(n) ⇒ ratio ~1, O(n²) ⇒ ratio ~4
const REPS = 15; // median over more reps → stabler time signal at small absolute ms
const FP_SIZE = 15; // fixed size for the behavior fingerprint
const NO_CAP = 0; // measure the algorithm, not the production safety cap
// ---- timing ----
function median(xs) {
const s = [...xs].sort((a, b) => a - b);
const m = Math.floor(s.length / 2);
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
}
function measureCollect(src, file, reps) {
const root = parse(src).rootNode; // parse ONCE; reuse across reps
collectFunctionCfgs(root, visitor, `warmup-${file}`, NO_CAP); // warm JIT (uncounted)
const samples = [];
let out;
for (let i = 0; i < reps; i++) {
const start = process.hrtime.bigint();
out = collectFunctionCfgs(root, visitor, file, NO_CAP);
samples.push(Number(process.hrtime.bigint() - start) / 1e6);
}
return {
ms: median(samples),
cfgs: out.cfgs,
blockCount: out.cfgs.reduce((a, c) => a + c.blocks.length, 0),
// DISK growth: utf8 byte size of the serialized cfgSideChannel — exactly
// what a --pdg run writes onto every ParsedFile shard in the durable store
// + parse cache (the field is plain JSON, so this is the on-disk delta).
// Should scale linearly with source covered; a super-linear ratio means the
// CFG duplicates text and bloats warm-cache shards at scale.
diskBytes: Buffer.byteLength(JSON.stringify(out.cfgs), 'utf8'),
};
}
// ---- reaching-defs solve cost (#2082 M2) ----
// Times computeReachingDefs over a scenario's collected CFGs (the exact work
// the scope-resolution emit loop adds per file on a --pdg run). `maxFacts`
// mirrors the per-scenario production posture: 0 (unlimited) measures the
// algorithm; the production default exercises the boundedness contract.
function measureReachingDefs(cfgs, reps, maxFacts) {
for (const c of cfgs) computeReachingDefs(c, { maxFacts }); // warm JIT
const samples = [];
let facts = 0;
for (let i = 0; i < reps; i++) {
const start = process.hrtime.bigint();
facts = 0;
for (const c of cfgs) facts += computeReachingDefs(c, { maxFacts }).facts.length;
samples.push(Number(process.hrtime.bigint() - start) / 1e6);
}
return { ms: median(samples), facts };
}
// ---- memory growth: retained heap of the cfgSideChannel payload ----
// Needs `node --expose-gc` to force collection for a clean delta; without it the
// heap metric is reported as null and its --check gate is skipped (so a local
// run without the flag still works).
const GC = typeof global.gc === 'function' ? () => (global.gc(), global.gc()) : null;
function retainedHeapBytes(src, file) {
if (!GC) return null;
// Retained-size-by-RELEASE: measure the heap with the CFGs held, drop them,
// GC, measure again. The drop isolates exactly the JS heap the cfgSideChannel
// payload retains (the extra RAM a --pdg run carries per file until the shard
// is flushed) — robust to pre-existing garbage, which is constant across both
// measurements. The parse tree is a temporary (its native memory isn't on the
// JS heap); block text strings are fresh copies, so they count here.
let cfgs = collectFunctionCfgs(parse(src).rootNode, visitor, file, NO_CAP).cfgs;
GC();
const withCfgs = process.memoryUsage().heapUsed;
if (cfgs.length < 0) throw new Error('unreachable'); // keep cfgs live past withCfgs
cfgs = null;
GC();
const withoutCfgs = process.memoryUsage().heapUsed;
return Math.max(0, withCfgs - withoutCfgs);
}
// ---- correctness fingerprint (order-independent over blocks + edges) ----
function canonicalizeCfg(cfg) {
const blocks = cfg.blocks
.map(
(b) =>
`B|${b.index}|${b.startLine}-${b.endLine}|${b.kind}|${b.text}|` +
// #2082 M2: statement facts join the canon so harvest drift (lost
// defs/uses, changed binding resolution) trips the fingerprint gate.
JSON.stringify(b.statements ?? null),
)
.sort();
const edges = cfg.edges.map((e) => `E|${e.from}->${e.to}|${e.kind}`).sort();
const bindings = JSON.stringify(cfg.bindings ?? null);
return `${cfg.functionStartLine}:${cfg.functionStartColumn}\n${bindings}\n${blocks.join('\n')}\n${edges.join('\n')}`;
}
function fingerprint(scenario) {
const out = collectFunctionCfgs(parse(scenario.gen(FP_SIZE)).rootNode, visitor, 'fp.ts', NO_CAP);
const canon = out.cfgs.map(canonicalizeCfg).sort().join('\n====\n');
return {
fingerprint: crypto.createHash('sha256').update(canon).digest('hex'),
fp_cfgs: out.cfgs.length,
fp_blocks: out.cfgs.reduce((a, c) => a + c.blocks.length, 0),
fp_edges: out.cfgs.reduce((a, c) => a + c.edges.length, 0),
};
}
function measureScenario(scenario) {
// Per-scenario sizes (straight-line needs larger N to separate a concat
// quadratic from noise — see its comment); the rest default to the globals.
const nSmall = scenario.small ?? SMALL;
const nLarge = scenario.large ?? LARGE;
const small = measureCollect(scenario.gen(nSmall), `${scenario.name}.ts`, REPS);
const large = measureCollect(scenario.gen(nLarge), `${scenario.name}.ts`, REPS);
const sizeRatio = nLarge / nSmall;
const scalingRatio = small.ms > 0 ? large.ms / small.ms / sizeRatio : 0;
const diskRatio = small.diskBytes > 0 ? large.diskBytes / small.diskBytes / sizeRatio : 0;
// Memory growth (only when --expose-gc gave us a forced GC).
const heapSmall = retainedHeapBytes(scenario.gen(nSmall), `${scenario.name}.ts`);
const heapLarge = retainedHeapBytes(scenario.gen(nLarge), `${scenario.name}.ts`);
const heapRatio =
heapSmall !== null && heapLarge !== null && heapSmall > 0
? heapLarge / heapSmall / sizeRatio
: null;
// #2082 M2: reaching-defs solve cost over the same CFGs.
const rdMaxFacts = scenario.rdMaxFacts ?? 0;
const rdSmall = measureReachingDefs(small.cfgs, REPS, rdMaxFacts);
const rdLarge = measureReachingDefs(large.cfgs, REPS, rdMaxFacts);
// Clamp the denominator: a 0.000ms small-N median would otherwise yield
// ratio 0 and the gate would self-disable exactly when the solver is fast.
const rdRatio = rdLarge.ms / Math.max(rdSmall.ms, 0.001) / sizeRatio;
return {
scenario: scenario.name,
elapsed_ms_small: Number(small.ms.toFixed(3)),
elapsed_ms_large: Number(large.ms.toFixed(3)),
scaling_ratio: Number(scalingRatio.toFixed(3)),
disk_bytes_small: small.diskBytes,
disk_bytes_large: large.diskBytes,
disk_bytes_ratio: Number(diskRatio.toFixed(3)),
heap_bytes_small: heapSmall,
heap_bytes_large: heapLarge,
heap_ratio: heapRatio === null ? null : Number(heapRatio.toFixed(3)),
blocks_small: small.blockCount,
blocks_large: large.blockCount,
rd_ms_small: Number(rdSmall.ms.toFixed(3)),
rd_ms_large: Number(rdLarge.ms.toFixed(3)),
rd_scaling_ratio: Number(rdRatio.toFixed(3)),
facts_small: rdSmall.facts,
facts_large: rdLarge.facts,
...fingerprint(scenario),
};
}
// ---- run ----
const CHECK = process.argv.includes('--check');
// The retained-heap budget is a primary regression detector, but it can only be
// measured with a forced GC. Rather than let `--check` silently PASS with the
// heap gate skipped (a green no-op if someone drops --expose-gc), fail loudly.
if (CHECK && !GC) {
process.stderr.write(
'[cfg --check] FAIL: retained-heap gate requires --expose-gc. ' +
'Run: node --expose-gc --import tsx bench/cfg/measure.mjs --check\n',
);
process.exit(1);
}
const results = SCENARIOS.map(measureScenario);
if (!CHECK) {
for (const r of results) process.stdout.write(JSON.stringify(r) + '\n');
} else {
const baselines = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8'));
const failures = [];
for (const r of results) {
const base = baselines[r.scenario];
if (base === undefined) {
failures.push(`${r.scenario}: no baseline recorded`);
continue;
}
if (r.fingerprint !== base.fingerprint) {
failures.push(
`${r.scenario}: CFG fingerprint drift (got ${r.fingerprint}, expected ${base.fingerprint})`,
);
}
if (r.scaling_ratio >= base.scaling_budget) {
failures.push(
`${r.scenario}: scaling ratio ${r.scaling_ratio} >= budget ${base.scaling_budget} ` +
`(${SMALL}->${LARGE} stmts/fns, ms ${r.elapsed_ms_small}->${r.elapsed_ms_large})`,
);
}
if (base.disk_bytes_budget !== undefined && r.disk_bytes_ratio >= base.disk_bytes_budget) {
failures.push(
`${r.scenario}: cfgSideChannel disk-bytes ratio ${r.disk_bytes_ratio} >= budget ` +
`${base.disk_bytes_budget} (bytes ${r.disk_bytes_small}->${r.disk_bytes_large})`,
);
}
// #2082 M2 gates — rd solve-time scaling, fact-count boundedness, and an
// ABSOLUTE side-channel size ceiling (a ratio gate is blind to a
// constant-factor encoding bloat like named records vs indexed facts).
if (base.rd_scaling_budget !== undefined && r.rd_scaling_ratio >= base.rd_scaling_budget) {
failures.push(
`${r.scenario}: reaching-defs scaling ratio ${r.rd_scaling_ratio} >= budget ` +
`${base.rd_scaling_budget} (ms ${r.rd_ms_small}->${r.rd_ms_large})`,
);
}
if (base.facts_large_max !== undefined && r.facts_large > base.facts_large_max) {
failures.push(
`${r.scenario}: fact materialization ${r.facts_large} > bound ${base.facts_large_max} ` +
`(the maxFacts early-stop is the boundedness contract)`,
);
}
if (base.disk_bytes_large_max !== undefined && r.disk_bytes_large > base.disk_bytes_large_max) {
failures.push(
`${r.scenario}: cfgSideChannel absolute size ${r.disk_bytes_large} > ceiling ` +
`${base.disk_bytes_large_max} bytes (constant-factor encoding bloat)`,
);
}
// Heap gate only when measured (--expose-gc present) AND a budget exists.
if (
base.heap_budget !== undefined &&
r.heap_ratio !== null &&
r.heap_ratio >= base.heap_budget
) {
failures.push(
`${r.scenario}: retained-heap ratio ${r.heap_ratio} >= budget ${base.heap_budget} ` +
`(heap ${r.heap_bytes_small}->${r.heap_bytes_large})`,
);
}
process.stdout.write(JSON.stringify(r) + '\n');
}
if (failures.length > 0) {
for (const f of failures) process.stderr.write(`[cfg --check] FAIL: ${f}\n`);
process.exit(1);
}
process.stderr.write(`[cfg --check] PASS (${results.length} scenarios)\n`);
}
+2 -2
View File
@@ -18,10 +18,10 @@
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression)."
},
"cpp": {
"fingerprint": "f56625342f73e182170e2c964d538e316c079fa6e9466a7f076bff2ebcf8aac4",
"fingerprint": "9b5b4393d158d76dcf1ef9807e0326462c45a5266310f0ae7894d017f3858219",
"scaling_budget": 1.5,
"_added": "#1956: cpp added to the scope-capture bench (was UNBENCHED). Heritage-bearing scale source (: public Base, public Mixin) drives emitCppInheritanceCaptures at scale. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in cpp/captures.ts (~12 sites, threaded c.node, byte-identical over 263 cpp-* fixtures); scaling 2.30 -> 1.12.",
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression).",
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression). #2094: deleted C++ declarations retain @declaration.is-deleted metadata; deleted operator and pointer-return shapes plus the expanded deleted-overload fixture are included. Intended capture drift; scaling remains linear (1.139 < 1.5).",
"_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift — no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures — pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6. #1993: + cpp-cross-namespace-same-tail fixture — pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae. #2077 review follow-up: cpp-member-lattice adds cross-file, qualified-base, nested-template, inherited-using, this-receiver, and non-virtual-override regressions; fixture_count 274->275. Capture scaling remains linear (1.134 < 1.5)."
},
"csharp": {
@@ -91,10 +91,20 @@ function hasGitNexusServerOwner(gitNexusDir) {
return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid);
}
/**
* Whether opt-in diagnostics should be written to the hook's stderr. Strict
* hook runners validate hook output, so normal, non-error skip paths must stay
* silent unless the operator explicitly asks for diagnostics via GITNEXUS_DEBUG.
* See issue #1913.
*/
function isDebugEnabled() {
return process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
}
function extractAugmentContext(stderr) {
const output = (stderr || '').trim();
const marker = output.indexOf('[GitNexus]');
const debug = process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
const debug = isDebugEnabled();
if (debug && output.length > 0) {
// Emit the FULL discarded prefix (everything before the marker, or all of
// it when no marker is present) so suppressed diagnostics — LadybugDB lock
@@ -258,8 +268,14 @@ function buildAfterToolContext(input) {
if (/\bgit\s+(commit|merge|rebase|cherry-pick|pull)(\s|$)/.test(command)) {
const hint = buildStaleIndexHint(gitNexusDir, cwd);
if (hint) {
process.stderr.write(`${hint}\n`);
// The hint always reaches the agent via additionalContext (parts). Mirror
// it to stderr (for terminal users) only under GITNEXUS_DEBUG, so strict
// hook runners see no unexpected output on this normal path (#1913). The
// claude hook never mirrored this to stderr — this aligns the two adapters.
parts.push(hint);
if (isDebugEnabled()) {
process.stderr.write(`${hint}\n`);
}
}
}
}
@@ -268,14 +284,32 @@ function buildAfterToolContext(input) {
}
function runAugment(gitNexusDir, cwd, pattern) {
if (hasGitNexusServerOwner(gitNexusDir)) {
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
// Acquire the per-repo slot BEFORE the DB-owner probe (#2163): the probe
// itself spawns lsof/ps, so it must be bounded by the same ≤3-per-repo cap
// as the augment, or concurrent sessions fan out unbounded probe
// subprocesses. The cheap guards (extractPattern, gitNexusDir lookup) run in
// buildAfterToolContext before this — moving the acquire any earlier would
// churn slot files on tool calls that never probe.
const release = acquireHookSlot(gitNexusDir);
if (!release) {
// Normal skip path: all per-repo hook slots are held by concurrent
// sessions. Stay silent for strict hook runners (issue #1913); surface
// the reason only under GITNEXUS_DEBUG.
if (isDebugEnabled()) {
process.stderr.write('[GitNexus] augment skipped: hook slots saturated\n');
}
return '';
}
const release = acquireHookSlot(gitNexusDir);
if (!release) return '';
const cliPath = resolveCliPath();
try {
if (hasGitNexusServerOwner(gitNexusDir)) {
// Normal skip path: the MCP server owns the DB. Stay silent for strict
// hook runners (issue #1913); surface the reason only under GITNEXUS_DEBUG.
if (isDebugEnabled()) {
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
}
return '';
}
const cliPath = resolveCliPath();
const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000);
if (!child.error && child.status === 0) {
return extractAugmentContext(child.stderr || '');
@@ -338,7 +372,7 @@ function main() {
const handler = handlers[input.hook_event_name || ''];
if (handler) handler(input);
} catch (err) {
if (process.env.GITNEXUS_DEBUG) {
if (isDebugEnabled()) {
console.error('GitNexus antigravity hook error:', (err.message || '').slice(0, 200));
}
}
+36 -8
View File
@@ -110,10 +110,20 @@ function hasGitNexusServerOwner(gitNexusDir) {
return hasGitNexusDbLockedByGitNexusServer(path.join(gitNexusDir, 'lbug'), process.pid);
}
/**
* Whether opt-in diagnostics should be written to the hook's stderr. Strict
* hook runners (e.g. Codex `PreToolUse`) validate hook output, so normal,
* non-error skip paths must stay silent unless the operator explicitly asks
* for diagnostics via GITNEXUS_DEBUG. See issue #1913.
*/
function isDebugEnabled() {
return process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
}
function extractAugmentContext(stderr) {
const output = (stderr || '').trim();
const marker = output.indexOf('[GitNexus]');
const debug = process.env.GITNEXUS_DEBUG === '1' || process.env.GITNEXUS_DEBUG === 'true';
const debug = isDebugEnabled();
if (debug && output.length > 0) {
// Emit the FULL discarded prefix (everything before the marker, or all of
// it when no marker is present) so suppressed diagnostics — KuzuDB lock
@@ -249,17 +259,35 @@ function handlePreToolUse(input) {
const pattern = extractPattern(toolName, toolInput);
if (!pattern || pattern.length < 3) return;
if (hasGitNexusServerOwner(gitNexusDir)) {
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
// Acquire the per-repo slot BEFORE the DB-owner probe (#2163): the probe
// itself spawns lsof/ps, so it must be bounded by the same ≤3-per-repo cap
// as the augment, or concurrent sessions fan out unbounded probe
// subprocesses. Keep the acquire right after the cheap guards above —
// moving it earlier would churn slot files on tool calls that never probe.
const release = acquireHookSlot(gitNexusDir);
if (!release) {
// Normal skip path: all per-repo hook slots are held by concurrent
// sessions. Stay silent for strict hook runners (issue #1913); surface
// the reason only when diagnostics are explicitly requested.
if (isDebugEnabled()) {
process.stderr.write('[GitNexus] augment skipped: hook slots saturated\n');
}
return;
}
const release = acquireHookSlot(gitNexusDir);
if (!release) return;
const cliPath = resolveCliPath();
let result = '';
try {
if (hasGitNexusServerOwner(gitNexusDir)) {
// Normal skip path: the MCP server owns the DB, so the CLI augment would
// contend on the lock. Stay silent for strict hook runners (issue #1913);
// surface the reason only when diagnostics are explicitly requested.
if (isDebugEnabled()) {
process.stderr.write('[GitNexus] augment skipped: MCP server owns DB\n');
}
return;
}
const cliPath = resolveCliPath();
const child = runGitNexusCli(cliPath, ['augment', '--', pattern], cwd, 7000);
if (!child.error && child.status === 0) {
result = extractAugmentContext(child.stderr || '');
@@ -361,7 +389,7 @@ function main() {
const handler = handlers[input.hook_event_name || ''];
if (handler) handler(input);
} catch (err) {
if (process.env.GITNEXUS_DEBUG) {
if (isDebugEnabled()) {
console.error('GitNexus hook error:', (err.message || '').slice(0, 200));
}
}
+123 -2
View File
@@ -11,6 +11,22 @@
*
* Fail-open on most errors; fail-closed only on lsof ETIMEDOUT (Unix) or
* PowerShell ETIMEDOUT (Windows), matching the hook contract.
*
* Unix subprocess containment contract (#2163):
* - lsof/ps are wrapped in coreutils `timeout`/`gtimeout` when a working
* wrapper is found (`timeout -k 1 <budget> lsof ...`). If this hook process
* is itself SIGKILLed (e.g. by the runner's 10s hook timeout) the wrapper
* survives, SIGTERMs its child at the budget (2s lsof / 1s ps) and SIGKILLs
* it 1s later — orphan lifetime is bounded at ~3s instead of unbounded.
* - GITNEXUS_HOOK_TIMEOUT_PATH: the sentinel value `disabled` switches the
* wrapper off deterministically; any other value is adopted only when it
* exists AND passes a one-shot `-k` self-test — otherwise resolution FALLS
* THROUGH to the built-in candidate list (first self-test pass wins), so
* no malformed value of any shape can silently disable orphan containment.
* - The gitnexus server is lazy-open + sticky-hold: an idle MCP server holds
* ZERO lbug fds until the repo's first MCP query, then keeps the fd open.
* A probe before that first query is therefore always false — a known,
* pre-existing race, not a bug in this probe.
*/
const fs = require('fs');
@@ -46,6 +62,80 @@ function resolveHookBinary(tool) {
return tool;
}
// Sentinel:
// undefined = not resolved yet (resolve lazily, on first lsof/ps fallback)
// string = self-tested coreutils timeout/gtimeout path (use as wrapper)
// null = no usable wrapper (disabled, none found, or self-test failed)
let unixGuardTimeoutCache;
/**
* Resolve a coreutils `timeout`/`gtimeout` binary to wrap lsof/ps with
* (#2163). Dead code on Windows (the win32 dispatch returns earlier).
*
* GITNEXUS_HOOK_TIMEOUT_PATH semantics: the sentinel `disabled` turns the
* wrapper off; any other value is only a CANDIDATE — an existing file path
* is tried first, but it must pass the `-k` self-test to be adopted. On any
* failure (non-existent path, directory, non-executable file, wrapper
* without `-k` support, …) resolution falls through to the built-in
* candidates below, tried in order, first self-test pass wins. This is
* strictly stronger than the sibling GITNEXUS_HOOK_LSOF_PATH /
* GITNEXUS_HOOK_PS_PATH overrides (which only check existence): no bad env
* value of ANY shape can silently disable orphan containment.
*
* Lazy self-test: candidates are probed only when the lsof/ps fallback is
* first reached, and the result is memoized. A candidate is adopted only
* when `timeout -k 1 1 /bin/sh -c :` exits 0. This rejects wrappers that do
* not support the coreutils `-k` flag — busybox <1.34, toybox, broken
* symlinks — which would otherwise exit with a usage error without ever
* running lsof, silently converting the lsof-ETIMEDOUT fail-closed contract
* into fail-open (#1492 regression). Only when EVERY candidate fails does
* the probe fall back to the unwrapped status quo (memoized null).
* busybox ≥1.34 passes the test and is fully usable (capability, not
* identity, decides).
*/
function passesGuardSelfTest(guard) {
try {
const selfTest = spawnSync(guard, ['-k', '1', '1', '/bin/sh', '-c', ':'], {
encoding: 'utf-8',
timeout: 3000,
stdio: ['ignore', 'ignore', 'ignore'],
windowsHide: true,
});
return !selfTest.error && selfTest.status === 0;
} catch {
return false;
}
}
function resolveUnixGuardTimeout() {
if (unixGuardTimeoutCache !== undefined) return unixGuardTimeoutCache;
unixGuardTimeoutCache = null;
const fromEnv = process.env.GITNEXUS_HOOK_TIMEOUT_PATH;
const trimmed = fromEnv ? String(fromEnv).trim() : '';
if (trimmed === 'disabled') return unixGuardTimeoutCache;
const candidates = [];
if (trimmed && fs.existsSync(trimmed)) candidates.push(trimmed);
for (const builtin of [
'/usr/bin/timeout',
'/bin/timeout',
'/opt/homebrew/bin/gtimeout',
'/usr/local/bin/gtimeout',
]) {
try {
if (fs.existsSync(builtin)) candidates.push(builtin);
} catch {
/* ignore */
}
}
for (const candidate of candidates) {
if (passesGuardSelfTest(candidate)) {
unixGuardTimeoutCache = candidate;
break;
}
}
return unixGuardTimeoutCache;
}
function resolveWindowsPowerShellPath() {
const fromEnv = process.env.GITNEXUS_HOOK_POWERSHELL_PATH;
if (fromEnv && String(fromEnv).trim() && fs.existsSync(String(fromEnv).trim())) {
@@ -188,20 +278,46 @@ function linuxProcScanFindGitNexusServer(dbPathAbs, myPid) {
}
function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
const guard = resolveUnixGuardTimeout();
const lsofPath = resolveHookBinary('lsof');
const lsof = spawnSync(lsofPath, ['-nP', '-t', '--', dbPathAbs], {
// The spawnSync timeouts below (lsof 1000ms / ps 500ms) are deliberately
// SHORTER than the wrapper budgets (2s / 1s): on the supervised path Node's
// SIGTERM always fires first, so `error.code === 'ETIMEDOUT'` and the
// fail-closed contract are untouched. The wrapper only matters once this
// hook process has been SIGKILLed and can no longer deliver that SIGTERM.
const [lsofCmd, lsofArgs] = guard
? [guard, ['-k', '1', '2', lsofPath, '-nP', '-t', '--', dbPathAbs]]
: [lsofPath, ['-nP', '-t', '--', dbPathAbs]];
const lsof = spawnSync(lsofCmd, lsofArgs, {
encoding: 'utf-8',
timeout: 1000,
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
});
if (lsof.error) return lsof.error.code === 'ETIMEDOUT';
// Guard-mediated deaths map to "unresponsive holder" (fail-closed). Three
// result shapes, verified against coreutils 9.1:
// - signal-death: when `-k` escalates to SIGKILL, coreutils timeout
// SELF-RAISES the signal, so spawnSync reports {status: null, signal}
// with no .error (spawnSync's own ETIMEDOUT was handled above). The
// same shape appears when this hook is frozen >2s (SIGSTOP, laptop
// suspend) and the guard expires while it sleeps. By construction, a
// guard-wrapped probe that died by signal without spawnSync ETIMEDOUT
// is a budget/kill outcome.
// - 124: budget expired and the child exited after the plain SIGTERM.
// - 137: NOT the coreutils -k path — only exit-code-propagating wrappers,
// or a child SIGKILLed externally (e.g. the OOM killer).
if (guard && lsof.status === null && lsof.signal) return true;
if (guard && (lsof.status === 124 || lsof.status === 137)) return true;
const pids = (lsof.stdout || '').split(/\s+/).filter(Boolean);
const psPath = resolveHookBinary('ps');
for (const pid of pids) {
if (Number(pid) === myPid) continue;
const ps = spawnSync(psPath, ['-p', pid, '-o', 'command='], {
const [psCmd, psArgs] = guard
? [guard, ['-k', '1', '1', psPath, '-p', pid, '-o', 'command=']]
: [psPath, ['-p', pid, '-o', 'command=']];
const ps = spawnSync(psCmd, psArgs, {
encoding: 'utf-8',
timeout: 500,
stdio: ['ignore', 'pipe', 'ignore'],
@@ -211,6 +327,11 @@ function unixLsofPsFindGitNexusServer(dbPathAbs, myPid) {
if (ps.error.code === 'ETIMEDOUT') return true;
continue;
}
// Same guard-mediated-death mapping as the lsof call above (signal-death
// from the -k escalation or a frozen hook; 124 budget expiry; 137 only
// for exit-code-propagating wrappers / external SIGKILL).
if (guard && ps.status === null && ps.signal) return true;
if (guard && (ps.status === 124 || ps.status === 137)) return true;
if (isGitNexusServerCommand(ps.stdout || '')) return true;
}
return false;
+49 -8
View File
@@ -1,12 +1,12 @@
{
"name": "gitnexus",
"version": "1.6.7",
"version": "1.6.8-rc.20",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitnexus",
"version": "1.6.7",
"version": "1.6.8-rc.20",
"hasInstallScript": true,
"license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {
@@ -14,6 +14,7 @@
"@ladybugdb/core": "^0.17.0",
"@modelcontextprotocol/sdk": "^1.0.0",
"@scarf/scarf": "^1.4.0",
"busboy": "^1.6.0",
"cli-progress": "^3.12.0",
"commander": "^14.0.3",
"cors": "^2.8.5",
@@ -51,6 +52,7 @@
"gitnexus": "dist/cli/index.js"
},
"devDependencies": {
"@types/busboy": "^1.5.4",
"@types/cli-progress": "^3.11.6",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.6",
@@ -1703,6 +1705,16 @@
"@types/node": "*"
}
},
"node_modules/@types/busboy": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@types/busboy/-/busboy-1.5.4.tgz",
"integrity": "sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -1810,9 +1822,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
"version": "25.9.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz",
"integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==",
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
@@ -2213,6 +2225,17 @@
"node": "18 || 20 || >=22"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -3432,9 +3455,19 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -4815,6 +4848,14 @@
"dev": true,
"license": "MIT"
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "gitnexus",
"version": "1.6.7",
"version": "1.6.8-rc.20",
"description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
"author": "Abhigyan Patwari",
"license": "PolyForm-Noncommercial-1.0.0",
@@ -49,7 +49,7 @@
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:cross-platform": "tsx scripts/run-cross-platform.ts",
"postinstall": "node scripts/materialize-vendor-grammars.cjs && node scripts/build-tree-sitter-grammars.cjs",
"postinstall": "node scripts/build-tree-sitter-grammars.cjs",
"assert-publish-coverage": "node scripts/assert-publish-grammar-coverage.cjs",
"prepare": "node scripts/build.js",
"prepack": "node scripts/assert-publish-grammar-coverage.cjs && node scripts/build.js"
@@ -59,6 +59,7 @@
"@ladybugdb/core": "^0.17.0",
"@modelcontextprotocol/sdk": "^1.0.0",
"@scarf/scarf": "^1.4.0",
"busboy": "^1.6.0",
"cli-progress": "^3.12.0",
"commander": "^14.0.3",
"cors": "^2.8.5",
@@ -93,6 +94,7 @@
"uuid": "^14.0.0"
},
"devDependencies": {
"@types/busboy": "^1.5.4",
"@types/cli-progress": "^3.11.6",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.6",
@@ -112,6 +112,24 @@ function findCoverageProblems({ grammars }) {
return problems;
}
/**
* Stray local source-build outputs under `vendor/<name>/build/`. These would
* ship in the tarball (`files: ["vendor"]` overrides .gitignore/.npmignore) AND
* shadow the committed prebuilds — `node-gyp-build` resolves `build/Release`
* BEFORE `prebuilds/`, so a consumer on the publisher's platform would load the
* stray (possibly stale/wrong) binding instead of the curated prebuild. The
* build dir is gitignored and only appears if a maintainer source-built locally
* (e.g. on a no-prebuild platform); refuse to publish it. (#2144 review.)
*/
function findStrayBuildArtifacts(vendorDir) {
if (!fs.existsSync(vendorDir)) return [];
return fs
.readdirSync(vendorDir)
.filter((d) => /^tree-sitter-/.test(d))
.filter((d) => fs.existsSync(path.join(vendorDir, d, 'build')))
.map((d) => `vendor/${d}/build`);
}
function collectGrammars(vendorDir, shipsVendorSource) {
if (!fs.existsSync(vendorDir)) return [];
return fs
@@ -141,6 +159,18 @@ function main() {
process.exit(1);
}
const stray = findStrayBuildArtifacts(vendorDir);
if (stray.length > 0) {
console.error(
'[publish-guard] Refusing to publish — stray source-build output under vendor/ would\n' +
'ship and shadow the committed prebuilds (node-gyp-build loads build/Release before\n' +
'prebuilds/):',
);
for (const s of stray) console.error(` - ${s}`);
console.error('\nFix: remove it before packing, e.g. `rm -rf gitnexus/vendor/*/build`.');
process.exit(1);
}
const problems = findCoverageProblems({ grammars });
if (problems.length > 0) {
console.error('[publish-guard] Refusing to publish — a vendored grammar would ship unusable:');
@@ -163,6 +193,7 @@ if (require.main === module) main();
module.exports = {
findCoverageProblems,
findStrayBuildArtifacts,
filesShipsVendorSource,
isBuildableFromSource,
sourceBuildSet,
+16 -10
View File
@@ -1,19 +1,25 @@
#!/usr/bin/env node
/**
* Activate the vendored tree-sitter native bindings after
* materialize-vendor-grammars.cjs. One registry-driven script replaces the
* former per-grammar build-tree-sitter-<name>.cjs files (they were ~95%
* identical).
* Activate the vendored tree-sitter native bindings IN PLACE under `vendor/`.
* One registry-driven script replaces the former per-grammar
* build-tree-sitter-<name>.cjs files (they were ~95% identical).
*
* The grammars (tree-sitter-c/dart/proto/swift/kotlin) are loaded from
* `vendor/<name>/` by absolute path at runtime (see
* src/core/tree-sitter/vendored-grammars.ts) and are NEVER copied into
* node_modules — an undeclared package under node_modules is "extraneous" to
* every subsequent npm/npx reify, which prunes/relocates it (Windows
* `EPERM: …, symlink` + a silent grammar deletion on the 2nd run; #2111/#1728).
*
* For each grammar the resolution order is identical:
* 1. If the package isn't materialized (no binding.gyp) or the binding is
* 1. If the vendored source is absent (no binding.gyp) or the binding is
* already built, do nothing.
* 2. Prefer a committed prebuild for this platform-arch (toolchain-free) via
* node-gyp-build — the goal once build-tree-sitter-prebuilds.yml has
* populated all six tuples.
* node-gyp-build — `vendor/<name>/prebuilds/` ships all six tuples, so on a
* supported platform this returns immediately and writes nothing.
* 3. Otherwise source-build from the vendored grammar source (binding.gyp +
* src/) so parsing still works on any toolchain host — e.g. CI, before the
* prebuilds land.
* src/) into `vendor/<name>/build/` (gitignored) so parsing still works on
* a toolchain host that lacks a matching prebuild.
*
* HARD INVARIANT: this runs in `gitnexus`'s postinstall, so it MUST NEVER throw
* or exit non-zero — a failure for any single grammar must not break the install.
@@ -53,7 +59,7 @@ function buildGrammar(short) {
return;
}
const dir = path.join(__dirname, '..', 'node_modules', `tree-sitter-${short}`);
const dir = path.join(__dirname, '..', 'vendor', `tree-sitter-${short}`);
const bindingGyp = path.join(dir, 'binding.gyp');
const bindingNode = path.join(dir, 'build', 'Release', `tree_sitter_${short}_binding.node`);
@@ -1,97 +0,0 @@
#!/usr/bin/env node
/**
* Copy vendored tree-sitter grammars into node_modules/ using real files (fs.cpSync).
*
* Published gitnexus used to declare these as optionalDependencies with
* `file:./vendor/...`, which makes npm symlink/junction vendor → node_modules on
* install. Windows without Developer Mode often fails with EPERM (#1728).
*
* Vendor trees stay read-only in gitnexus/vendor/; build artifacts must only
* land under node_modules/ (see #836).
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..');
// tree-sitter-c is a REQUIRED grammar that we vendor prebuild-only purely to
// close upstream's ARM prebuild gap (#2116) — it needs no toolchain and is not a
// language the user opts out of, so it is always materialized, even under
// GITNEXUS_SKIP_OPTIONAL_GRAMMARS. The rest are optional (user-skippable, and
// Dart/Proto compile from source) and honor the skip flag.
const REQUIRED_VENDORED = ['tree-sitter-c'];
const OPTIONAL_VENDORED = [
'tree-sitter-dart',
'tree-sitter-proto',
'tree-sitter-swift',
'tree-sitter-kotlin',
];
const skipOptional = process.env.GITNEXUS_SKIP_OPTIONAL_GRAMMARS === '1';
if (skipOptional) {
console.warn(
'[gitnexus] GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1: skipping optional Dart/Proto/Swift/Kotlin materialize (required C is still materialized).',
);
}
const VENDORED_GRAMMARS = skipOptional
? REQUIRED_VENDORED
: [...REQUIRED_VENDORED, ...OPTIONAL_VENDORED];
for (const name of VENDORED_GRAMMARS) {
const src = path.join(ROOT, 'vendor', name);
const dest = path.join(ROOT, 'node_modules', name);
if (!fs.existsSync(src)) {
console.warn(`[gitnexus] vendor/${name} missing; skipping materialize.`);
continue;
}
// Sequence: copy src → partial; rename dest → backup; rename partial → dest;
// remove backup. If any step fails, restore from backup so a previously-
// materialized grammar is never lost. Targets the #1728 EPERM scenario plus
// narrower failure modes (Windows AV scanner racing on rename, EBUSY mid-swap).
const partial = `${dest}.materialize-tmp`;
const backup = `${dest}.materialize-bak`;
try {
fs.mkdirSync(path.join(ROOT, 'node_modules'), { recursive: true });
fs.rmSync(partial, { recursive: true, force: true });
fs.rmSync(backup, { recursive: true, force: true });
fs.cpSync(src, partial, { recursive: true, verbatim: true });
if (fs.existsSync(dest)) {
fs.renameSync(dest, backup);
}
try {
fs.renameSync(partial, dest);
} catch (renameErr) {
// Best-effort rollback: restore the previous dest from backup.
let restored = false;
if (fs.existsSync(backup)) {
try {
fs.renameSync(backup, dest);
restored = true;
} catch {
// Rollback also failed — dest is now missing. Leave the backup in
// place (the catch below will NOT remove it) and surface where it is.
}
}
if (!restored && fs.existsSync(backup)) {
console.warn(
`[gitnexus] CRITICAL: could not materialize vendor/${name} AND could not restore the ` +
`previous node_modules/${name}. A recoverable copy remains at ${backup} — ` +
`restore it (e.g. \`mv ${backup} ${dest}\`) or reinstall to recover ${name}.`,
);
}
throw renameErr;
}
fs.rmSync(backup, { recursive: true, force: true });
} catch (err) {
// Fail-soft: a single locked/inaccessible file (common on Windows) must not
// abort the whole gitnexus install. Matches build-tree-sitter-*.cjs pattern.
// Only remove the scratch `partial`; never the `backup` (it may be the sole
// recoverable copy after a failed rollback above).
fs.rmSync(partial, { recursive: true, force: true });
console.warn(`[gitnexus] Could not materialize vendor/${name}: ${err.message}`);
console.warn(
`[gitnexus] ${name} parsing will be unavailable. Other functionality is unaffected.`,
);
}
}
+43
View File
@@ -56,6 +56,7 @@ type ValueKind =
| 'boolean'
| 'boolean-negate'
| 'string'
| 'string-array'
| 'numeric-string'
| 'embeddings'
| 'branch';
@@ -84,6 +85,7 @@ const KEY_SPECS: Record<string, KeySpec> = {
skipContextFiles: { target: 'skipAgentsMd', kind: 'boolean' },
skipAiContext: { target: 'skipAgentsMd', kind: 'boolean' },
skipSkills: { target: 'skipSkills', kind: 'boolean' },
pdg: { target: 'pdg', kind: 'boolean' },
indexOnly: { target: 'indexOnly', kind: 'boolean' },
stats: { target: 'stats', kind: 'boolean' },
noStats: { target: 'stats', kind: 'boolean-negate' },
@@ -99,6 +101,12 @@ const KEY_SPECS: Record<string, KeySpec> = {
embeddingBatchSize: { target: 'embeddingBatchSize', kind: 'numeric-string' },
embeddingSubBatchSize: { target: 'embeddingSubBatchSize', kind: 'numeric-string' },
embeddingDevice: { target: 'embeddingDevice', kind: 'string' },
// #1589/#1852 residual — extra fetch-wrapper function names to treat as HTTP
// consumers. The auto-detector only flags functions that call the bare global
// `fetch()`; a wrapper built on axios / a custom client, or named outside the
// built-in convention set, is otherwise invisible to route_map consumers.
// Listing it here adds it to the cross-file consumer scan.
fetchWrappers: { target: 'fetchWrappers', kind: 'string-array' },
};
/** Top-level container key for the nested form; not itself an `AnalyzeOptions` field. */
@@ -230,6 +238,41 @@ const normalizeValue = (kind: ValueKind, value: unknown, key: string): unknown =
}
return trimmed;
}
case 'string-array': {
// Generic shared validator — `source` already names the config key, so
// messages here stay key-agnostic (no fetch-wrapper coupling in the
// shared normalizer; #1589/#1852 review F7).
if (!Array.isArray(value)) {
throw new GitNexusRcError(`${source} must be an array of strings.`);
}
const names: string[] = [];
for (const item of value) {
if (typeof item !== 'string') {
throw new GitNexusRcError(`${source} entries must all be strings.`);
}
const trimmed = item.trim();
if (!trimmed) {
throw new GitNexusRcError(`${source} entries must not be empty.`);
}
assertNoHiddenChars(trimmed, source);
// Values may be interpolated into a RegExp downstream. Restrict to
// identifier / member-access shapes so a config value can never smuggle
// regex metacharacters into a consumer.
if (!/^[A-Za-z_$][A-Za-z0-9_$.]*$/.test(trimmed)) {
throw new GitNexusRcError(
`${source} entry "${trimmed}" must be an identifier or member name ` +
`(letters, digits, _, $, . — e.g. "client.get").`,
);
}
names.push(trimmed);
}
if (names.length === 0) {
throw new GitNexusRcError(`${source} must list at least one string.`);
}
// De-duplicate and cap to a sane bound so a pathological config cannot
// blow up the consumer scan's alternation.
return Array.from(new Set(names)).slice(0, 100);
}
case 'numeric-string': {
// Mirror Commander's contract: these options reach the existing CLI
// validation as strings. Accept a JSON number or a string; normalize to a
+59 -7
View File
@@ -599,6 +599,12 @@ export interface AnalyzeOptions {
verbose?: boolean;
/** Skip AGENTS.md and CLAUDE.md gitnexus block updates. */
skipAgentsMd?: boolean;
/**
* Build the control-flow-graph / PDG substrate (#2081 M1). Opt-in; off by
* default. Threaded to both the worker (CFG build) and scope-resolution
* (BasicBlock/CFG emit).
*/
pdg?: boolean;
/**
* Stats inclusion in AGENTS.md and CLAUDE.md.
*
@@ -620,6 +626,14 @@ export interface AnalyzeOptions {
* before being threaded into the generated AGENTS.md / CLAUDE.md content.
*/
defaultBranch?: string;
/**
* Index-branch selector (#2106). From `--branch`. Distinct from
* `defaultBranch` (cosmetic base_ref): this routes the index to a per-branch
* slot. NOT sourced from `.gitnexusrc` — the `.gitnexusrc` `branch` key is an
* alias for `defaultBranch` and must not change index placement. Defaults to
* the checked-out branch inside `runFullAnalysis` when omitted.
*/
branch?: string;
/** Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills). */
indexOnly?: boolean;
/** Index the folder even when no .git directory is present. */
@@ -655,6 +669,14 @@ export interface AnalyzeOptions {
embeddingBatchSize?: string;
embeddingSubBatchSize?: string;
embeddingDevice?: string;
/**
* Extra fetch-wrapper function names to treat as HTTP consumers (#1589/#1852
* residual). Supplied via `.gitnexusrc` `fetchWrappers: [...]`. Threaded into
* the routes phase, where the cross-file consumer scan unions them with the
* auto-detected `fetch()` wrappers so a custom/axios-based wrapper named
* outside the built-in convention still produces `route_map` consumers.
*/
fetchWrappers?: string[];
}
/**
@@ -762,6 +784,21 @@ const analyzeCommandImpl = async (
}
}
// Validate the index-branch selector (#2106) the same way, so a malformed
// `--branch` exits before any expensive analysis starts. Capture the TRIMMED
// return so a whitespace-padded value (e.g. " feature" from shell completion)
// normalizes before the checked-out-branch mismatch guard and slug — otherwise
// it would false-reject on-branch or create a ghost index when detached.
if (cliOptions?.branch !== undefined) {
try {
cliOptions.branch = validateBranchName(cliOptions.branch, '--branch');
} catch (err) {
cliError(` ${err instanceof Error ? err.message : String(err)}\n`);
process.exitCode = 1;
return;
}
}
// ── Load .gitnexusrc and merge: CLI flags override config (#243) ───
// Parse/validate before the progress bar so a malformed config produces an
// actionable error and exits before any expensive analysis starts.
@@ -1091,9 +1128,15 @@ const analyzeCommandImpl = async (
skipGit: options.skipGit,
skipAgentsMd,
skipSkills,
// CFG/PDG substrate opt-in (#2081 M1) — threaded to both sinks downstream.
pdg: options.pdg === true,
// Resolved default branch (CLI > .gitnexusrc > auto-detect > "main")
// threaded into the generated regression-compare example (#243).
defaultBranch: resolvedDefaultBranch,
// Index-branch selector (#2106). Read straight from the CLI flag (not
// the .gitnexusrc-merged options) so the cosmetic defaultBranch config
// can never change index placement. Undefined → auto-detect in pipeline.
branch: cliOptions?.branch,
// commander.js `.option('--no-stats', …)` registers the flag as
// `options.stats` (boolean, default true; `false` when the user
// passed --no-stats). Reading `options.noStats` here returns
@@ -1110,6 +1153,9 @@ const analyzeCommandImpl = async (
// GITNEXUS_WORKER_POOL_SIZE env mutation. `undefined` defers to the
// env / auto-formula fallback inside the pipeline.
workerPoolSize,
// Extra fetch-wrapper names from `.gitnexusrc` (#1589/#1852 residual);
// forwarded to the routes phase consumer scan.
fetchWrappers: options.fetchWrappers,
},
{
onProgress: (_phase, percent, message) => {
@@ -1131,14 +1177,20 @@ const analyzeCommandImpl = async (
// preserving the rest of the block (incl. --skills community rows). No-op
// when the value already matches, so a routine up-to-date run is silent
// (#1996 tri-review P2).
// Only refresh the repo-root AGENTS.md/CLAUDE.md base_ref for the
// PRIMARY/flat index (#2106 R2). A non-primary branch's up-to-date
// analyze must not churn the committed AGENTS.md — this mirrors the
// in-pipeline `if (!placement.branch)` gate around generateAIContextFiles.
let baseRefRefreshed: string[] = [];
try {
const { refreshBaseRefLine } = await import('./ai-context.js');
baseRefRefreshed = (
await refreshBaseRefLine(repoPath, resolvedDefaultBranch, { skipAgentsMd })
).files;
} catch {
/* best-effort — never fail the fast path over a context refresh */
if (result.isPrimaryBranch !== false) {
try {
const { refreshBaseRefLine } = await import('./ai-context.js');
baseRefRefreshed = (
await refreshBaseRefLine(repoPath, resolvedDefaultBranch, { skipAgentsMd })
).files;
} catch {
/* best-effort — never fail the fast path over a context refresh */
}
}
clearInterval(elapsedTimer);
process.removeListener('SIGINT', sigintHandler);
+45
View File
@@ -13,6 +13,8 @@ import {
unregisterRepo,
listRegisteredRepos,
assertSafeStoragePath,
getStoragePaths,
removeBranchIndex,
UnsafeStoragePathError,
} from '../storage/repo-manager.js';
import {
@@ -26,7 +28,50 @@ export const cleanCommand = async (options?: {
force?: boolean;
all?: boolean;
lbugSidecars?: boolean;
branch?: string;
}) => {
// --branch <name>: remove a single non-primary branch's index (#2106 R7).
// Resolve against the RECORDED branches[] summary (never by slugging the
// user's raw input, which can disagree with the index-time-sanitized label).
if (options?.branch) {
const cwd = process.cwd();
const repo = await findRepo(cwd);
if (!repo) {
console.log(t('clean.notFoundHere'));
return;
}
const entries = await listRegisteredRepos();
const entry = entries.find((e) => path.resolve(e.path) === path.resolve(repo.repoPath));
const summary = entry?.branches?.find((b) => b.branch === options.branch);
if (!summary) {
console.log(t('clean.branchNotIndexed', { branch: options.branch }));
return;
}
const { storagePath, lbugPath } = getStoragePaths(repo.repoPath, summary.branch);
const branchDir = path.dirname(lbugPath);
// Safety guard: the target MUST live under <repo>/.gitnexus/branches/.
// assertSafeStoragePath only validates the flat `<repo>/.gitnexus`, so this
// is a dedicated branches-sub-dir check before any destructive fs.rm.
const branchesRoot = path.join(storagePath, 'branches') + path.sep;
if (!branchDir.startsWith(branchesRoot)) {
logger.error(`Refusing to clean branch index outside .gitnexus/branches: ${branchDir}`);
return;
}
if (!options.force) {
console.log(t('clean.deleteBranch', { branch: summary.branch, path: branchDir }));
console.log(`\n${t('common.runForceConfirm')}`);
return;
}
try {
await fs.rm(branchDir, { recursive: true, force: true });
await removeBranchIndex(repo.repoPath, summary.branch);
console.log(t('clean.deletedBranch', { branch: summary.branch }));
} catch (err) {
logger.error({ err }, 'Failed to delete branch index:');
}
return;
}
if (options?.lbugSidecars) {
const cwd = process.cwd();
const repo = await findRepo(cwd);
+48
View File
@@ -189,7 +189,47 @@ export function formatImpactResult(result: any): string {
const byDepth = result.byDepth || {};
const total = result.impactedCount || 0;
// #2129 — an ambiguous bare name must not print the "isolated / safe to
// refactor" headline. Surface the per-candidate blast radius + the maximum,
// mirroring formatContextResult, so the real impact under whichever symbol the
// caller meant is visible on the text surface, not just in the JSON.
if (result.status === 'ambiguous') {
// #2129 review F11 — report the FULL match count (`totalCandidates`), not the
// truncated `candidates[]` length; note when the candidate list is capped.
const shown = result.candidates?.length ?? 0;
const total = result.totalCandidates ?? shown;
const countPhrase = total > shown ? `${total} symbols (showing ${shown})` : `${total} symbols`;
const lines = [
`${target?.name || '?'}: AMBIGUOUS — ${countPhrase} share this name. ` +
`Max blast radius ${result.maxImpactedCount ?? 0} (${result.maxRisk ?? 'UNKNOWN'} risk). ` +
`Disambiguate with --uid for one authoritative result:`,
];
for (const c of result.candidates || []) {
lines.push(
` ${c.kind} ${c.name} → ${c.filePath}:${c.line || '?'} ` +
`[${c.impactedCount ?? 0} ${direction}, risk ${c.risk ?? 'UNKNOWN'}] (uid: ${c.uid})`,
);
}
// #2129 review F1 — a failed per-candidate probe makes the max a lower bound.
if (result.partialProbe) {
lines.push(
' ⚠️ One or more candidate probes failed — max blast radius / risk are lower bounds.',
);
}
return lines.join('\n');
}
if (total === 0) {
// #1858 — "isolated" is a confident claim. If an interface / indirection
// boundary is on the path, the true count is a lower bound, not zero;
// callers binding via DI / dynamic dispatch were not traced. Say so instead.
if (result.epistemic === 'lower-bound') {
const lines = [
`${target?.name || '?'}: no direct ${direction} dependencies traced, but this is a LOWER BOUND — unresolved indirection on the path (actual impact may be higher):`,
];
for (const b of result.boundaries || []) lines.push(` • ${b}`);
return lines.join('\n');
}
return `${target?.name || '?'}: No ${direction} dependencies found. This symbol appears isolated.`;
}
@@ -202,6 +242,14 @@ export function formatImpactResult(result: any): string {
if (result.partial) {
lines.push('⚠️ Partial results — graph traversal was interrupted. Deeper impacts may exist.');
}
// #1858 — an interface / indirection boundary on the path makes this a lower
// bound; surface it so the count is not read as exhaustive.
if (result.epistemic === 'lower-bound') {
lines.push(
'⚠️ Lower bound — unresolved indirection on the path (callers binding via DI / dynamic dispatch are not traced; actual impact may be higher):',
);
for (const b of result.boundaries || []) lines.push(` • ${b}`);
}
lines.push('');
const depthLabels: Record<number, string> = {
+11
View File
@@ -30,6 +30,7 @@ const COMMAND_DESCRIPTION_KEYS = {
impact: 'help.command.impact.description',
cypher: 'help.command.cypher.description',
'detect-changes': 'help.command.detectChanges.description',
check: 'help.command.check.description',
'eval-server': 'help.command.evalServer.description',
group: 'help.command.group.description',
'group create': 'help.command.group.create.description',
@@ -73,6 +74,7 @@ const OPTION_DESCRIPTION_KEYS = {
'uninstall|-f, --force': 'help.option.uninstall.force',
'clean|-f, --force': 'help.option.force.confirmation',
'clean|--all': 'help.option.clean.all',
'clean|--branch <name>': 'help.option.clean.branch',
'clean|--lbug-sidecars': 'help.option.clean.lbugSidecars',
'remove|-f, --force': 'help.option.force.confirmation',
'wiki|-f, --force': 'help.option.wiki.force',
@@ -93,16 +95,19 @@ const OPTION_DESCRIPTION_KEYS = {
'publish|--id <owner/repo>': 'help.option.publish.id',
'publish|--skip-git': 'help.option.skipGit',
'query|-r, --repo <name>': 'help.option.repo.targetOmitOne',
'query|--branch <name>': 'help.option.branch',
'query|-c, --context <text>': 'help.option.query.context',
'query|-g, --goal <text>': 'help.option.query.goal',
'query|-l, --limit <n>': 'help.option.query.limit',
'query|--content': 'help.option.content',
'context|-r, --repo <name>': 'help.option.repo.target',
'context|--branch <name>': 'help.option.branch',
'context|-u, --uid <uid>': 'help.option.context.uid',
'context|-f, --file <path>': 'help.option.context.file',
'context|--content': 'help.option.content',
'impact|-d, --direction <dir>': 'help.option.impact.direction',
'impact|-r, --repo <name>': 'help.option.repo.target',
'impact|--branch <name>': 'help.option.branch',
'impact|-u, --uid <uid>': 'help.option.context.uid',
'impact|-f, --file <path>': 'help.option.context.file',
'impact|--kind <kind>': 'help.option.impact.kind',
@@ -112,9 +117,15 @@ const OPTION_DESCRIPTION_KEYS = {
'impact|--offset <n>': 'help.option.impact.offset',
'impact|--summary-only': 'help.option.impact.summaryOnly',
'cypher|-r, --repo <name>': 'help.option.repo.target',
'cypher|--branch <name>': 'help.option.branch',
'detect-changes|-s, --scope <scope>': 'help.option.detectChanges.scope',
'detect-changes|-b, --base-ref <ref>': 'help.option.detectChanges.baseRef',
'detect-changes|-r, --repo <name>': 'help.option.repo.target',
'detect-changes|--branch <name>': 'help.option.branch',
'check|--cycles': 'help.option.check.cycles',
'check|--json': 'help.option.json',
'check|-r, --repo <name>': 'help.option.repo.target',
'check|--branch <name>': 'help.option.branch',
'eval-server|-p, --port <port>': 'help.option.port',
'eval-server|--host <host>': 'help.option.evalServer.host',
'eval-server|--idle-timeout <seconds>': 'help.option.evalServer.idleTimeout',
+14
View File
@@ -10,6 +10,9 @@ export const en = {
'list.title': 'Indexed Repositories ({{count}})',
'list.indexed': 'Indexed',
'list.commit': 'Commit',
'list.branch': 'Branch',
'list.branchIndexes': 'Branch indexes',
'list.branchLine': '{{branch}} ({{commit}}, {{indexed}})',
'list.stats': 'Stats',
'list.statsValue': '{{files}} files, {{symbols}} symbols, {{edges}} edges',
'list.clusters': 'Clusters',
@@ -23,6 +26,10 @@ export const en = {
'status.indexed': 'Indexed',
'status.indexedCommit': 'Indexed commit',
'status.currentCommit': 'Current commit',
'status.branch': 'Branch',
'status.detached': '(detached HEAD)',
'status.branchNotIndexed':
"⚠️ current branch not indexed (primary index is for '{{primary}}'; run gitnexus analyze)",
'status.status': 'Status',
'status.upToDate': '✅ up-to-date',
'status.stale': '⚠️ stale (re-run gitnexus analyze)',
@@ -30,6 +37,9 @@ export const en = {
'clean.deletedRepo': 'Deleted: {{name}} ({{storagePath}})',
'clean.notFoundHere': 'No indexed repository found in this directory.',
'clean.deleteCurrent': 'This will delete the GitNexus index for: {{repoName}}',
'clean.branchNotIndexed': 'No indexed branch named "{{branch}}" for this repository.',
'clean.deleteBranch': 'This will delete the branch index "{{branch}}" at: {{path}}',
'clean.deletedBranch': 'Deleted branch index: {{branch}}',
'clean.lbugSidecars.state': 'LadybugDB sidecar state: {{state}}',
'clean.lbugSidecars.none': 'No quarantined LadybugDB missing-shadow WAL sidecars found.',
'clean.lbugSidecars.preview':
@@ -133,6 +143,7 @@ export const en = {
'help.command.cypher.description': 'Execute raw Cypher query against the knowledge graph',
'help.command.detectChanges.description':
'Map git diff hunks to indexed symbols and affected execution flows',
'help.command.check.description': 'Run structural checks against the indexed graph',
'help.command.evalServer.description':
'Start lightweight HTTP server for fast tool calls during evaluation',
'help.command.group.description': 'Manage repository groups for cross-index impact analysis',
@@ -189,6 +200,7 @@ export const en = {
'help.option.force.confirmation': 'Skip confirmation prompt',
'help.option.uninstall.force': 'Apply the changes (default is a dry-run preview)',
'help.option.clean.all': 'Clean all indexed repos',
'help.option.clean.branch': 'Delete only the named branch index (not the primary)',
'help.option.clean.lbugSidecars': 'Clean quarantined LadybugDB missing-shadow WAL sidecars',
'help.option.wiki.force': 'Force full regeneration even if up to date',
'help.option.wiki.provider':
@@ -217,6 +229,7 @@ export const en = {
'help.option.query.limit': 'Max processes to return (default: 5)',
'help.option.content': 'Include full symbol source code',
'help.option.repo.target': 'Target repository',
'help.option.branch': 'Scope to a specific branch index (multi-branch repos)',
'help.option.context.uid': 'Direct symbol UID (zero-ambiguity lookup)',
'help.option.context.file': 'File path to disambiguate common names',
'help.option.impact.kind':
@@ -229,6 +242,7 @@ export const en = {
'help.option.impact.summaryOnly': 'Return counts and risk only, omit symbol list',
'help.option.detectChanges.scope': 'What to analyze: unstaged, staged, all, or compare',
'help.option.detectChanges.baseRef': 'Branch/commit for compare scope (e.g. main)',
'help.option.check.cycles': 'Detect circular imports and fail when any are found',
'help.option.evalServer.host':
'Bind address (default: 127.0.0.1, use 0.0.0.0 to expose to all interfaces)',
'help.option.evalServer.idleTimeout': 'Auto-shutdown after N seconds idle (0 = disabled)',
+14
View File
@@ -14,6 +14,9 @@ export const zhCN = {
'list.title': '已索引仓库({{count}})',
'list.indexed': '索引时间',
'list.commit': '提交',
'list.branch': '分支',
'list.branchIndexes': '分支索引',
'list.branchLine': '{{branch}}({{commit}},{{indexed}})',
'list.stats': '统计',
'list.statsValue': '{{files}} 个文件,{{symbols}} 个符号,{{edges}} 条边',
'list.clusters': '聚类',
@@ -27,6 +30,10 @@ export const zhCN = {
'status.indexed': '索引时间',
'status.indexedCommit': '索引提交',
'status.currentCommit': '当前提交',
'status.branch': '分支',
'status.detached': '(分离 HEAD)',
'status.branchNotIndexed':
"⚠️ 当前分支未索引(主索引对应 '{{primary}}';请运行 gitnexus analyze)",
'status.status': '状态',
'status.upToDate': '✅ 已是最新',
'status.stale': '⚠️ 已过期(重新运行 gitnexus analyze)',
@@ -34,6 +41,9 @@ export const zhCN = {
'clean.deletedRepo': '已删除:{{name}}({{storagePath}})',
'clean.notFoundHere': '当前目录未找到已索引仓库。',
'clean.deleteCurrent': '将删除该仓库的 GitNexus 索引:{{repoName}}',
'clean.branchNotIndexed': '该仓库没有名为 “{{branch}}” 的已索引分支。',
'clean.deleteBranch': '将删除分支索引 “{{branch}}”,路径:{{path}}',
'clean.deletedBranch': '已删除分支索引:{{branch}}',
'clean.lbugSidecars.state': 'LadybugDB sidecar 状态:{{state}}',
'clean.lbugSidecars.none': '未找到已隔离的 LadybugDB missing-shadow WAL sidecar。',
'clean.lbugSidecars.preview':
@@ -129,6 +139,7 @@ export const zhCN = {
'help.command.impact.description': '影响面分析:修改符号会影响什么',
'help.command.cypher.description': '对知识图谱执行原始 Cypher 查询',
'help.command.detectChanges.description': '将 git diff hunk 映射到已索引符号和受影响执行流程',
'help.command.check.description': '对已索引图谱运行结构检查',
'help.command.evalServer.description': '启动轻量 HTTP 服务器,用于评测期间的快速工具调用',
'help.command.group.description': '管理仓库组,用于跨索引影响分析',
'help.command.group.create.description': '使用模板 group.yaml 创建新仓库组',
@@ -178,6 +189,7 @@ export const zhCN = {
'help.option.force.confirmation': '跳过确认提示',
'help.option.uninstall.force': '应用更改(默认仅为预演预览)',
'help.option.clean.all': '清理所有已索引仓库',
'help.option.clean.branch': '仅删除指定分支的索引(不影响主索引)',
'help.option.clean.lbugSidecars': '清理已隔离的 LadybugDB missing-shadow WAL sidecar',
'help.option.wiki.force': '即使已是最新也强制完整重新生成',
'help.option.wiki.provider':
@@ -203,6 +215,7 @@ export const zhCN = {
'help.option.query.limit': '最多返回的流程数(默认:5)',
'help.option.content': '包含完整符号源码',
'help.option.repo.target': '目标仓库',
'help.option.branch': '将查询限定到指定分支的索引(多分支仓库)',
'help.option.context.uid': '直接符号 UID(零歧义查找)',
'help.option.context.file': '用于消除常见名称歧义的文件路径',
'help.option.impact.kind': '用于消除常见名称歧义的类型过滤(如 Function、Class、Method)',
@@ -214,6 +227,7 @@ export const zhCN = {
'help.option.impact.summaryOnly': '仅返回计数和风险等级,省略符号列表',
'help.option.detectChanges.scope': '分析范围:unstaged、staged、all 或 compare',
'help.option.detectChanges.baseRef': 'compare 范围的分支/提交(例如 main)',
'help.option.check.cycles': '检测循环导入,并在发现循环时失败',
'help.option.evalServer.host': '绑定地址(默认:127.0.0.1;用 0.0.0.0 暴露到所有网卡)',
'help.option.evalServer.idleTimeout': '空闲 N 秒后自动关闭(0 = 禁用)',
'help.option.group.create.force': '覆盖现有仓库组',
+26
View File
@@ -52,11 +52,22 @@ program
'(no-op when --index-only is also set).',
)
.option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md')
.option(
'--pdg',
'Build the control-flow-graph / PDG substrate (BasicBlock nodes + CFG edges) ' +
'for supported languages. Opt-in; off by default. (#2081 M1)',
)
.option(
'--default-branch <branch>',
'Default branch used in the generated regression-compare example (base_ref). ' +
'Falls back to .gitnexusrc, then auto-detected origin/HEAD, then "main".',
)
.option(
'--branch <name>',
'Index the working tree under a specific branch slot (multi-branch indexing). ' +
'Defaults to the checked-out branch; the primary/first-indexed branch keeps the ' +
'flat index and others get their own. Distinct from --default-branch (cosmetic base_ref).',
)
.option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md')
.option(
'--skip-skills',
@@ -145,6 +156,7 @@ program
.description('Delete GitNexus index for current repo')
.option('-f, --force', 'Skip confirmation prompt')
.option('--all', 'Clean all indexed repos')
.option('--branch <name>', 'Delete only the named branch index (not the primary)')
.option('--lbug-sidecars', 'Clean quarantined LadybugDB missing-shadow WAL sidecars')
.action(createLazyAction(() => import('./clean.js'), 'cleanCommand'));
@@ -216,6 +228,7 @@ program
.command('query <search_query>')
.description('Search the knowledge graph for execution flows related to a concept')
.option('-r, --repo <name>', 'Target repository (omit if only one indexed)')
.option('--branch <name>', 'Scope to a specific branch index (multi-branch repos)')
.option('-c, --context <text>', 'Task context to improve ranking')
.option('-g, --goal <text>', 'What you want to find')
.option('-l, --limit <n>', 'Max processes to return (default: 5)')
@@ -226,6 +239,7 @@ program
.command('context [name]')
.description('360-degree view of a code symbol: callers, callees, processes')
.option('-r, --repo <name>', 'Target repository')
.option('--branch <name>', 'Scope to a specific branch index (multi-branch repos)')
.option('-u, --uid <uid>', 'Direct symbol UID (zero-ambiguity lookup)')
.option('-f, --file <path>', 'File path to disambiguate common names')
.option('--content', 'Include full symbol source code')
@@ -236,6 +250,7 @@ program
.description('Blast radius analysis: what breaks if you change a symbol')
.option('-d, --direction <dir>', 'upstream (dependants) or downstream (dependencies)', 'upstream')
.option('-r, --repo <name>', 'Target repository')
.option('--branch <name>', 'Scope to a specific branch index (multi-branch repos)')
.option('-u, --uid <uid>', 'Direct symbol UID (zero-ambiguity lookup)')
.option('-f, --file <path>', 'File path to disambiguate common names')
.option(
@@ -253,6 +268,7 @@ program
.command('cypher <query>')
.description('Execute raw Cypher query against the knowledge graph')
.option('-r, --repo <name>', 'Target repository')
.option('--branch <name>', 'Scope to a specific branch index (multi-branch repos)')
.action(createLbugLazyAction(() => import('./tool.js'), 'cypherCommand'));
program
@@ -262,8 +278,18 @@ program
.option('-s, --scope <scope>', 'What to analyze: unstaged, staged, all, or compare', 'unstaged')
.option('-b, --base-ref <ref>', 'Branch/commit for compare scope (e.g. main)')
.option('-r, --repo <name>', 'Target repository')
.option('--branch <name>', 'Scope to a specific branch index (multi-branch repos)')
.action(createLbugLazyAction(() => import('./tool.js'), 'detectChangesCommand'));
program
.command('check')
.description('Run structural checks against the indexed graph')
.option('--cycles', 'Detect circular imports and fail when any are found')
.option('--json', 'Emit machine-readable JSON')
.option('-r, --repo <name>', 'Target repository')
.option('--branch <name>', 'Scope to a specific branch index (multi-branch repos)')
.action(createLbugLazyAction(() => import('./tool.js'), 'checkCommand'));
// ─── Eval Server (persistent daemon for SWE-bench) ─────────────────
program
+13
View File
@@ -38,6 +38,7 @@ export const listCommand = async () => {
console.log(` ${t('common.path')}: ${entry.path}`);
console.log(` ${t('list.indexed')}: ${indexedDate}`);
console.log(` ${t('list.commit')}: ${commitShort}`);
if (entry.branch) console.log(` ${t('list.branch')}: ${entry.branch}`);
console.log(
` ${t('list.stats')}: ${t('list.statsValue', {
files: stats.files ?? 0,
@@ -47,6 +48,18 @@ export const listCommand = async () => {
);
if (stats.communities) console.log(` ${t('list.clusters')}: ${stats.communities}`);
if (stats.processes) console.log(` ${t('list.processes')}: ${stats.processes}`);
// Per-branch indexes (#2106). Only rendered when extra branches were
// indexed for this path, so single-branch output is unchanged.
if (entry.branches && entry.branches.length > 0) {
console.log(` ${t('list.branchIndexes')}:`);
for (const b of entry.branches) {
const bCommit = b.lastCommit?.slice(0, 7) || t('list.unknown');
const bIndexed = new Date(b.indexedAt).toLocaleString();
console.log(
` ${t('list.branchLine', { branch: b.branch, commit: bCommit, indexed: bIndexed })}`,
);
}
}
console.log('');
}
};
+9 -13
View File
@@ -1,15 +1,13 @@
/**
* Optional grammar availability check.
*
* tree-sitter-dart, tree-sitter-proto, and tree-sitter-swift are vendored
* under vendor/ and materialized into node_modules/ at postinstall. Dart
* and Proto are built from source with node-gyp; Swift ships platform
* prebuilds activated via node-gyp-build. tree-sitter-kotlin is a declared
* optionalDependency (not vendored). All can be skipped via
* tree-sitter-dart, -proto, -swift, and -kotlin are vendored under vendor/ and
* loaded from there by absolute path (NEVER copied into node_modules — see
* core/tree-sitter/vendored-grammars.ts / #2111). Each ships committed platform
* prebuilds activated via node-gyp-build. All can be skipped via
* GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 (postinstall scripts), or can silently
* soft-fail when the toolchain is missing (Dart/Proto), when no prebuild
* matches the host platform (Swift), or when the optional install was
* skipped or its native build failed (Kotlin).
* soft-fail when no prebuild matches the host platform (and a source build was
* unavailable / not attempted).
*
* Either path produces the same observable: the .node binding is absent
* at runtime. This helper detects that condition and surfaces a single
@@ -17,17 +15,15 @@
* support is unavailable instead of silently getting a degraded index.
*/
import { createRequire } from 'module';
import { SupportedLanguages } from 'gitnexus-shared';
import { isGrammarRuntimeSkipped } from '../core/tree-sitter/parser-loader.js';
import { requireVendoredGrammar } from '../core/tree-sitter/vendored-grammars.js';
import { cliWarn } from './cli-message.js';
const _require = createRequire(import.meta.url);
interface OptionalGrammar {
/** Display name in warnings */
name: string;
/** Module name to require.resolve */
/** Vendored grammar package name (directory under vendor/) */
pkg: string;
/** File extensions this grammar parses */
extensions: string[];
@@ -109,7 +105,7 @@ export function detectMissingOptionalGrammars(): MissingGrammar[] {
continue;
}
try {
_require(g.pkg);
requireVendoredGrammar(g.pkg);
} catch (err) {
const code = (err as NodeJS.ErrnoException | undefined)?.code;
const msg = err instanceof Error ? err.message : String(err);
+29 -5
View File
@@ -4,8 +4,9 @@
* Shows the indexing status of the current repository.
*/
import { findRepo, getStoragePaths, hasKuzuIndex } from '../storage/repo-manager.js';
import { getCurrentCommit, isGitRepo, getGitRoot } from '../storage/git.js';
import path from 'path';
import { findRepo, getStoragePaths, loadMeta, hasKuzuIndex } from '../storage/repo-manager.js';
import { getCurrentCommit, getCurrentBranch, isGitRepo, getGitRoot } from '../storage/git.js';
import { t } from './i18n/index.js';
export const statusCommand = async () => {
@@ -32,11 +33,34 @@ export const statusCommand = async () => {
}
const currentCommit = getCurrentCommit(repo.repoPath);
const isUpToDate = currentCommit === repo.meta.lastCommit;
const currentBranch = getCurrentBranch(repo.repoPath);
// Pick the index matching the checked-out branch (#2106). The flat index
// belongs to the primary branch (repo.meta.branch); when the current branch
// differs and has its own index, report that one. Legacy/no-branch metas and
// detached HEAD fall through to the flat index (unchanged behavior).
let activeMeta = repo.meta;
let currentBranchIndexed = true;
if (currentBranch && repo.meta.branch && currentBranch !== repo.meta.branch) {
const { metaPath } = getStoragePaths(repo.repoPath, currentBranch);
const branchMeta = await loadMeta(path.dirname(metaPath));
if (branchMeta) activeMeta = branchMeta;
else currentBranchIndexed = false;
}
console.log(`${t('status.repository')}: ${repo.repoPath}`);
console.log(`${t('status.indexed')}: ${new Date(repo.meta.indexedAt).toLocaleString()}`);
console.log(`${t('status.indexedCommit')}: ${repo.meta.lastCommit?.slice(0, 7)}`);
console.log(`${t('status.branch')}: ${currentBranch ?? t('status.detached')}`);
if (!currentBranchIndexed) {
console.log(
`${t('status.status')}: ${t('status.branchNotIndexed', { primary: repo.meta.branch ?? '' })}`,
);
return;
}
const isUpToDate = currentCommit === activeMeta.lastCommit;
console.log(`${t('status.indexed')}: ${new Date(activeMeta.indexedAt).toLocaleString()}`);
console.log(`${t('status.indexedCommit')}: ${activeMeta.lastCommit?.slice(0, 7)}`);
console.log(`${t('status.currentCommit')}: ${currentCommit?.slice(0, 7)}`);
console.log(`${t('status.status')}: ${isUpToDate ? t('status.upToDate') : t('status.stale')}`);
};
+51 -1
View File
@@ -1,7 +1,7 @@
/**
* Direct CLI Tool Commands
*
* Exposes GitNexus tools (query, context, impact, cypher) as direct CLI commands.
* Exposes GitNexus tools (query, context, impact, cypher, check) as direct CLI commands.
* Bypasses MCP entirely — invokes LocalBackend directly for minimal overhead.
*
* Usage:
@@ -62,6 +62,7 @@ export async function queryCommand(
queryText: string,
options?: {
repo?: string;
branch?: string;
context?: string;
goal?: string;
limit?: string;
@@ -81,6 +82,7 @@ export async function queryCommand(
limit: options?.limit ? parseInt(options.limit) : undefined,
include_content: options?.content ?? false,
repo: options?.repo,
branch: options?.branch,
});
output(result);
}
@@ -89,6 +91,7 @@ export async function contextCommand(
name: string,
options?: {
repo?: string;
branch?: string;
file?: string;
uid?: string;
content?: boolean;
@@ -111,6 +114,7 @@ export async function contextCommand(
file_path: options?.file,
include_content: options?.content ?? false,
repo: options?.repo,
branch: options?.branch,
});
output(result);
}
@@ -120,6 +124,7 @@ export async function impactCommand(
options?: {
direction?: string;
repo?: string;
branch?: string;
uid?: string;
file?: string;
kind?: string;
@@ -165,6 +170,7 @@ export async function impactCommand(
maxDepth: options?.depth ? parseInt(options.depth, 10) : undefined,
includeTests: options?.includeTests ?? false,
repo: options?.repo,
branch: options?.branch,
limit: parsedLimit,
offset: parsedOffset,
summaryOnly: options?.summaryOnly ?? undefined,
@@ -188,6 +194,7 @@ export async function cypherCommand(
query: string,
options?: {
repo?: string;
branch?: string;
},
): Promise<void> {
if (!query?.trim()) {
@@ -199,6 +206,7 @@ export async function cypherCommand(
const result = await backend.callTool('cypher', {
query,
repo: options?.repo,
branch: options?.branch,
});
output(result);
}
@@ -207,12 +215,54 @@ export async function detectChangesCommand(options?: {
scope?: string;
baseRef?: string;
repo?: string;
branch?: string;
}): Promise<void> {
const backend = await getBackend();
const result = await backend.callTool('detect_changes', {
scope: options?.scope || 'unstaged',
base_ref: options?.baseRef,
repo: options?.repo,
branch: options?.branch,
});
output(formatDetectChangesResult(result));
}
export async function checkCommand(options?: {
cycles?: boolean;
json?: boolean;
repo?: string;
branch?: string;
}): Promise<void> {
if (!options?.cycles) {
process.stderr.write('Usage: gitnexus check --cycles [--json]\n');
process.exitCode = 1;
return;
}
try {
const backend = await getBackend();
const result = await backend.callTool('check', {
cycles: true,
repo: options.repo,
branch: options.branch,
});
if (result?.error) {
output(result);
process.exitCode = 1;
return;
}
if (options.json) {
output(result);
} else if (result.cycleCount === 0) {
output('No circular imports found.');
} else {
output(
result.cycles.map((cycle: { files: string[] }) => cycle.files.join(' -> ')).join('\n'),
);
}
if (result.cycleCount > 0) process.exitCode = 1;
} catch (error) {
output({ error: error instanceof Error ? error.message : String(error) });
process.exitCode = 1;
}
}
+4
View File
@@ -28,6 +28,7 @@ import { isHttpMode, getHttpDimensions, httpEmbed } from './http-client.js';
import { resolveEmbeddingConfig } from './config.js';
import { applyHfEnvOverrides, isHfDownloadFailure, withHfDownloadRetry } from './hf-env.js';
import { getLocalEmbeddingRuntimeBlocker } from './runtime-support.js';
import { ensureOnnxRuntimeCommonResolvable } from './onnxruntime-common-resolver.js';
import { logger } from '../logger.js';
/**
@@ -179,6 +180,9 @@ export const initEmbedder = async (
try {
// Lazy-load transformers.js only after the runtime guard has passed, so
// unsupported platforms never reach the native ONNX import (#1515).
// Under pnpm-strict / `pnpm dlx`, transformers' phantom `onnxruntime-common`
// import is unresolvable; register the fallback resolver first (#307).
ensureOnnxRuntimeCommonResolvable();
const { pipeline, env } = await import('@huggingface/transformers');
// Configure transformers.js environment
@@ -36,13 +36,8 @@ import {
} from './types.js';
import { resolveEmbeddingConfig } from './config.js';
import { rankExactEmbeddingRows, type ExactEmbeddingRow } from './exact-search.js';
import {
EMBEDDING_TABLE_NAME,
EMBEDDING_INDEX_NAME,
CREATE_VECTOR_INDEX_QUERY,
STALE_HASH_SENTINEL,
} from '../lbug/schema.js';
import { loadVectorExtension } from '../lbug/lbug-adapter.js';
import { EMBEDDING_TABLE_NAME, EMBEDDING_INDEX_NAME, STALE_HASH_SENTINEL } from '../lbug/schema.js';
import { loadVectorExtension, createVectorIndex } from '../lbug/lbug-adapter.js';
import type { ExtensionInstallPolicy } from '../lbug/extension-loader.js';
import { getExactScanLimit } from '../platform/capabilities.js';
import { logger } from '../logger.js';
@@ -215,24 +210,36 @@ export const batchInsertEmbeddings = async (
};
/**
* Create the vector index for semantic search
* Now indexes the separate CodeEmbedding table.
* Delegates extension loading to lbug-adapter's loadVectorExtension(),
* which owns the VECTOR extension lifecycle and state tracking.
* Create the vector index for semantic search (indexes the CodeEmbedding table).
*
* Keeps the embedding-specific extension-install policy gate here
* (ensureVectorExtensionAvailable → resolveEmbeddingInstallPolicy, default
* `auto` for the analyze write path), then delegates the actual
* `CALL CREATE_VECTOR_INDEX(...)` to the adapter, which runs it through the
* unprepared `conn.query()` path. It must NOT go through the injected
* `executeQuery` (prepared `conn.prepare()`): LadybugDB cannot prepare that
* procedure and fails with "We do not support prepare multiple statements" —
* the silent degrade in #2114.
*/
const createVectorIndex = async (
executeQuery: (cypher: string) => Promise<any[]>,
): Promise<boolean> => {
const buildVectorIndex = async (): Promise<boolean> => {
// This pre-check applies the embedding-specific install policy
// (resolveEmbeddingInstallPolicy, default `auto` for analyze) before reaching
// the adapter. The adapter's createVectorIndex() calls loadVectorExtension()
// again, but that's a no-op here: once this gate loads VECTOR the module-level
// `vectorExtensionLoaded` flag is set, so the adapter's second call
// short-circuits without re-resolving the policy — no double install.
if (!(await ensureVectorExtensionAvailable())) return false;
try {
await executeQuery(CREATE_VECTOR_INDEX_QUERY);
return true;
return await createVectorIndex();
} catch (error) {
if (isDev) {
logger.warn({ error }, 'Vector index creation warning:');
}
// Surface this even outside dev: it silently downgrades a user-requested
// feature (semantic search) to exact scan. Log under `err` so pino's
// standard serializer captures the message/stack — logging under `error`
// serialized an Error to `{}` (the empty `{"error":{}}` reported in #2114).
logger.warn(
{ err: error },
'Vector index creation failed; semantic search will use exact-scan fallback',
);
return false;
}
};
@@ -383,7 +390,7 @@ export const runEmbeddingPipeline = async (
// Ensure the vector index exists even when no new nodes need embedding.
// A prior crash or first-time incremental run may have left CodeEmbedding
// rows without ever reaching index creation.
const vectorIndexReady = await createVectorIndex(executeQuery);
const vectorIndexReady = await buildVectorIndex();
onProgress({
phase: 'ready',
@@ -544,7 +551,7 @@ export const runEmbeddingPipeline = async (
logger.info('📇 Creating vector index...');
}
const vectorIndexReady = await createVectorIndex(executeQuery);
const vectorIndexReady = await buildVectorIndex();
onProgress({
phase: 'ready',
@@ -0,0 +1,133 @@
/**
* Make `@huggingface/transformers`' phantom `onnxruntime-common` import
* resolvable under strict package-manager layouts (#307, #2069).
*
* ## Why
* transformers' shipped `dist/transformers.node.mjs` does a bare
* `import 'onnxruntime-common'`, but transformers' `package.json` never declares
* onnxruntime-common (it lists onnxruntime-node / onnxruntime-web / sharp). With
* npm's flat `node_modules` — or pnpm with hoisting — the package is hoisted to
* a directory on transformers' resolution path and the import resolves by
* accident. Under pnpm's isolated store (and therefore `pnpm dlx` / `pnpx`), a
* package only sees its *declared* deps, so the import dies with
* `ERR_MODULE_NOT_FOUND` before `analyze --embeddings` can run.
*
* Declaring onnxruntime-common in gitnexus' own dependencies (#2074) does NOT
* fix this under pnpm: Node resolves the bare specifier from *transformers'*
* module scope, not ours, and overrides/resolutions can only re-version an
* existing edge, never add the missing one.
*
* ## What this does
* Install a synchronous, in-thread ESM resolution hook (`module.registerHooks`,
* Node >= 22.15) that redirects `onnxruntime-common` to a copy gitnexus can
* resolve — but only when the default resolver fails. The redirect target is
* preferentially the `onnxruntime-common` that `onnxruntime-node` (the native
* binding transformers actually loads) itself depends on, so the redirected copy
* is version-matched to that binding even under `pnpm dlx` — where gitnexus'
* npm-style `overrides` block does NOT apply, because it is honoured only from a
* root manifest and gitnexus is a transitive dependency there. It falls back to
* gitnexus' own direct `onnxruntime-common` dependency when that chain can't be
* walked. onnxruntime-common is a stable, pure-JS package whose `Tensor` surface
* is unchanged across 1.24–1.26, so either target is API-compatible. On working
* layouts the default resolver succeeds first and the hook never fires, so
* behaviour is unchanged.
*
* `registerHooks` (synchronous, in-thread) is preferred over the older
* `module.register` (async, off-thread, now deprecated — DEP0205, removed in
* Node 26): the redirect is a one-line conditional that needs no worker thread,
* no separate hook module, and no `data` marshalling.
*
* ## Safety
* Best-effort and idempotent. The hook is installed lazily, only on the
* local-embedding code path (after parsing), so it is never registered during
* analysis, in the parse workers, or in HTTP embedding mode. Once installed it
* is process-global: its resolve closure runs for every subsequent module
* resolution, but it passes all of them through untouched and only substitutes a
* result for the exact `onnxruntime-common` specifier when that specifier is
* genuinely absent — so it cannot mask an unrelated resolution error, and the
* per-resolution cost is a single string comparison.
*
* `module.registerHooks` is marked `@experimental` and requires Node >= 22.15
* (the gitnexus engines floor is >= 22.0.0). On older runtimes it is absent and
* this is a graceful no-op: embeddings then resolve onnxruntime-common exactly
* as before — fine on hoisted layouts. Any failure during installation is
* swallowed.
*/
import { registerHooks, createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
import { logger } from '../logger.js';
let attempted = false;
/**
* Compute the file: URL the hook redirects `onnxruntime-common` to.
*
* Prefer the copy `onnxruntime-node` (the native binding transformers loads)
* depends on, so the redirected module is version-matched to the binding even
* under `pnpm dlx`, where transformers keeps its own pinned onnxruntime-node.
* The walk resolves transformers' MAIN entry — NOT `@huggingface/transformers/
* package.json`, which transformers' `exports` map blocks
* (`ERR_PACKAGE_PATH_NOT_EXPORTED`) — then onnxruntime-node, then its
* onnxruntime-common. Falls back to gitnexus' own direct dependency (always
* resolvable from our scope) when any step fails.
*/
const resolveOnnxRuntimeCommonUrl = (): string => {
const require = createRequire(import.meta.url);
try {
const transformersMain = require.resolve('@huggingface/transformers');
const ortNodePkg = createRequire(transformersMain).resolve('onnxruntime-node/package.json');
const common = createRequire(ortNodePkg).resolve('onnxruntime-common');
return pathToFileURL(common).href;
} catch {
return pathToFileURL(require.resolve('onnxruntime-common')).href;
}
};
/**
* Idempotently install the onnxruntime-common resolution fallback. Call once
* immediately before the dynamic `import('@huggingface/transformers')` on the
* local-embedding path.
*/
export const ensureOnnxRuntimeCommonResolvable = (): void => {
if (attempted) return;
// Mark attempted up-front: a failed attempt must not retry on every
// initEmbedder() call, and the hook is process-global — once is enough.
attempted = true;
try {
// Node < 22.15 (the gitnexus engines floor is >= 22.0.0): no synchronous
// hooks API. Degrade gracefully — the import still works on hoisted layouts.
if (typeof registerHooks !== 'function') return;
const redirectUrl = resolveOnnxRuntimeCommonUrl();
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier !== 'onnxruntime-common') return nextResolve(specifier, context);
// Honour a real, package-manager-provided copy when one is on the path
// (npm / hoisted pnpm); only substitute ours when the specifier is
// genuinely absent.
try {
return nextResolve(specifier, context);
} catch (err) {
// The phantom import surfaces as ERR_MODULE_NOT_FOUND (or, for a
// present-but-exports-broken copy, ERR_PACKAGE_PATH_NOT_EXPORTED).
// Rethrow anything else so a genuinely broken install is not masked.
const code = (err as { code?: string } | null | undefined)?.code;
if (code === 'ERR_MODULE_NOT_FOUND' || code === 'ERR_PACKAGE_PATH_NOT_EXPORTED') {
return { url: redirectUrl, shortCircuit: true };
}
throw err;
}
},
});
logger.debug({ redirectUrl }, 'Installed onnxruntime-common resolution fallback (#307)');
} catch (err) {
// Never block embeddings on the fallback. On layouts where the package
// manager already resolves onnxruntime-common this is unnecessary anyway.
logger.debug(
{ err: err instanceof Error ? err.message : String(err) },
'onnxruntime-common resolution fallback not installed',
);
}
};
+110
View File
@@ -0,0 +1,110 @@
interface ImportEdge {
source: string;
target: string;
}
function findCyclePath(component: string[], adjacency: Map<string, string[]>): string[] {
const allowed = new Set(component);
const start = component[0];
const parents = new Map<string, string | null>([[start, null]]);
const queue = [start];
for (let index = 0; index < queue.length; index += 1) {
const node = queue[index];
for (const next of adjacency.get(node) ?? []) {
if (!allowed.has(next)) continue;
if (next === start) {
const path: string[] = [];
let cursor: string | null = node;
while (cursor !== null) {
path.push(cursor);
cursor = parents.get(cursor) ?? null;
}
path.reverse();
return [...path, start];
}
if (parents.has(next)) continue;
parents.set(next, node);
queue.push(next);
}
}
throw new Error('Invariant violation: no cycle found through SCC root.');
}
/**
* Return one deterministic concrete cycle for every cyclic strongly connected
* component in the file import graph.
*/
export function findImportCycles(edges: ImportEdge[]): string[][] {
const adjacency = new Map<string, Set<string>>();
for (const { source, target } of edges) {
if (!source || !target) continue;
const targets = adjacency.get(source) ?? new Set<string>();
targets.add(target);
adjacency.set(source, targets);
if (!adjacency.has(target)) adjacency.set(target, new Set());
}
const sortedAdjacency = new Map(
[...adjacency].map(([node, targets]) => [node, [...targets].sort()] as const),
);
const reverseAdjacency = new Map<string, string[]>();
for (const node of sortedAdjacency.keys()) reverseAdjacency.set(node, []);
for (const [source, targets] of sortedAdjacency) {
for (const target of targets) reverseAdjacency.get(target)!.push(source);
}
for (const sources of reverseAdjacency.values()) sources.sort();
const visited = new Set<string>();
const finishOrder: string[] = [];
const components: string[][] = [];
for (const start of [...sortedAdjacency.keys()].sort()) {
if (visited.has(start)) continue;
visited.add(start);
const stack = [{ node: start, nextIndex: 0 }];
while (stack.length > 0) {
const frame = stack[stack.length - 1];
const neighbors = sortedAdjacency.get(frame.node) ?? [];
if (frame.nextIndex < neighbors.length) {
const next = neighbors[frame.nextIndex++];
if (!visited.has(next)) {
visited.add(next);
stack.push({ node: next, nextIndex: 0 });
}
} else {
finishOrder.push(frame.node);
stack.pop();
}
}
}
visited.clear();
for (let index = finishOrder.length - 1; index >= 0; index -= 1) {
const start = finishOrder[index];
if (visited.has(start)) continue;
const component: string[] = [];
const stack = [start];
visited.add(start);
while (stack.length > 0) {
const node = stack.pop()!;
component.push(node);
for (const next of reverseAdjacency.get(node) ?? []) {
if (visited.has(next)) continue;
visited.add(next);
stack.push(next);
}
}
component.sort();
components.push(component);
}
return components
.filter(
(component) =>
component.length > 1 || (sortedAdjacency.get(component[0]) ?? []).includes(component[0]),
)
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
.map((component) => findCyclePath(component, sortedAdjacency));
}
@@ -1,4 +1,5 @@
import { createRequire } from 'node:module';
import { requireVendoredGrammar } from '../../../tree-sitter/vendored-grammars.js';
import {
compilePatterns,
runCompiledPatterns,
@@ -10,11 +11,11 @@ import type { GrpcDetection, GrpcLanguagePlugin } from './types.js';
/**
* Protobuf (.proto) tree-sitter plugin for gRPC contract extraction.
*
* Uses `tree-sitter-proto` (coder3101/tree-sitter-proto) as an
* optionalDependency — if the grammar is not installed (e.g. native
* compilation failed on an unusual platform), the plugin exports
* `null` and the orchestrator falls back to the existing manual
* string-sanitizing parser.
* Uses `tree-sitter-proto` (coder3101/tree-sitter-proto), loaded from
* `vendor/` by absolute path (NEVER copied into node_modules — see
* vendored-grammars.ts / #2111). If the grammar's binding cannot be loaded
* (e.g. no prebuild for an unusual platform), the plugin exports `null` and the
* orchestrator falls back to the existing manual string-sanitizing parser.
*
* The grammar is vendored in `vendor/tree-sitter-proto/` with
* parser.c regenerated against tree-sitter-cli 0.24 (ABI version 14)
@@ -22,10 +23,13 @@ import type { GrpcDetection, GrpcLanguagePlugin } from './types.js';
* (which loads ABI 13–14).
*/
// Only for `tree-sitter` (a real npm dependency) in the smoke-test below;
// the vendored grammar goes through requireVendoredGrammar (never a bare
// `_require('tree-sitter-proto')`, which would force a node_modules copy — #2111).
const _require = createRequire(import.meta.url);
let ProtoGrammar: unknown = null;
try {
ProtoGrammar = _require('tree-sitter-proto');
ProtoGrammar = requireVendoredGrammar('tree-sitter-proto');
} catch {
// Grammar not installed — PROTO_GRPC_PLUGIN will be null.
}
@@ -6,6 +6,11 @@ import {
unquoteLiteral,
type LanguagePatterns,
} from '../tree-sitter-scanner.js';
import {
METHOD_ANNOTATION_TO_HTTP,
isRouteMemberKey,
findEnclosingClass,
} from '../../../ingestion/route-extractors/spring-shared.js';
import type {
HttpDetection,
HttpFileDetections,
@@ -33,14 +38,6 @@ import type {
* OkHttp, Java/Apache HttpClient) keep their own focused queries.
*/
const METHOD_ANNOTATION_TO_HTTP: Record<string, string> = {
GetMapping: 'GET',
PostMapping: 'POST',
PutMapping: 'PUT',
DeleteMapping: 'DELETE',
PatchMapping: 'PATCH',
};
// Each route-defining annotation has two AST shapes — a positional argument
// and a named one — that must both be matched:
// @RequestMapping("/api") → (annotation_argument_list (string_literal))
@@ -361,19 +358,9 @@ const APACHE_HTTP_CLIENT_PATTERNS = compilePatterns({
} satisfies LanguagePatterns<Record<string, never>>);
/**
* Find the nearest enclosing class/interface declaration ancestor for
* a node, or null if the node is top-level. Tree-sitter's
* SyntaxNode.parent walks one level at a time.
* Find the nearest enclosing interface declaration ancestor for a node, or
* null if the node is top-level.
*/
function findEnclosingClass(node: Parser.SyntaxNode): Parser.SyntaxNode | null {
let cur: Parser.SyntaxNode | null = node.parent;
while (cur) {
if (cur.type === 'class_declaration') return cur;
cur = cur.parent;
}
return null;
}
function findEnclosingInterface(node: Parser.SyntaxNode): Parser.SyntaxNode | null {
let cur: Parser.SyntaxNode | null = node.parent;
while (cur) {
@@ -439,18 +426,6 @@ function hasAnnotation(node: Parser.SyntaxNode, names: string | readonly string[
return false;
}
/**
* A named annotation argument contributes a route only when its member key is
* `path` or `value`; a positional argument (no key node) always qualifies.
* This is the JS-side replacement for the in-query `^(path|value)$` filter and
* drops Spring's non-route string attributes (`produces`, `consumes`,
* `headers`, `name`, `params`) that would otherwise be mis-read as routes.
*/
function isRouteMemberKey(keyNode: Parser.SyntaxNode | undefined): boolean {
if (!keyNode) return true;
return keyNode.text === 'path' || keyNode.text === 'value';
}
interface MethodRouteAnnotation {
methodNode: Parser.SyntaxNode;
methodName: string | null;
@@ -1,5 +1,5 @@
import Parser from 'tree-sitter';
import { createRequire } from 'node:module';
import { requireVendoredGrammar } from '../../../tree-sitter/vendored-grammars.js';
import {
compilePatterns,
runCompiledPatterns,
@@ -60,17 +60,16 @@ import type { HttpDetection, HttpLanguagePlugin } from './types.js';
* value_argument
* string_literal ← the path
*
* tree-sitter-kotlin is an optional npm dependency — when its native
* binding is unavailable the plugin gracefully exports `null` and
* `http-patterns/index.ts` skips registration for `.kt`/`.kts` files.
* tree-sitter-kotlin is a vendored grammar loaded from `vendor/` by absolute
* path (NEVER copied into node_modules — see vendored-grammars.ts / #2111) —
* when its native binding is unavailable the plugin gracefully exports `null`
* and `http-patterns/index.ts` skips registration for `.kt`/`.kts` files.
*/
const _require = createRequire(import.meta.url);
/** Loaded lazily; null when the grammar binding isn't installed. */
/** Loaded lazily; null when the grammar binding isn't available. */
let Kotlin: unknown | null = null;
try {
Kotlin = _require('tree-sitter-kotlin');
Kotlin = requireVendoredGrammar('tree-sitter-kotlin');
} catch {
Kotlin = null;
}
@@ -1,20 +1,20 @@
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import { createRequire } from 'node:module';
import { glob } from 'glob';
import Parser from 'tree-sitter';
import Cpp from 'tree-sitter-cpp';
import { requireVendoredGrammar } from '../../tree-sitter/vendored-grammars.js';
// `tree-sitter-c` is vendored prebuild-only (#2116) and may be absent on a
// toolchain-less / `--ignore-scripts` install. Load it via a guarded `_require`
// rather than a top-level `import C from 'tree-sitter-c'`, which would throw
// ERR_MODULE_NOT_FOUND at module-load and crash analyze (#2091/#2093). When the
// `tree-sitter-c` is vendored (#2116), loaded from `vendor/` by absolute path
// (NEVER copied into node_modules — see vendored-grammars.ts / #2111). Load it
// via a guarded call rather than a top-level `import C from 'tree-sitter-c'`,
// which would throw ERR_MODULE_NOT_FOUND at module-load and crash analyze
// (#2091/#2093). It may be absent on a platform without a prebuild; when the
// binding is absent, `getLanguageForFile` returns null for `.c`/`.h` so C
// include-extraction is skipped (C++ is unaffected — its binding always ships).
const _require = createRequire(import.meta.url);
let C: unknown = null;
try {
C = _require('tree-sitter-c');
C = requireVendoredGrammar('tree-sitter-c');
} catch {
/* C grammar unavailable — C include extraction degrades to a no-op. */
}
@@ -0,0 +1,175 @@
/**
* CfgBuilder (issue #2081, M1) — the language-agnostic accumulator.
*
* A per-language `CfgVisitor` drives this: it creates blocks as it walks
* statements, wires edges (including back-edges and break/continue/return/throw
* targets resolved via {@link ControlFlowContext}), and calls {@link finish} to
* produce the serializable {@link FunctionCfg}. The builder owns the synthetic
* ENTRY (index 0) and EXIT blocks and de-duplicates identical edges so repeated
* `connect` calls (common when wiring a set of dangling exits) stay idempotent.
*
* It has no knowledge of any AST — it is exercised directly in unit tests with
* hand-built block sequences, which is how the classic CFG hazards are pinned
* before the tree-sitter visitor (U2) drives it.
*/
import type {
BasicBlockData,
BindingEntry,
CfgEdgeData,
CfgEdgeKind,
FunctionCfg,
StatementFacts,
} from './types.js';
interface MutableBlock {
startLine: number;
endLine: number;
/**
* Block source accumulated as fragments, joined once in {@link finish}. A
* coalescing straight-line run appends one fragment per statement; storing
* them as an array and joining at the end keeps that O(n) instead of the
* O(n²) of repeatedly concatenating onto a growing string (a long generated
* init function is the worst case — see bench/cfg).
*/
textParts: string[];
kind: BasicBlockData['kind'];
/**
* Per-statement def/use facts in execution order (#2082 M2 U1). Parallel to
* the statements that accrued to this block — but self-describing (each
* record carries its line): facts-only attaches (ENTRY params, catch params)
* mean fact index ≠ text-fragment index.
*/
statements: StatementFacts[];
}
export class CfgBuilder {
private readonly blocks: MutableBlock[] = [];
private readonly edges: CfgEdgeData[] = [];
private readonly edgeKeys = new Set<string>();
readonly entryIndex: number;
readonly exitIndex: number;
constructor(
private readonly filePath: string,
private readonly functionStartLine: number,
private readonly functionEndLine: number,
/** Start column of the owning function — disambiguates same-line functions
* in the BasicBlock ids (see {@link FunctionCfg.functionStartColumn}).
* Defaults to 0 for hand-built test CFGs that don't model columns. */
private readonly functionStartColumn: number = 0,
) {
this.entryIndex = this.newBlock(functionStartLine, functionStartLine, '', 'entry');
this.exitIndex = this.newBlock(functionEndLine, functionEndLine, '', 'exit');
}
/** Create a block and return its index. */
newBlock(
startLine: number,
endLine: number,
text: string,
kind: BasicBlockData['kind'] = 'normal',
facts?: StatementFacts,
): number {
this.blocks.push({
startLine,
endLine,
textParts: text ? [text] : [],
kind,
statements: facts ? [facts] : [],
});
return this.blocks.length - 1;
}
/** Add a single edge (idempotent on from+to+kind). */
edge(from: number, to: number, kind: CfgEdgeKind): void {
const key = `${from}->${to}:${kind}`;
if (this.edgeKeys.has(key)) return;
this.edgeKeys.add(key);
this.edges.push({ from, to, kind });
}
/** Wire a set of dangling exits to a single target block with one kind. */
connect(exits: readonly number[], to: number, kind: CfgEdgeKind = 'seq'): void {
for (const from of exits) this.edge(from, to, kind);
}
/** Extend a block's end line as more statements accrue to it. */
extendBlock(index: number, endLine: number, appendText?: string, facts?: StatementFacts): void {
const b = this.blocks[index];
if (!b) return;
if (endLine > b.endLine) b.endLine = endLine;
if (appendText) b.textParts.push(appendText);
if (facts) b.statements.push(facts);
}
/**
* Attach a facts-only statement record to a block WITHOUT touching its text
* or line span (#2082 M2 U1) — bench fingerprints and CFG snapshots include
* block text, so harvesting must never perturb it (ENTRY-block param defs
* are the canonical use; records that must precede a walked body get their
* own facts-only block instead, see the catch-param handling in visitTry).
*/
attachFacts(index: number, facts: StatementFacts): void {
const b = this.blocks[index];
if (!b) return;
b.statements.push(facts);
}
get blockCount(): number {
return this.blocks.length;
}
/** Produce the serializable CFG. Caller is responsible for having wired the
* function's dangling exits to {@link exitIndex} before calling.
*
* Pass `bindings` (the function's binding table, possibly empty) to emit
* statement facts (#2082 M2 U1) — every block then carries a `statements`
* array. Omit it (hand-built test CFGs, pre-M2 producers) and both fields
* are absent, which the reaching-defs solver reports as `no-facts`. */
finish(bindings?: readonly BindingEntry[]): FunctionCfg {
const withFacts = bindings !== undefined;
return {
filePath: this.filePath,
functionStartLine: this.functionStartLine,
functionEndLine: this.functionEndLine,
functionStartColumn: this.functionStartColumn,
entryIndex: this.entryIndex,
exitIndex: this.exitIndex,
blocks: this.blocks.map((b, index) => ({
index,
startLine: b.startLine,
endLine: b.endLine,
text: b.textParts.join('\n'),
kind: b.kind,
...(withFacts ? { statements: b.statements } : {}),
})),
edges: [...this.edges],
...(withFacts ? { bindings } : {}),
};
}
}
/**
* Block indices reachable from `entryIndex` by following edges. Backs the
* reachability property tests (R9) over hand-built and visitor-produced CFGs.
*/
export const reachableBlocks = (cfg: FunctionCfg): Set<number> => {
const adj = new Map<number, number[]>();
for (const e of cfg.edges) {
const list = adj.get(e.from);
if (list) list.push(e.to);
else adj.set(e.from, [e.to]);
}
const seen = new Set<number>([cfg.entryIndex]);
const stack = [cfg.entryIndex];
while (stack.length) {
const n = stack.pop() as number;
for (const next of adj.get(n) ?? []) {
if (!seen.has(next)) {
seen.add(next);
stack.push(next);
}
}
}
return seen;
};
@@ -0,0 +1,63 @@
/**
* collectFunctionCfgs (issue #2081, M1).
*
* Walks a parsed file's tree-sitter tree and builds one {@link FunctionCfg} per
* CFG-bearing function via the language's {@link CfgVisitor}. Runs IN THE PARSE
* WORKER (where the AST lives — KTD1/KTD7); the result rides on
* `ParsedFile.cfgSideChannel` across the worker→main boundary.
*
* Nested functions are enumerated independently — each gets its own CFG, and
* appears as an opaque straight-line block in its enclosing function's CFG (the
* visitor does not descend into nested function bodies). `maxFunctionLines`
* bounds per-function cost: a function whose source span exceeds the cap is
* skipped (and counted) rather than walked, so a pathological mega-function
* cannot blow up worker time/memory. A cap of `0` means no limit.
*/
import type { SyntaxNode } from '../utils/ast-helpers.js';
import type { CfgVisitor, FunctionCfg } from './types.js';
/**
* Default per-function source-line cap used by the worker when the `--pdg` run
* does not specify `pdgMaxFunctionLines`. A function longer than this (almost
* always minified/generated code) is skipped rather than walked — its CFG is
* both expensive and low-value. Overridable via `PipelineOptions.pdgMaxFunctionLines`.
*/
export const DEFAULT_PDG_MAX_FUNCTION_LINES = 2000;
export interface CollectedCfgs {
readonly cfgs: readonly FunctionCfg[];
/** Functions skipped for exceeding `maxFunctionLines` (0 ⇒ none skipped). */
readonly skipped: number;
}
export function collectFunctionCfgs(
root: SyntaxNode,
visitor: CfgVisitor<SyntaxNode>,
filePath: string,
maxFunctionLines = 0,
): CollectedCfgs {
const cfgs: FunctionCfg[] = [];
let skipped = 0;
const stack: SyntaxNode[] = [root];
while (stack.length) {
const node = stack.pop() as SyntaxNode;
if (visitor.isFunction(node)) {
const lines = node.endPosition.row - node.startPosition.row + 1;
if (maxFunctionLines > 0 && lines > maxFunctionLines) {
skipped++;
} else {
const cfg = visitor.buildFunctionCfg(node, filePath);
if (cfg) cfgs.push(cfg);
}
}
// Descend regardless (a skipped mega-function may still contain small
// nested functions that are worth a CFG of their own).
for (let i = node.namedChildCount - 1; i >= 0; i--) {
const child = node.namedChild(i);
if (child) stack.push(child);
}
}
return { cfgs, skipped };
}
@@ -0,0 +1,216 @@
/**
* ControlFlowContext (issue #2081 M1; finalizer frames added by #2082 M2 U2).
*
* Resolves the targets of `break`/`continue` (plain and labeled) as the visitor
* descends through loops and switches. Loops and switches push a target frame
* on entry and pop it on exit; a labeled statement attaches its label to the
* frame of the construct it labels, so `break outer` / `continue outer` resolve
* against the right enclosing loop/switch rather than the nearest one.
*
* M2 adds FINALIZER frames, interleaved on the SAME stack as loop/switch frames
* — interleaving is load-bearing: a jump must route through exactly the
* `finally` bodies lexically BETWEEN it and its target (target-relative
* threading). A `break` whose loop lives entirely inside the `try` crosses no
* finally and must keep its direct edge; re-routing it anyway would force the
* only path to the in-try continuation through the finally, letting a finally
* redefinition falsely KILL in-loop definitions for the downstream
* reaching-defs pass (a taint false negative). A parallel stack cannot express
* that between-ness, which is why the frames live here.
*/
import type { CfgBuilder } from './cfg-builder.js';
import type { CfgEdgeKind } from './types.js';
interface LoopFrame {
readonly kind: 'loop';
/** Block a `continue` jumps to (the loop header / update). */
readonly continueTo: number;
/** Block a `break` jumps to (the loop exit / join). */
readonly breakTo: number;
/** All labels naming this construct (`outer: inner: for` carries both). */
readonly labels: readonly string[];
}
interface SwitchFrame {
readonly kind: 'switch';
/** Block a `break` jumps to (after the switch). `continue` is invalid here. */
readonly breakTo: number;
readonly labels: readonly string[];
}
/**
* A labeled NON-loop statement (`blk: { … break blk; … }`) — break-to-label
* targets the synthesized join after the body (tri-review P1: routing such a
* break to EXIT removed the real continuation and falsely killed every def
* live at the jump for post-construct uses). Matched ONLY by a labeled break
* naming it; unlabeled breaks and continues skip it.
*/
interface BlockFrame {
readonly kind: 'block';
readonly breakTo: number;
readonly labels: readonly string[];
}
/** A `finally` whose body any crossing jump must route through. */
export interface FinalizerFrame {
readonly kind: 'finalizer';
/** Entry block of the finally body. */
readonly entry: number;
/**
* Completion legs registered by jumps that crossed this finally: once the
* owning try pops the frame, it wires `finally-exits → to` with `kind` for
* each entry. Mutated by the jump handlers via {@link ControlFlowContext}.
*/
readonly pending: { to: number; kind: CfgEdgeKind }[];
}
type Frame = LoopFrame | SwitchFrame | BlockFrame | FinalizerFrame;
type TargetFrame = LoopFrame | SwitchFrame | BlockFrame;
/** A resolved jump: its ultimate target + the finallys it crosses (inner→outer). */
export interface JumpResolution {
readonly target: number;
readonly finalizers: readonly FinalizerFrame[];
}
export class ControlFlowContext {
private readonly stack: Frame[] = [];
pushLoop(continueTo: number, breakTo: number, labels: readonly string[] = []): void {
this.stack.push({ kind: 'loop', continueTo, breakTo, labels });
}
pushSwitch(breakTo: number, labels: readonly string[] = []): void {
this.stack.push({ kind: 'switch', breakTo, labels });
}
/** Push a labeled non-loop statement's break-target frame. */
pushLabeledBlock(breakTo: number, labels: readonly string[]): void {
this.stack.push({ kind: 'block', breakTo, labels });
}
/**
* Push a finalizer frame and return it — the owning `visitTry` keeps the
* reference to wire {@link FinalizerFrame.pending} after popping it.
*/
pushFinalizer(entry: number): FinalizerFrame {
const frame: FinalizerFrame = { kind: 'finalizer', entry, pending: [] };
this.stack.push(frame);
return frame;
}
pop(): void {
this.stack.pop();
}
/**
* Resolve a `break`: the nearest enclosing loop/switch frame (or, with a
* label, the nearest frame carrying that label) plus every finalizer frame
* stacked ABOVE it — i.e. exactly the finallys the jump crosses, innermost
* first. Returns `undefined` if there is no valid target (malformed input or
* an unmodeled label) — the caller falls back to its conservative routing and
* threads nothing.
*/
resolveBreak(label?: string): JumpResolution | undefined {
return this.resolve((f) =>
label === undefined
? f.kind !== 'block' // an unlabeled break never targets a labeled block
: f.labels.includes(label),
);
}
/** Resolve a `continue`: like {@link resolveBreak} but only loop frames match. */
resolveContinue(label?: string): JumpResolution | undefined {
return this.resolve(
(f) => f.kind === 'loop' && (label === undefined || f.labels.includes(label)),
(f) => (f as LoopFrame).continueTo,
);
}
/** Every active finalizer, innermost first — what a `return` must cross. */
finalizersForReturn(): readonly FinalizerFrame[] {
const fins: FinalizerFrame[] = [];
for (let i = this.stack.length - 1; i >= 0; i--) {
const f = this.stack[i];
if (f.kind === 'finalizer') fins.push(f);
}
return fins;
}
/**
* Target block for a `break` (no finalizer info) — see {@link resolveBreak}.
* Prefer `resolveBreak` + {@link wireJumpThroughFinalizers} in visitors: a
* target-only lookup silently loses finalizer threading (the M2 soundness
* fix). Kept for target-shape assertions in tests.
*/
breakTarget(label?: string): number | undefined {
return this.resolveBreak(label)?.target;
}
/** Target block for a `continue` — same caveat as {@link breakTarget}. */
continueTarget(label?: string): number | undefined {
return this.resolveContinue(label)?.target;
}
private resolve(
matches: (f: TargetFrame) => boolean,
targetOf: (f: TargetFrame) => number = (f) => f.breakTo,
): JumpResolution | undefined {
const crossed: FinalizerFrame[] = [];
for (let i = this.stack.length - 1; i >= 0; i--) {
const f = this.stack[i];
if (f.kind === 'finalizer') {
crossed.push(f);
continue;
}
if (matches(f)) return { target: targetOf(f), finalizers: crossed };
}
return undefined;
}
}
/**
* Wire a jump from `from` to `target`, routing through the finallys it
* crosses (innermost first). The first leg keeps the bare jump `kind`
* (preserving the "kind ⟹ source-block terminator" invariant in types.ts);
* each finally's completion leg is registered as pending on its frame with the
* matching `finally-*` kind and wired by the owning try via
* {@link drainFinalizerPending} once the finally's exits are known.
*
* Language-agnostic on purpose (#2082 M2): the threading protocol encodes
* three subtle invariants every future language visitor needs identically —
* keeping it here means a new visitor cannot drift on any of them.
*/
export function wireJumpThroughFinalizers(
builder: CfgBuilder,
from: number,
finalizers: readonly FinalizerFrame[],
target: number,
kind: 'return' | 'break' | 'continue',
): void {
if (finalizers.length === 0) {
builder.edge(from, target, kind);
return;
}
const completionKind = `finally-${kind}` as CfgEdgeKind;
builder.edge(from, finalizers[0].entry, kind);
for (let i = 0; i < finalizers.length; i++) {
const to = i + 1 < finalizers.length ? finalizers[i + 1].entry : target;
finalizers[i].pending.push({ to, kind: completionKind });
}
}
/**
* Wire a popped finalizer frame's pending completion legs from the finally's
* exit blocks. A finally that itself always jumps (`finally { return 2; }`)
* has no exits — its pending legs wire nowhere, matching JS's
* finally-override semantics.
*/
export function drainFinalizerPending(
builder: CfgBuilder,
frame: FinalizerFrame,
finallyExits: readonly number[],
): void {
for (const p of frame.pending) {
builder.connect(finallyExits, p.to, p.kind);
}
}
+412
View File
@@ -0,0 +1,412 @@
/**
* cfg/emit.ts (issue #2081, M1) — serialized side-channel → graph.
*
* Pure helper: given a file's per-function CFGs (off `ParsedFile.cfgSideChannel`,
* produced by the worker in U3), emit one persisted `BasicBlock` node per block
* and one `CFG` edge per edge into the {@link KnowledgeGraph}. Invoked from
* scope-resolution (run.ts Phase 4) while the disk-backed ParsedFile store is
* still live — the only window where the worker-built CFGs are loaded (KTD1/
* KTD5). Default (`--pdg` off) runs never call this, so the emitted graph stays
* byte-identical to a pre-#2081 run.
*
* BasicBlock id: `BasicBlock:<filePath>:<functionStartLine>:<functionStartColumn>:<blockIndex>`
* (KTD3). The function start line+column segments disambiguate blocks across
* multiple functions in one file — including same-line functions — since each
* function's block indices restart at 0; blocks carry no `name` (the
* BasicBlock table has no such column). The edge KIND
* (`seq`/`cond-true`/…) rides in the relationship `reason` — CFG edges are
* values of the single `CodeRelation` table's `type` column (`'CFG'`), so the
* kind cannot be its own edge type and is queried via `reason`.
*/
import type { KnowledgeGraph } from '../../graph/types.js';
import { generateId } from '../../../lib/utils.js';
import { computeReachingDefs } from './reaching-defs.js';
import type { BindingEntry, FunctionCfg } from './types.js';
/**
* Default per-function CFG edge cap. A pathological generated function could
* otherwise emit an unbounded edge set; the cap bounds graph growth and is
* overridable via `--pdg` options. `0` (in options) means no cap (unlimited
* — see the `cap` mapping in {@link emitFileCfgs}); `undefined` means this
* default.
*/
export const DEFAULT_MAX_CFG_EDGES_PER_FUNCTION = 5000;
/**
* Default per-function REACHING_DEF edge cap (#2082 M2 KTD9). 4000 mirrors
* Joern's per-method `maxNumberOfDefinitions` — the closest production prior
* art — but truncates-and-warns instead of silently skipping the function.
* Counts (defBlock, useBlock, binding) DEDUPED edges, not statement-level
* facts. `0` ⇒ unlimited; `undefined` ⇒ this default.
*/
export const DEFAULT_PDG_MAX_REACHING_DEF_EDGES_PER_FUNCTION = 4000;
/**
* Fact-materialization headroom over the edge cap (#2082 M2 U3/F3): facts are
* O(defs×uses) BY SPEC in merge-heavy code, and the edge cap alone bounds the
* GRAPH, not the per-function memory spike of materializing facts before
* dedup. {@link emitFileReachingDefs} hands `edgeCap × this` to
* `computeReachingDefs` as `maxFacts` (unlimited when the edge cap is 0) —
* single source of truth; the DEFAULT constant below is derived, never the
* mechanism.
*/
export const REACHING_DEF_FACTS_PER_EDGE_CAP = 4;
/** Derived emit-path fact limit at the default edge cap (bench/doc anchor). */
export const DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION =
REACHING_DEF_FACTS_PER_EDGE_CAP * DEFAULT_PDG_MAX_REACHING_DEF_EDGES_PER_FUNCTION;
export interface CfgEmitResult {
blocks: number;
edges: number;
/** Edges dropped because a function's edge count exceeded the cap. */
droppedEdges: number;
/** Number of functions that hit the cap. */
cappedFunctions: number;
}
const basicBlockId = (
filePath: string,
functionStartLine: number,
functionStartColumn: number,
blockIndex: number,
): string => `BasicBlock:${filePath}:${functionStartLine}:${functionStartColumn}:${blockIndex}`;
/**
* Whether an untrusted `cfgSideChannel` element is safe to feed to
* {@link emitFileCfgs}. Deliberately NOT full FunctionCfg validation — it
* checks exactly the fields whose corruption is SILENT given emit's
* mechanics: {@link basicBlockId} string-templates every id-anchor value
* (filePath, function start line/column, block index, edge endpoints) and
* the graph's addNode/addRelationship are no-throw Map inserts. Unchecked,
* a missing anchor field cross-wires same-`undefined`-id blocks across
* functions (addNode is first-writer-wins), and an edge endpoint that
* matches no block index becomes a dangling `BasicBlock:…:<n>` edge that
* detonates much later at DB bulk-load instead of throwing here — so
* endpoints are checked for MEMBERSHIP in the block-index set, not just
* integer-ness. Lives in this module so the guard evolves with the id
* templating it defends (#2099 F4; M2 fields that join the id path must
* join this check).
*/
export const isEmitSafeCfg = (cfg: FunctionCfg | undefined | null): cfg is FunctionCfg => {
if (
typeof cfg?.filePath !== 'string' ||
!Number.isInteger(cfg.functionStartLine) ||
!Number.isInteger(cfg.functionStartColumn) ||
!Array.isArray(cfg.blocks) ||
!Array.isArray(cfg.edges)
) {
return false;
}
// Contiguity (index === position), not just integer-ness: every consumer —
// this module's id templating AND the reaching-defs solver's
// position-indexed adjacency arrays — assumes blocks[i].index === i. A
// membership-only check would admit a compacted channel ({index:0},{index:5})
// whose edge 0→5 passes membership but indexes past the arrays downstream.
for (let i = 0; i < cfg.blocks.length; i++) {
if (cfg.blocks[i]?.index !== i) return false;
}
const n = cfg.blocks.length;
// entry/exit must land on real blocks — the solver feeds entryIndex straight
// into its RPO walk, where an out-of-range index throws and (worse than this
// one element) costs the whole FILE's REACHING_DEF pass (tri-review P3).
if (
!Number.isInteger(cfg.entryIndex) ||
cfg.entryIndex < 0 ||
cfg.entryIndex >= n ||
!Number.isInteger(cfg.exitIndex) ||
cfg.exitIndex < 0 ||
cfg.exitIndex >= n
) {
return false;
}
return cfg.edges.every(
(e) =>
Number.isInteger(e?.from) &&
Number.isInteger(e?.to) &&
e.from >= 0 &&
e.from < n &&
e.to >= 0 &&
e.to < n,
);
};
/**
* Whether a structurally-valid CFG's M2 statement facts are safe to feed to
* the reaching-defs solver + REACHING_DEF id templating (#2082 U1/U4): the
* binding table's name/declLine/declColumn template into edge ids, and
* statement def/use indices must stay IN RANGE of the table (an escaping
* index would fabricate `undefined`-keyed ids). Deliberately SEPARATE from
* {@link isEmitSafeCfg}: malformed facts must cost only the function's
* REACHING_DEF projection — degrading to M1 behavior (CFG emitted, no facts)
* — never the BasicBlock/CFG layer itself.
*/
export const hasEmitSafeFacts = (cfg: FunctionCfg): boolean => {
const bindings = cfg.bindings;
if (bindings === undefined) {
// Pre-M2 channel — statements must be absent too.
return cfg.blocks.every((b) => b.statements === undefined);
}
if (!Array.isArray(bindings)) return false;
for (const b of bindings) {
if (
typeof b?.name !== 'string' ||
!Number.isInteger(b.declLine) ||
!Number.isInteger(b.declColumn)
) {
return false;
}
}
const bindingCount = bindings.length;
const inRange = (i: number): boolean => Number.isInteger(i) && i >= 0 && i < bindingCount;
for (const b of cfg.blocks) {
const stmts = b.statements;
if (stmts === undefined) continue;
if (!Array.isArray(stmts)) return false;
for (const s of stmts) {
if (!Number.isInteger(s?.line) || !Array.isArray(s.defs) || !Array.isArray(s.uses)) {
return false;
}
if (!s.defs.every(inRange) || !s.uses.every(inRange)) return false;
if (s.mayDefs !== undefined) {
if (!Array.isArray(s.mayDefs) || !s.mayDefs.every(inRange)) return false;
}
}
}
return true;
};
/**
* Emit BasicBlock nodes + CFG edges for every function CFG in `cfgs`.
*
* `maxEdgesPerFunction` caps edges per function. On overflow we stop emitting
* that function's remaining edges and call `onWarn` naming the dropped count —
* no silent truncation (KTD6/R6). Block nodes are always fully emitted (their
* count is bounded by the function's statement count); only edges are capped.
*/
export function emitFileCfgs(
graph: KnowledgeGraph,
cfgs: readonly FunctionCfg[],
maxEdgesPerFunction: number = DEFAULT_MAX_CFG_EDGES_PER_FUNCTION,
onWarn?: (message: string) => void,
): CfgEmitResult {
const result: CfgEmitResult = { blocks: 0, edges: 0, droppedEdges: 0, cappedFunctions: 0 };
const cap = maxEdgesPerFunction > 0 ? maxEdgesPerFunction : Infinity;
for (const cfg of cfgs) {
const { filePath, functionStartLine, functionStartColumn } = cfg;
for (const b of cfg.blocks) {
graph.addNode({
id: basicBlockId(filePath, functionStartLine, functionStartColumn, b.index),
label: 'BasicBlock',
properties: {
name: '', // BasicBlock has no name column; identified by id + span
filePath,
startLine: b.startLine,
endLine: b.endLine,
text: b.text,
},
});
result.blocks++;
}
let emittedForFn = 0;
for (const e of cfg.edges) {
if (emittedForFn >= cap) {
const dropped = cfg.edges.length - emittedForFn;
result.droppedEdges += dropped;
result.cappedFunctions++;
onWarn?.(
`[cfg] ${filePath}:${functionStartLine}: per-function CFG edge cap ` +
`(${maxEdgesPerFunction}) reached — dropped ${dropped} of ${cfg.edges.length} edges`,
);
break;
}
const sourceId = basicBlockId(filePath, functionStartLine, functionStartColumn, e.from);
const targetId = basicBlockId(filePath, functionStartLine, functionStartColumn, e.to);
graph.addRelationship({
id: generateId('CFG', `${sourceId}->${targetId}:${e.kind}`),
type: 'CFG',
sourceId,
targetId,
confidence: 1.0,
reason: e.kind, // CfgEdgeKind (seq/cond-true/loop-back/…) — queryable
});
result.edges++;
emittedForFn++;
}
}
return result;
}
export interface ReachingDefEmitResult {
/** Deduped (defBlock, useBlock, binding) edges persisted. */
edges: number;
/** Deduped edges dropped by the per-function edge cap. */
droppedEdges: number;
cappedFunctions: number;
/** Functions whose FACT materialization hit the solver's maxFacts limit. */
truncatedFunctions: number;
/** Functions whose facts failed {@link hasEmitSafeFacts} (CFG kept, facts skipped). */
malformedFactFunctions: number;
/** Total statement-level facts the solver produced (pre-dedup telemetry). */
facts: number;
}
/**
* Stable identity for a binding inside edge ids (#2082 M2 KTD3/KTD9):
* `name:declLine:declCol` for declared bindings, `name@module` for synthetic
* ones. Distinct same-name bindings never share a key; identifier characters
* cannot contain the id separators.
*/
const bindingKey = (b: BindingEntry): string =>
b.synthetic ? `${b.name}@module` : `${b.name}:${b.declLine}:${b.declColumn}`;
/**
* Compute reaching definitions per function and persist the bounded
* REACHING_DEF projection (#2082 M2 U4).
*
* Facts are DEDUPED to (defBlock, useBlock, binding) before budgeting — the
* persisted columns (`from,to,type,confidence,reason,step`; relationship ids
* are in-memory-only, the CodeRelation table has no id column) cannot
* distinguish finer rows, so statement-indexed ids would only manufacture
* byte-identical duplicate rows that burn budget. Statement granularity lives
* in the in-memory {@link computeReachingDefs} result, which the M3 taint
* engine recomputes on demand — the budget here governs only this projection
* and can never drop a taint fact.
*
* R7 (no silent truncation) covers BOTH layers: the per-function edge cap AND
* the solver's fact-materialization limit (which can fire without the edge
* cap ever being reached, since dedup is many-to-one) each produce one
* unconditional `onWarn`. The edge-cap warn names the top bindings by fact
* count — overflow is almost always one variable, which is exactly the datum
* M3 tuning wants.
*/
export function emitFileReachingDefs(
graph: KnowledgeGraph,
cfgs: readonly FunctionCfg[],
maxEdgesPerFunction: number = DEFAULT_PDG_MAX_REACHING_DEF_EDGES_PER_FUNCTION,
onWarn?: (message: string) => void,
): ReachingDefEmitResult {
const result: ReachingDefEmitResult = {
edges: 0,
droppedEdges: 0,
cappedFunctions: 0,
truncatedFunctions: 0,
malformedFactFunctions: 0,
facts: 0,
};
const cap = maxEdgesPerFunction > 0 ? maxEdgesPerFunction : Infinity;
const maxFacts = Number.isFinite(cap) ? (cap as number) * REACHING_DEF_FACTS_PER_EDGE_CAP : 0; // 0 ⇒ unlimited
for (const cfg of cfgs) {
// Graceful degradation: malformed M2 facts cost only this function's
// REACHING_DEF projection — its BasicBlock/CFG layer was already emitted.
if (!hasEmitSafeFacts(cfg)) {
result.malformedFactFunctions++;
onWarn?.(
`[reaching-defs] ${cfg.filePath}:${cfg.functionStartLine}: malformed ` +
`statement facts (bad binding table or out-of-range fact indices) — ` +
`REACHING_DEF skipped for this function; its CFG is unaffected`,
);
continue;
}
const r = computeReachingDefs(cfg, { maxFacts });
if (r.status === 'no-facts') continue;
result.facts += r.facts.length;
const { filePath, functionStartLine, functionStartColumn } = cfg;
if (r.status === 'truncated') {
result.truncatedFunctions++;
onWarn?.(
`[reaching-defs] ${filePath}:${functionStartLine}: fact materialization ` +
`limit (${maxFacts}) reached — facts beyond it were not computed; ` +
`the persisted REACHING_DEF projection for this function is sparse`,
);
} else if (r.status === 'overflow') {
result.truncatedFunctions++;
onWarn?.(
`[reaching-defs] ${filePath}:${functionStartLine}: a basic block exceeds ` +
`the def-key stride (≥2^21 coalesced statements — minified/generated ` +
`code) — REACHING_DEF skipped for this function (computing any facts ` +
`would risk wrong-block aliasing); its CFG is unaffected`,
);
continue;
}
// Dedup to (defBlock, useBlock, binding) — facts arrive sorted, so the
// deduped order (and therefore cap truncation) is deterministic.
const seen = new Set<string>();
const deduped: { defBlock: number; useBlock: number; bindingIdx: number }[] = [];
for (const f of r.facts) {
const key = `${f.def.blockIndex}:${f.use.blockIndex}:${f.bindingIdx}`;
if (seen.has(key)) continue;
seen.add(key);
deduped.push({
defBlock: f.def.blockIndex,
useBlock: f.use.blockIndex,
bindingIdx: f.bindingIdx,
});
}
let emittedForFn = 0;
for (const edge of deduped) {
if (emittedForFn >= cap) {
const dropped = deduped.length - emittedForFn;
result.droppedEdges += dropped;
result.cappedFunctions++;
// Tallied lazily — cap overflow is the rare path; the common uncapped
// case must not pay a per-fact counting pass.
const factsPerBinding = new Map<number, number>();
for (const f of r.facts) {
factsPerBinding.set(f.bindingIdx, (factsPerBinding.get(f.bindingIdx) ?? 0) + 1);
}
const top = [...factsPerBinding.entries()]
.sort((a, b) => b[1] - a[1] || a[0] - b[0])
.slice(0, 2)
.map(([idx, count]) => `${r.bindings[idx]?.name ?? `#${idx}`}(${count} facts)`)
.join(', ');
onWarn?.(
`[reaching-defs] ${filePath}:${functionStartLine}: per-function ` +
`REACHING_DEF edge cap (${maxEdgesPerFunction}) reached — dropped ` +
`${dropped} of ${deduped.length} edges; top bindings: ${top}`,
);
break;
}
const binding = r.bindings[edge.bindingIdx];
const sourceId = basicBlockId(
filePath,
functionStartLine,
functionStartColumn,
edge.defBlock,
);
const targetId = basicBlockId(
filePath,
functionStartLine,
functionStartColumn,
edge.useBlock,
);
graph.addRelationship({
// Single function anchor — the two block ids share it, so templating
// it once halves the id size (ids are in-memory-only but ~4000 of
// them per capped function is real transient heap).
id: generateId(
'REACHING_DEF',
`${filePath}:${functionStartLine}:${functionStartColumn}:` +
`${edge.defBlock}->${edge.useBlock}:${bindingKey(binding)}`,
),
type: 'REACHING_DEF',
sourceId,
targetId,
confidence: 1.0,
reason: binding.name, // plain source-level name (M0/S1 verdict) — queryable
});
result.edges++;
emittedForFn++;
}
}
return result;
}
@@ -0,0 +1,448 @@
/**
* Reaching definitions (#2082 M2 U3) — classic GEN/KILL monotone fixpoint over
* one function's CFG, plus the canonical intra-block statement sweep that
* recovers statement-granular def→use facts from M1's coalesced blocks
* WITHOUT re-splitting the CFG.
*
* PURE AND DETERMINISTIC (load-bearing contract):
* - Pure function of its inputs — no graph, no logger (warnings are the
* caller's job), importable outside the worker. The M3 taint engine calls
* this same function in-phase (facts are recomputed on demand, never
* retained run-wide — the persisted REACHING_DEF edges are a bounded
* projection, never the taint substrate).
* - Deterministic — predecessors merge in sorted block-index order,
* insertion-ordered Maps/Sets throughout, and the output fact array is
* explicitly sorted. Snapshot tests and content-derived edge ids rely on it.
*
* COMPLEXITY DISCIPLINE (the four-times-repeated repo bug shape is per-item
* re-derivation inside the loop): def-sets are SHARED BY REFERENCE, never
* deep-copied — a MUST def's kill is total per binding, so a transfer either
* aliases the incoming set or replaces it; a MAY def (conditional context —
* see StatementFacts.mayDefs) unions WITHOUT killing via a copy-on-extend.
* Single-predecessor blocks alias the predecessor's OUT map outright;
* multi-pred merges union only bindings whose incoming sets differ by
* reference. Iteration is reverse post-order, seeded with every block
* (unreachable blocks keep ⊥ IN — correct, their defs reach nothing).
* Convergence: sets grow monotonically within the finite def-site universe ⇒
* ≤ loop-depth+1 passes in practice.
*
* `limits.maxFacts` bounds materialization: facts are O(defs×uses) BY SPEC in
* merge-heavy code (N branch-arm defs × N later uses = N² facts), and a
* 2000-line function can spike 100k+ fact objects on the main thread. The
* emit path passes DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION (emit.ts);
* M3 passes its own large-but-finite limit and treats `status: 'truncated'`
* as a per-function taint-coverage gap.
*/
import type { BindingEntry, FunctionCfg } from './types.js';
/** A statement-granular program point within one function's CFG. */
export interface ProgramPoint {
readonly blockIndex: number;
/** Statement index within the block's `statements` array. */
readonly stmtIndex: number;
readonly line: number;
}
/** One def→use fact: the definition at `def` reaches the use at `use`. */
export interface DefUseFact {
/** Index into {@link FunctionDefUse.bindings}. */
readonly bindingIdx: number;
readonly def: ProgramPoint;
readonly use: ProgramPoint;
}
export interface ReachingDefsLimits {
/**
* Maximum number of facts to materialize; the sweep stops early and reports
* `status: 'truncated'`. `undefined`/0 ⇒ unlimited.
*/
readonly maxFacts?: number;
}
export interface FunctionDefUse {
/**
* `computed` — full facts.
* `no-facts` — the CFG carries no statement facts (hand-built or pre-M2
* side channel); empty facts, NOT an error.
* `truncated` — `limits.maxFacts` hit; `facts` is a deterministic prefix.
* `overflow` — a block's statement count breaches the def-key stride; no
* facts at all (computing any would risk key aliasing —
* wrong-block facts are strictly worse than none). Distinct
* from `truncated` so the caller's diagnostic doesn't
* misname it as the fact-materialization limit.
*/
readonly status: 'computed' | 'no-facts' | 'truncated' | 'overflow';
/** Pass-through of the CFG's binding table (empty for `no-facts`). */
readonly bindings: readonly BindingEntry[];
/** Sorted by (def block, def stmt, use block, use stmt, binding). */
readonly facts: readonly DefUseFact[];
/** Total def / use sites seen (telemetry; independent of truncation). */
readonly defCount: number;
readonly useCount: number;
}
/**
* def-site key: packs (blockIndex, stmtIndex) into one number. The stride is
* a per-BLOCK statement bound, and `maxFunctionLines` caps LINES, not
* statements — a minified one-line function coalesces arbitrarily many
* statements into one block, so an overflow would silently alias
* (block b, stmt STRIDE+k) with (block b+1, stmt k) and fabricate wrong-block
* facts. computeReachingDefs therefore range-checks up front and bails to a
* sound empty `truncated` result instead of ever letting a key alias.
* 2^21 statements per block × blocks ≤ 2^32 stays inside Number's 2^53.
*/
const STMT_STRIDE = 1 << 21;
const defKey = (blockIndex: number, stmtIndex: number): number =>
blockIndex * STMT_STRIDE + stmtIndex;
type DefSet = Set<number>;
/** bindingIdx → def-site keys reaching this program point. */
type Lattice = Map<number, DefSet>;
const EMPTY_LATTICE: Lattice = new Map();
/**
* Compute reaching definitions for one function. See the module doc for the
* purity/determinism/sharing contract.
*/
export function computeReachingDefs(cfg: FunctionCfg, limits?: ReachingDefsLimits): FunctionDefUse {
if (!cfg.bindings) {
return { status: 'no-facts', bindings: [], facts: [], defCount: 0, useCount: 0 };
}
const blocks = cfg.blocks;
const n = blocks.length;
// Key-aliasing guard (see STMT_STRIDE): a block with ≥ STRIDE statements
// cannot be keyed without aliasing into the next block's def sites, which
// would fabricate wrong-block facts — strictly worse than producing none.
// Bail to a sound empty `overflow` result (the emit path warns distinctly).
for (const b of blocks) {
if ((b.statements?.length ?? 0) >= STMT_STRIDE) {
return { status: 'overflow', bindings: cfg.bindings, facts: [], defCount: 0, useCount: 0 };
}
}
// ── adjacency (sorted for deterministic merges) ─────────────────────────
// A `throw` edge contributes IN(from) ∪ allDefs(from) to its handler, not
// OUT: an exception can fire BEFORE the block's defs complete (the seed def
// in `let x = seed(); try { x = risky(); } catch { sink(x) }` must reach the
// sink) AND between any two defs of a multi-def coalesced block (the parse
// def in `x = parse(a); x = normalize(x);` is live exactly when normalize
// throws — OUT's last-def-wins misses it). Sound over-approximation;
// monotone, so the fixpoint absorbs it. See mergePreds.
const preds: { from: number; viaThrow: boolean }[][] = Array.from({ length: n }, () => []);
const succs: number[][] = Array.from({ length: n }, () => []);
// Handlers whose IN depends on this block's IN (throw edges) — requeued on
// IN change, since a genned binding can absorb IN growth without changing
// OUT, which would otherwise leave the handler stale.
const throwSuccs: number[][] = Array.from({ length: n }, () => []);
for (const e of cfg.edges) {
// Optional-chained pushes drop out-of-range endpoints defensively — the
// emit path validates via isEmitSafeCfg, but this pure function also runs
// on hand-built CFGs.
succs[e.from]?.push(e.to);
preds[e.to]?.push({ from: e.from, viaThrow: e.kind === 'throw' });
if (e.kind === 'throw') throwSuccs[e.from]?.push(e.to);
}
for (const list of preds) {
list.sort((a, b) => a.from - b.from || Number(a.viaThrow) - Number(b.viaThrow));
// duplicate (from, throw+non-throw) pairs both survive — the throw leg
// adds IN(from); the merge dedups set-wise.
}
for (const list of succs) list.sort((a, b) => a - b);
// ── per-block GEN + def/use telemetry ────────────────────────────────────
// gen[b]: bindingIdx → { set, kills }. A MUST def resets the accumulated
// set (kill is total); a MAY def (conditionally-evaluated context — see
// StatementFacts.mayDefs) only ADDS: the binding's incoming defs survive,
// so the transfer is out[x] = kills ? set : in[x] ∪ set.
interface GenEntry {
set: DefSet;
kills: boolean;
}
const gen: (Map<number, GenEntry> | null)[] = new Array(n).fill(null);
// allDefsGen[b]: bindingIdx → EVERY def-site key in the block (must + may).
// This is what a throw edge delivers to its handler: an exception can fire
// between any two statements, so every intermediate def may be the live one
// at the handler — IN∪OUT alone misses defs overwritten later in the same
// coalesced block (`try { x = parse(a); x = normalize(x); } catch { sink(x) }`
// — parse's value is exactly what sink sees when normalize throws).
const allDefsGen: (Lattice | null)[] = new Array(n).fill(null);
const defLine = new Map<number, number>(); // defKey → source line
let defCount = 0;
let useCount = 0;
for (const b of blocks) {
const stmts = b.statements;
if (!stmts || stmts.length === 0) continue;
let g: Map<number, GenEntry> | null = null;
let all: Lattice | null = null;
for (let i = 0; i < stmts.length; i++) {
const s = stmts[i];
useCount += s.uses.length;
const key = defKey(b.index, i);
const record = (d: number, kills: boolean): void => {
defCount += 1;
defLine.set(key, s.line);
if (!g) g = new Map();
const entry = g.get(d);
if (kills || !entry) {
g.set(d, { set: new Set([key]), kills: kills || (entry?.kills ?? false) });
} else {
entry.set.add(key); // may-def accumulates; never clears
}
if (!all) all = new Map();
const allSet = all.get(d);
if (allSet) allSet.add(key);
else all.set(d, new Set([key]));
};
if (s.mayDefs) for (const d of s.mayDefs) record(d, false);
for (const d of s.defs) record(d, true);
}
gen[b.index] = g;
allDefsGen[b.index] = all;
}
// ── iteration order: RPO over reachable blocks, then the rest by index ──
const order = reversePostOrder(cfg.entryIndex, succs, n);
// ── fixpoint ────────────────────────────────────────────────────────────
const inSets: Lattice[] = new Array(n).fill(EMPTY_LATTICE);
const outSets: Lattice[] = new Array(n).fill(EMPTY_LATTICE);
const inWorklist = new Array(n).fill(true);
let pending = n;
while (pending > 0) {
for (const b of order) {
if (!inWorklist[b]) continue;
inWorklist[b] = false;
pending -= 1;
const p = preds[b];
const inB: Lattice =
p.length === 0
? EMPTY_LATTICE
: p.length === 1 && !p[0].viaThrow
? outSets[p[0].from] // alias — zero allocation on straight-line chains
: mergePreds(p, inSets, outSets, allDefsGen);
const inChanged = !latticeEquals(inSets[b], inB);
inSets[b] = inB;
const g = gen[b];
// OUT = overlay(IN): a KILLING gen entry replaces the binding's set; a
// may-def-only entry unions with the incoming set (never kills). When
// nothing is genned, OUT aliases IN outright.
let outB: Lattice;
if (!g) {
outB = inB;
} else {
outB = new Map(inB); // copies REFERENCES, never set contents
for (const [bindingIdx, entry] of g) {
if (entry.kills) {
outB.set(bindingIdx, entry.set);
} else {
const incoming = inB.get(bindingIdx);
outB.set(bindingIdx, incoming ? unionSets(incoming, entry.set) : entry.set);
}
}
}
const requeue = (s: number): void => {
if (!inWorklist[s]) {
inWorklist[s] = true;
pending += 1;
}
};
if (!latticeEquals(outSets[b], outB)) {
outSets[b] = outB;
for (const s of succs[b]) requeue(s);
}
if (inChanged) for (const s of throwSuccs[b]) requeue(s);
}
}
// ── statement sweep: recover statement-granular def→use facts ───────────
const maxFacts = limits?.maxFacts && limits.maxFacts > 0 ? limits.maxFacts : Infinity;
const facts: DefUseFact[] = [];
let truncated = false;
outer: for (const b of blocks) {
const stmts = b.statements;
if (!stmts || stmts.length === 0) continue;
// Lazy overlay of IN — entries are replaced (never mutated) on def, so the
// shared sets stay intact.
let reach: Lattice | null = null;
for (let i = 0; i < stmts.length; i++) {
const s = stmts[i];
// A use's binding that the SAME statement also defines could be a
// read-then-write (`x += 1` — sees prior defs) OR a write-then-read
// (`if ((m = re.exec(s)) && m[1])` — sees the same-statement def).
// StatementFacts carries no intra-statement order, so emit BOTH: prior
// defs ∪ the same-statement def. Sound over-approximation — the extra
// self-fact on compound assignments is harmless; missing the
// assign-and-test def→use (the most common JS idiom) would be a taint
// false negative. May-defs join the self-key set the same way.
const sameStmtDefs =
s.defs.length > 0 || s.mayDefs?.length ? new Set([...s.defs, ...(s.mayDefs ?? [])]) : null;
for (const u of s.uses) {
const reaching = (reach ?? inSets[b.index]).get(u);
const selfKey = sameStmtDefs?.has(u) ? defKey(b.index, i) : undefined;
if (!reaching && selfKey === undefined) continue;
const keys =
selfKey !== undefined && !reaching?.has(selfKey)
? [...(reaching ?? []), selfKey]
: [...(reaching ?? [])];
for (const key of keys) {
if (facts.length >= maxFacts) {
truncated = true;
break outer;
}
const defBlock = Math.floor(key / STMT_STRIDE);
const defStmt = key % STMT_STRIDE;
facts.push({
bindingIdx: u,
def: { blockIndex: defBlock, stmtIndex: defStmt, line: defLine.get(key) ?? s.line },
use: { blockIndex: b.index, stmtIndex: i, line: s.line },
});
}
}
if (s.mayDefs?.length) {
// Gen WITHOUT kill: the conditional def joins the binding's set.
if (!reach) reach = new Map(inSets[b.index]);
const key = defKey(b.index, i);
for (const d of s.mayDefs) {
const prior = reach.get(d);
reach.set(d, prior ? unionSets(prior, new Set([key])) : new Set([key]));
}
}
if (s.defs.length > 0) {
if (!reach) reach = new Map(inSets[b.index]);
for (const d of s.defs) reach.set(d, new Set([defKey(b.index, i)])); // kill + gen
}
}
}
facts.sort(
(a, b) =>
a.def.blockIndex - b.def.blockIndex ||
a.def.stmtIndex - b.def.stmtIndex ||
a.use.blockIndex - b.use.blockIndex ||
a.use.stmtIndex - b.use.stmtIndex ||
a.bindingIdx - b.bindingIdx,
);
return {
status: truncated ? 'truncated' : 'computed',
bindings: cfg.bindings,
facts,
defCount,
useCount,
};
}
/** RPO over blocks reachable from `entry`; unreachable blocks appended by index. */
function reversePostOrder(entry: number, succs: readonly number[][], n: number): number[] {
const visited = new Array<boolean>(n).fill(false);
const post: number[] = [];
// Iterative DFS with an explicit phase stack (children pushed in reverse so
// they pop in sorted order — determinism).
const stack: { node: number; childIdx: number }[] = [{ node: entry, childIdx: 0 }];
visited[entry] = true;
while (stack.length) {
const top = stack[stack.length - 1];
const children = succs[top.node];
if (top.childIdx < children.length) {
const next = children[top.childIdx];
top.childIdx += 1;
if (!visited[next]) {
visited[next] = true;
stack.push({ node: next, childIdx: 0 });
}
} else {
post.push(top.node);
stack.pop();
}
}
const order = post.reverse();
for (let b = 0; b < n; b++) if (!visited[b]) order.push(b);
return order;
}
/**
* Union predecessor lattices, sharing sets where possible. A normal edge
* contributes OUT(from). A THROW edge contributes IN(from) ∪ allDefs(from):
* an exception may fire before, between, or after any of the block's defs, so
* the handler can observe the incoming state OR any intermediate def — OUT
* alone (last-def-wins) misses defs overwritten later in the same block.
* IN ∪ allDefs ⊇ OUT, so the throw contribution subsumes it.
*/
function mergePreds(
preds: readonly { from: number; viaThrow: boolean }[],
inSets: readonly Lattice[],
outSets: readonly Lattice[],
allDefsGen: readonly (Lattice | null)[],
): Lattice {
const merged: Lattice = new Map();
const mergeOne = (source: Lattice): void => {
for (const [bindingIdx, set] of source) {
const existing = merged.get(bindingIdx);
if (!existing) {
merged.set(bindingIdx, set); // share the first contributor's set
} else if (existing !== set) {
// Union only when the references differ. Copy-on-extend: `existing`
// may be a shared set from another block — never mutate it.
let target = existing;
let copied = false;
for (const key of set) {
if (!target.has(key)) {
if (!copied) {
target = new Set(existing);
copied = true;
}
target.add(key);
}
}
if (copied) merged.set(bindingIdx, target);
}
}
};
for (const p of preds) {
if (p.viaThrow) {
mergeOne(inSets[p.from]); // exception may fire pre-defs…
const all = allDefsGen[p.from];
if (all) mergeOne(all); // …or after ANY of the block's defs
} else {
mergeOne(outSets[p.from]);
}
}
return merged;
}
/** Order-stable union of two def-sets (shares `a` when `b` adds nothing). */
function unionSets(a: DefSet, b: DefSet): DefSet {
let target = a;
let copied = false;
for (const key of b) {
if (!target.has(key)) {
if (!copied) {
target = new Set(a);
copied = true;
}
target.add(key);
}
}
return target;
}
/** Per-binding equality with a reference fast path (sets only ever grow). */
function latticeEquals(a: Lattice, b: Lattice): boolean {
if (a === b) return true;
if (a.size !== b.size) return false;
for (const [k, bSet] of b) {
const aSet = a.get(k);
if (aSet === bSet) continue;
if (!aSet || aSet.size !== bSet.size) return false;
for (const v of bSet) if (!aSet.has(v)) return false;
}
return true;
}
@@ -0,0 +1,21 @@
/**
* TraversalResult (issue #2081, M1).
*
* Visiting a statement (or a statement sequence) returns the block its control
* flow ENTERS through, plus the set of blocks whose **normal** control flows
* out the bottom (the "dangling exits") — to be wired to the entry of whatever
* comes next. Abnormal exits (return/break/continue/throw) are wired directly
* to their targets during the walk and are NOT part of `exits`.
*
* A statement that cannot fall through (e.g. ends in `return`/`throw`, or both
* branches of an `if` return) yields an empty `exits` array.
*/
export interface TraversalResult {
/** Block index control enters this statement/sequence through. */
readonly entry: number;
/** Block indices whose normal control falls out the bottom (may be empty). */
readonly exits: readonly number[];
}
/** A sequence of statements that produced no blocks (e.g. an empty body). */
export const emptyTraversal = (entry: number): TraversalResult => ({ entry, exits: [entry] });
+161
View File
@@ -0,0 +1,161 @@
/**
* CFG data model — plain, JSON-serializable types (issue #2081, M1).
*
* These cross the worker→main boundary and the disk-backed/durable ParsedFile
* store, so they must contain NO tree-sitter AST references, class instances,
* or anything that does not survive `JSON.stringify` → `JSON.parse`. Block and
* edge endpoints are referenced by integer index within a function's CFG.
*
* The per-language `CfgVisitor` (built in the parse worker, where the AST
* lives — see the M1 plan KTD1/KTD7) produces a `FunctionCfg` per function; the
* array of them is what rides on `ParsedFile.cfgSideChannel`.
*/
/**
* One distinct declared variable (binding) within a function (#2082 M2 U1).
*
* Statement facts reference bindings by integer index into
* {@link FunctionCfg.bindings} — names appear once per binding instead of once
* per occurrence (measured ~4× smaller serialized payload than named records).
* Distinct bindings of the same name (shadowing) get distinct entries, which is
* what keeps an inner `let x` from falsely killing the outer `x`'s definitions
* in the reaching-defs solver. NOTE: no field here may be named `nodeId` — the
* durable parsedfile-store reviver dedups objects keyed on that field name.
*/
export interface BindingEntry {
/** Source-level variable name (what the persisted edge's `reason` carries). */
readonly name: string;
/**
* 1-based line/0-based column of the canonical declaration site — `var`
* multi-declarations canonicalize to the FIRST declaration in source order.
* Both 0 for synthetic bindings.
*/
readonly declLine: number;
readonly declColumn: number;
/** How the binding was introduced (param/catch matter to the M3 taint pass). */
readonly kind: 'var' | 'let' | 'const' | 'param' | 'catch' | 'function' | 'class' | 'module';
/**
* True when the name has no in-function declaration site (implicit global,
* import, or a variable captured from an enclosing function) — keyed
* `name@module` in edge ids instead of `name:line:col`.
*/
readonly synthetic?: boolean;
}
/**
* Def/use facts for one harvested statement (or construct header), in
* execution order within its block (#2082 M2 U1). `defs`/`uses` are indices
* into {@link FunctionCfg.bindings}. A compound assignment / update expression
* lists its binding in BOTH. Self-describing — `line` is carried here, never
* inferred from the block's text fragments (facts-only records exist, e.g.
* params on ENTRY and catch params).
*
* `mayDefs` (tri-review P1): defs harvested inside CONDITIONALLY-EVALUATED
* subexpressions — short-circuit right operands (`a && (x = v)`,
* `c ?? (c = load())`), ternary arms, logical-assignment operators, and
* switch case-test expressions. The solver treats them as GEN WITHOUT KILL:
* treating them as must-defs would falsely kill the prior def on the
* not-taken path (a taint false negative on core JS idioms). Optional —
* absent means none.
*/
export interface StatementFacts {
readonly line: number;
readonly defs: readonly number[];
readonly uses: readonly number[];
readonly mayDefs?: readonly number[];
}
/** A basic block: a maximal straight-line run of statements between leaders. */
export interface BasicBlockData {
/** Block index within its function. The synthetic ENTRY is always 0. */
readonly index: number;
readonly startLine: number;
readonly endLine: number;
/** Source snippet for the block (empty for synthetic ENTRY/EXIT). */
readonly text: string;
readonly kind: 'entry' | 'exit' | 'normal';
/**
* Per-statement def/use facts in execution order (#2082 M2 U1). Present only
* when the producing visitor harvests (TS/JS under `--pdg`); absent on
* hand-built or pre-M2 CFGs — the reaching-defs solver reports `no-facts`.
*/
readonly statements?: readonly StatementFacts[];
}
/**
* Why one block flows to another — drives the `reason` on the emitted CFG edge.
*
* Kind invariant (M2): a bare jump kind (`return`/`break`/`continue`) means the
* SOURCE block's terminator is that jump statement. A `finally-*` kind marks a
* COMPLETION edge out of a `finally` body's exit — the leg that resumes a jump
* which was re-routed through the finally (issue #2082 U2). Reusing the bare
* kinds on completion edges would silently break consumers that infer the
* source block's terminator from the kind, and a single generic kind would lose
* WHICH jump each completion edge completes when a shared finally has several
* pending targets.
*/
export type CfgEdgeKind =
| 'seq' // straight-line fallthrough
| 'cond-true' // branch taken (if/while/for condition true)
| 'cond-false' // branch not taken / loop exit
| 'loop-back' // back-edge to a loop header
| 'break' // break → loop/switch exit (or the finally it must cross)
| 'continue' // continue → loop header (or the finally it must cross)
| 'return' // return → function EXIT (or the finally it must cross)
| 'throw' // throw → nearest handler / finally / EXIT
| 'switch-case' // dispatch to a case
| 'fallthrough' // switch case → next case (no break)
| 'finally-return' // finally exit → resumed return target (EXIT / outer finally)
| 'finally-break' // finally exit → resumed break target
| 'finally-continue'; // finally exit → resumed continue target
export interface CfgEdgeData {
readonly from: number;
readonly to: number;
readonly kind: CfgEdgeKind;
}
/** One function's control-flow graph. `cfgSideChannel` is `readonly FunctionCfg[]`. */
export interface FunctionCfg {
readonly filePath: string;
/** Source span of the owning function — anchors the BasicBlock node ids. */
readonly functionStartLine: number;
readonly functionEndLine: number;
/**
* Start COLUMN of the owning function. Combined with `functionStartLine` it
* disambiguates the BasicBlock node ids when two functions share a start line
* — e.g. `{ a: () => x(), b: () => y() }`, where both arrows begin on the same
* line and each restarts its block indices at 0. Without the column the ids
* collide and the graph's first-writer-wins `addNode` silently drops the
* second function's blocks and cross-wires its edges.
*/
readonly functionStartColumn: number;
readonly entryIndex: number;
readonly exitIndex: number;
readonly blocks: readonly BasicBlockData[];
readonly edges: readonly CfgEdgeData[];
/**
* The function's binding table (#2082 M2 U1) — referenced by index from
* {@link BasicBlockData.statements}. Present iff statement facts are.
*/
readonly bindings?: readonly BindingEntry[];
}
/**
* Per-language CFG strategy. Invoked **in the parse worker** for each function
* node. `TNode` is the language's AST node type (tree-sitter `SyntaxNode` for
* TS/JS) — kept generic so this module stays AST-library-agnostic. Returns
* `undefined` when the node is not a CFG-bearing function (the caller skips it).
*/
export interface CfgVisitor<TNode = unknown> {
buildFunctionCfg(fnNode: TNode, filePath: string): FunctionCfg | undefined;
/**
* Whether `node` is a CFG-bearing function this visitor handles. Lets the
* worker enumerate functions (and apply the per-function line budget) by a
* cheap node-type test, instead of attempting to build a CFG for every AST
* node. `buildFunctionCfg` still re-checks, so this is purely an optimization
* + the seam the line-budget hooks into.
*/
isFunction(node: TNode): boolean;
}
@@ -0,0 +1,656 @@
/**
* TS/JS def/use harvester (#2082 M2 U1).
*
* Runs in the parse worker next to the CFG visitor, extracting per-statement
* variable definition/use facts that ride the side channel for the
* reaching-defs solver (`cfg/reaching-defs.ts`). Output is the per-function
* binding table ({@link BindingEntry}[]) plus {@link StatementFacts} records
* the visitor attaches to blocks as it walks.
*
* TWO-PHASE, ORDER-INDEPENDENT (load-bearing): the CFG walk is NOT source-order
* — `visitTry` builds the finally body before the protected body, `visitFor`
* creates the init block after walking the body, `visitDoWhile` the condition
* before the body. Resolving names against a scope stack populated *during*
* that walk would mis-resolve common code (`try { var v = 1; } finally
* { use(v); }` keys the use synthetically while the def gets the real binding —
* the def→use fact silently never forms, a taint false negative). So phase 1
* pre-scans the whole function subtree once, collecting every declaration into
* a completed lexical scope tree (also resolving `var` hoisting and multi-decl
* canonicalization order-independently, eslint-scope style); phase 2 resolves
* defs/uses against that finished tree from any walk order.
*
* v1 def-semantics scope (plan KTD4): var/let/const declarations, assignments
* (plain/compound/destructuring), update expressions, function/class
* declarations, parameters (incl. defaults/rest/destructured), catch params,
* for-in/of heads. EXCLUDED, deliberately: property/member writes (`this.x=`,
* `obj.p=` — TypeScript-CFA precedent), and BOTH directions of nested-function
* capture — writes to outer variables from nested bodies AND reads of captured
* variables inside nested bodies are invisible (nested functions are opaque
* blocks in the enclosing CFG; callback flows like `arr.forEach(() => sink(y))`
* register no use of `y` — closure/callback dataflow is M4 territory and the
* M3 consumer contract must name it).
*
* Identifiers with no in-function declaration (implicit globals, imports,
* variables captured from an enclosing function) resolve to a SYNTHETIC
* module-level binding (`name@module`), applied identically by def and use
* harvesting so `notDeclared = 1; use(notDeclared)` still forms a fact.
*
* NOTE: nothing serialized here may carry a field named `nodeId` — the durable
* parsedfile-store reviver dedups objects keyed on that field name.
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import type { BindingEntry, StatementFacts } from '../types.js';
/** Node types that own a nested CFG — their subtrees are opaque to harvesting. */
const NESTED_FUNCTION_TYPES = new Set([
'function_declaration',
'function_expression',
'arrow_function',
'method_definition',
'generator_function_declaration',
'generator_function',
'async_function_declaration',
'async_arrow_function',
]);
/** Function-ish declaration statements whose NAME still binds in the enclosing scope. */
const FUNCTION_DECL_TYPES = new Set([
'function_declaration',
'generator_function_declaration',
'async_function_declaration',
]);
/**
* Nodes that open a lexical scope for `let`/`const`/`class`/catch bindings.
* A `switch` BODY is deliberately ONE scope shared by all case arms (JS
* semantics: `case 1: let x = 1; case 2: use(x)` is the same binding).
*/
const SCOPE_TYPES = new Set([
'statement_block',
'for_statement',
'for_in_statement',
'for_of_statement',
'catch_clause',
'switch_body',
]);
/** Type-position subtrees — identifiers inside them are not value uses. */
const TYPE_CONTEXT_TYPES = new Set([
'type_annotation',
'type_arguments',
'type_parameters',
'type_predicate_annotation',
'asserts_annotation',
]);
interface Scope {
readonly parent: Scope | null;
/** name → binding index */
readonly table: Map<string, number>;
}
export class TsHarvester {
private readonly bindings: BindingEntry[] = [];
/** Scope-opening node id → its scope. */
private readonly scopeByNode = new Map<number, Scope>();
private readonly root: Scope = { parent: null, table: new Map() };
/** name → synthetic binding index (implicit global / import / captured). */
private readonly synthetic = new Map<string, number>();
private readonly fnId: number;
/**
* Innermost enclosing scope per visited node id, filled during the prescan
* (which already touches every named node once). Makes phase-2 resolution
* O(scope-chain) instead of O(AST-depth) per identifier — a deeply-chained
* single-statement expression (generated code) otherwise turns the
* parent-chain walk quadratic (tri-review perf finding).
*/
private readonly nearestScopeCache = new Map<number, Scope>();
/**
* >0 while walking a conditionally-evaluated subexpression (short-circuit
* right operand, ternary arm, logical-assignment target, case test). Defs
* found there are MAY-defs — gen without kill (tri-review P1: a must-def
* here falsely kills the prior def on the not-taken path).
*/
private conditionalDepth = 0;
constructor(private readonly fnNode: SyntaxNode) {
this.fnId = fnNode.id;
this.scopeByNode.set(fnNode.id, this.root);
this.declareParams(fnNode);
const body = fnNode.childForFieldName('body');
if (body)
this.prescan(body, body.type === 'statement_block' ? this.openScope(body) : this.root);
}
/** The completed binding table — pass to `CfgBuilder.finish`. */
table(): readonly BindingEntry[] {
return this.bindings;
}
// ── phase 1: declaration pre-scan ────────────────────────────────────────
private openScope(node: SyntaxNode): Scope {
const existing = this.scopeByNode.get(node.id);
if (existing) return existing;
const scope: Scope = { parent: this.nearestScopeOf(node), table: new Map() };
this.scopeByNode.set(node.id, scope);
return scope;
}
private nearestScopeOf(node: SyntaxNode): Scope {
for (let p = node.parent; p; p = p.parent) {
const s = this.scopeByNode.get(p.id);
if (s) return s;
if (p.id === this.fnId) break;
}
return this.root;
}
private declare(
nameNode: SyntaxNode,
kind: BindingEntry['kind'],
scope: Scope,
hoistToRoot: boolean,
): void {
const target = hoistToRoot ? this.root : scope;
const name = nameNode.text;
// `var` multi-declaration (and a param + `var` of the same name) is ONE
// binding — first declaration in source order is canonical. The dedup is
// scoped to the single target table, so an inner `let x` shadowing a root
// `var x` still gets its own entry in its own scope.
if (target.table.has(name)) return;
target.table.set(name, this.bindings.length);
this.bindings.push({
name,
declLine: nameNode.startPosition.row + 1,
declColumn: nameNode.startPosition.column,
kind,
});
}
private declareParams(fnNode: SyntaxNode): void {
const params = fnNode.childForFieldName('parameters') ?? fnNode.childForFieldName('parameter');
if (!params) return;
if (params.type === 'identifier') {
this.declare(params, 'param', this.root, true); // `x => …` single-param arrow
return;
}
for (let i = 0; i < params.namedChildCount; i++) {
const p = params.namedChild(i);
if (!p) continue;
// TS wraps each param (required_parameter/optional_parameter, field
// `pattern`); plain JS puts the pattern directly in formal_parameters.
const pattern = p.childForFieldName('pattern') ?? p;
this.declarePattern(pattern, 'param', this.root, true);
}
}
/** Declare every name bound by a (possibly destructuring) pattern. */
private declarePattern(
node: SyntaxNode,
kind: BindingEntry['kind'],
scope: Scope,
hoistToRoot: boolean,
): void {
switch (node.type) {
case 'identifier':
case 'shorthand_property_identifier_pattern':
this.declare(node, kind, scope, hoistToRoot);
return;
case 'rest_pattern':
case 'object_pattern':
case 'array_pattern':
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.declarePattern(c, kind, scope, hoistToRoot);
}
return;
case 'pair_pattern': {
const value = node.childForFieldName('value');
if (value) this.declarePattern(value, kind, scope, hoistToRoot);
return;
}
case 'assignment_pattern':
case 'object_assignment_pattern': {
const left = node.childForFieldName('left');
if (left) this.declarePattern(left, kind, scope, hoistToRoot);
return;
}
default:
// Type annotations / unknown wrappers — descend defensively.
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c && !TYPE_CONTEXT_TYPES.has(c.type)) {
this.declarePattern(c, kind, scope, hoistToRoot);
}
}
}
}
private prescan(node: SyntaxNode, scope: Scope): void {
this.nearestScopeCache.set(node.id, scope);
const t = node.type;
if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) {
// A nested function's NAME binds in the enclosing scope; its body is opaque.
if (FUNCTION_DECL_TYPES.has(t)) {
const name = node.childForFieldName('name');
if (name) this.declare(name, 'function', scope, false);
}
return;
}
let childScope = scope;
if (SCOPE_TYPES.has(t)) childScope = this.openScope(node);
switch (t) {
case 'lexical_declaration': {
const kind = node.child(0)?.type === 'const' ? 'const' : 'let';
this.declareDeclarators(node, kind, childScope, false);
break;
}
case 'variable_declaration':
this.declareDeclarators(node, 'var', childScope, true);
break;
case 'class_declaration': {
const name = node.childForFieldName('name');
if (name) this.declare(name, 'class', childScope, false);
break;
}
case 'catch_clause': {
const param = node.childForFieldName('parameter');
if (param) this.declarePattern(param, 'catch', childScope, false);
break;
}
case 'for_in_statement':
case 'for_of_statement': {
// `for (const x of xs)` — the `kind` keyword marks a declaration; a bare
// `for (x of xs)` left is an assignment, resolved at use time instead.
const kindNode = node.childForFieldName('kind');
const left = node.childForFieldName('left');
if (kindNode && left) {
const k = kindNode.type === 'var' ? 'var' : kindNode.type === 'const' ? 'const' : 'let';
this.declarePattern(left, k, childScope, k === 'var');
}
break;
}
default:
break;
}
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.prescan(c, childScope);
}
}
private declareDeclarators(
declNode: SyntaxNode,
kind: 'var' | 'let' | 'const',
scope: Scope,
hoistToRoot: boolean,
): void {
for (let i = 0; i < declNode.namedChildCount; i++) {
const d = declNode.namedChild(i);
if (d?.type !== 'variable_declarator') continue;
const name = d.childForFieldName('name');
if (name) this.declarePattern(name, kind, scope, hoistToRoot);
}
}
// ── phase 2: per-statement fact extraction ───────────────────────────────
/**
* Def/use facts for one statement (or construct-header expression) node.
* Safe from any walk order — resolution consults the completed scope tree.
*/
facts(node: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(node.startPosition.row + 1);
this.walkValue(node, acc);
return acc.finish();
}
/**
* Facts for an expression whose WHOLE evaluation is conditional (switch
* case tests, which only run when earlier cases didn't match) — every def
* inside becomes a may-def.
*/
factsConditional(node: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(node.startPosition.row + 1);
this.conditional(() => this.walkValue(node, acc));
return acc.finish();
}
/** Facts for a `for (left in/of right)` head: left binds/assigns, right is used. */
forInHeadFacts(stmt: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(stmt.startPosition.row + 1);
const left = stmt.childForFieldName('left');
const right = stmt.childForFieldName('right');
if (left) this.walkDefPattern(left, acc);
if (right) this.walkValue(right, acc);
return acc.finish();
}
/** ENTRY-block facts for the function's parameters (defs + default-value uses). */
paramFacts(): StatementFacts | undefined {
const fnNode = this.fnNode;
const params = fnNode.childForFieldName('parameters') ?? fnNode.childForFieldName('parameter');
if (!params) return undefined;
const acc = new FactAccumulator(fnNode.startPosition.row + 1);
if (params.type === 'identifier') {
this.def(params, acc);
} else {
for (let i = 0; i < params.namedChildCount; i++) {
const p = params.namedChild(i);
if (!p) continue;
const pattern = p.childForFieldName('pattern') ?? p;
this.walkDefPattern(pattern, acc);
const dflt = p.childForFieldName('value');
if (dflt) this.walkValue(dflt, acc);
}
}
return acc.defCount() || acc.useCount() ? acc.finish() : undefined;
}
/** Def fact for a `catch (e)` parameter — prepend to the handler entry block. */
catchParamFacts(catchClause: SyntaxNode): StatementFacts | undefined {
const param = catchClause.childForFieldName('parameter');
if (!param) return undefined;
const acc = new FactAccumulator(catchClause.startPosition.row + 1);
this.walkDefPattern(param, acc);
return acc.defCount() ? acc.finish() : undefined;
}
private resolve(nameNode: SyntaxNode): number {
const name = nameNode.text;
// Fast path: the prescan cached every visited node's innermost scope, so
// resolution walks the SCOPE chain (shallow), not the AST parent chain
// (arbitrarily deep in chained expressions). The parent-chain walk remains
// as fallback for the few nodes the prescan never visits (e.g. a nested
// function declaration's own name node).
const cached = this.nearestScopeCache.get(nameNode.id);
let startScope: Scope | null = cached ?? null;
if (!startScope) {
for (let p: SyntaxNode | null = nameNode; p; p = p.parent) {
const scope = this.scopeByNode.get(p.id) ?? this.nearestScopeCache.get(p.id);
if (scope) {
startScope = scope;
break;
}
if (p.id === this.fnId) {
startScope = this.root;
break;
}
}
}
for (let s: Scope | null = startScope; s; s = s.parent) {
const idx = s.table.get(name);
if (idx !== undefined) return idx;
}
// No in-function declaration — synthetic module-level binding, shared by
// defs and uses so `notDeclared = 1; use(notDeclared)` still forms a fact.
let idx = this.synthetic.get(name);
if (idx === undefined) {
idx = this.bindings.length;
this.synthetic.set(name, idx);
this.bindings.push({ name, declLine: 0, declColumn: 0, kind: 'module', synthetic: true });
}
return idx;
}
private def(nameNode: SyntaxNode, acc: FactAccumulator): void {
if (this.conditionalDepth > 0) acc.addMayDef(this.resolve(nameNode));
else acc.addDef(this.resolve(nameNode));
}
/** Run `fn` with defs demoted to may-defs (conditionally-evaluated context). */
private conditional(fn: () => void): void {
this.conditionalDepth++;
try {
fn();
} finally {
this.conditionalDepth--;
}
}
/** Strip wrappers that don't change the lvalue (`(x) += 1`, `x! ++`). */
private unwrapLvalue(node: SyntaxNode): SyntaxNode {
let n = node;
while (n.type === 'parenthesized_expression' || n.type === 'non_null_expression') {
const inner = n.namedChild(0);
if (!inner) break;
n = inner;
}
return n;
}
private use(nameNode: SyntaxNode, acc: FactAccumulator): void {
acc.addUse(this.resolve(nameNode));
}
/** Value-position walk: collect uses; route def positions to the pattern walk. */
private walkValue(node: SyntaxNode, acc: FactAccumulator): void {
const t = node.type;
if (TYPE_CONTEXT_TYPES.has(t)) return;
if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) {
// Opaque nested function: its NAME (function declaration) is a def in
// the enclosing scope; captured reads/writes inside are invisible (KTD4).
if (FUNCTION_DECL_TYPES.has(t)) {
const name = node.childForFieldName('name');
if (name) this.def(name, acc);
}
return;
}
switch (t) {
case 'identifier':
case 'shorthand_property_identifier':
this.use(node, acc);
return;
case 'lexical_declaration':
case 'variable_declaration':
for (let i = 0; i < node.namedChildCount; i++) {
const d = node.namedChild(i);
if (d?.type !== 'variable_declarator') continue;
const name = d.childForFieldName('name');
const value = d.childForFieldName('value');
// A bare `var x;` mid-function is hoisted and writes NOTHING at
// runtime — harvesting it as a def would fabricate a kill of the
// live def (`x = source(); var x; sink(x)` must keep source→sink;
// tri-review P2). `let`/`const` declarators genuinely initialize.
if (name && (value || t === 'lexical_declaration')) {
this.walkDefPattern(name, acc);
}
if (value) this.walkValue(value, acc);
}
return;
case 'assignment_expression': {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
if (left) this.walkDefPattern(this.unwrapLvalue(left), acc);
if (right) this.walkValue(right, acc);
return;
}
case 'augmented_assignment_expression': {
// `x += y` both defines and uses x. The logical-assignment operators
// (`||=`, `&&=`, `??=`) only WRITE conditionally — their def is a
// may-def (the read always happens).
const left = node.childForFieldName('left')
? this.unwrapLvalue(node.childForFieldName('left') as SyntaxNode)
: null;
const right = node.childForFieldName('right');
const op = node.childForFieldName('operator')?.type ?? '';
const logical = op === '||=' || op === '&&=' || op === '??=';
if (left?.type === 'identifier') {
if (logical) this.conditional(() => this.def(left, acc));
else this.def(left, acc);
this.use(left, acc);
} else if (left) {
this.walkValue(left, acc); // member/subscript target — uses only
}
// The RHS of a logical assignment is itself conditionally evaluated.
if (right) {
if (logical) this.conditional(() => this.walkValue(right, acc));
else this.walkValue(right, acc);
}
return;
}
case 'update_expression': {
const rawArg = node.childForFieldName('argument');
const arg = rawArg ? this.unwrapLvalue(rawArg) : null;
if (arg?.type === 'identifier') {
this.def(arg, acc);
this.use(arg, acc);
} else if (arg) {
this.walkValue(arg, acc);
}
return;
}
case 'binary_expression': {
// Short-circuit operators evaluate their RIGHT operand conditionally:
// a def inside it (`a && (x = clean())`, `c ?? (c = load())`) must be
// a may-def or the not-taken path's prior def is falsely killed
// (tri-review P1). Other binary operators evaluate both sides.
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
const op = node.childForFieldName('operator')?.type ?? '';
if (left) this.walkValue(left, acc);
if (right) {
if (op === '&&' || op === '||' || op === '??') {
this.conditional(() => this.walkValue(right, acc));
} else {
this.walkValue(right, acc);
}
}
return;
}
case 'ternary_expression': {
// Each arm is conditionally evaluated — defs inside are may-defs.
const cond = node.childForFieldName('condition');
const consequence = node.childForFieldName('consequence');
const alternative = node.childForFieldName('alternative');
if (cond) this.walkValue(cond, acc);
if (consequence) this.conditional(() => this.walkValue(consequence, acc));
if (alternative) this.conditional(() => this.walkValue(alternative, acc));
return;
}
case 'class_declaration': {
// The class NAME is a def (prescan declared the binding) — without
// this case the default walk would record it as a bogus USE in plain
// JS (the name is an `identifier` there; in TS it's a type_identifier
// and would be silently skipped, losing the def either way). The body
// walk picks up field-initializer uses; methods are opaque nested fns.
const name = node.childForFieldName('name');
if (name) this.def(name, acc);
const body = node.childForFieldName('body');
if (body) this.walkValue(body, acc);
return;
}
case 'class': {
// Class EXPRESSION: its name (if any) binds only inside the class —
// not a def in the enclosing function. Walk only the body.
const body = node.childForFieldName('body');
if (body) this.walkValue(body, acc);
return;
}
default:
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.walkValue(c, acc);
}
}
}
/** Assignment-target walk: identifiers bind; member/subscript targets are uses. */
private walkDefPattern(node: SyntaxNode, acc: FactAccumulator): void {
switch (node.type) {
case 'identifier':
case 'shorthand_property_identifier_pattern':
this.def(node, acc);
return;
case 'rest_pattern':
case 'object_pattern':
case 'array_pattern':
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.walkDefPattern(c, acc);
}
return;
case 'pair_pattern': {
const key = node.childForFieldName('key');
const value = node.childForFieldName('value');
if (key?.type === 'computed_property_name') this.walkValue(key, acc);
if (value) this.walkDefPattern(value, acc);
return;
}
case 'assignment_pattern':
case 'object_assignment_pattern': {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
if (left) this.walkDefPattern(left, acc);
if (right) this.walkValue(right, acc);
return;
}
case 'member_expression':
case 'subscript_expression':
// Property/element write — NOT a scalar def (KTD4); its identifiers
// (object, computed key) are uses.
this.walkValue(node, acc);
return;
default:
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c && !TYPE_CONTEXT_TYPES.has(c.type)) this.walkDefPattern(c, acc);
}
}
}
}
/** Ordered, deduplicating def/use collector for one statement record. */
class FactAccumulator {
private readonly defs: number[] = [];
private readonly uses: number[] = [];
private readonly mayDefs: number[] = [];
private readonly defSeen = new Set<number>();
private readonly useSeen = new Set<number>();
private readonly mayDefSeen = new Set<number>();
constructor(private readonly line: number) {}
addDef(idx: number): void {
if (this.defSeen.has(idx)) return;
this.defSeen.add(idx);
this.defs.push(idx);
}
/** A def that may not execute (conditional context) — gen without kill. */
addMayDef(idx: number): void {
if (this.mayDefSeen.has(idx)) return;
this.mayDefSeen.add(idx);
this.mayDefs.push(idx);
}
addUse(idx: number): void {
if (this.useSeen.has(idx)) return;
this.useSeen.add(idx);
this.uses.push(idx);
}
defCount(): number {
return this.defs.length + this.mayDefs.length;
}
useCount(): number {
return this.uses.length;
}
finish(): StatementFacts {
return {
line: this.line,
defs: this.defs,
uses: this.uses,
// Optional field stays absent when empty — keeps the serialized
// side-channel payload lean (most statements have no may-defs).
...(this.mayDefs.length > 0 ? { mayDefs: this.mayDefs } : {}),
};
}
}
@@ -0,0 +1,782 @@
/**
* TS/JS CfgVisitor (issue #2081, M1).
*
* Walks a TypeScript/JavaScript function's tree-sitter AST and drives the
* language-agnostic {@link CfgBuilder} to produce a serializable
* {@link FunctionCfg}. TS and JS share a grammar family (tree-sitter-typescript
* reuses tree-sitter-javascript's statement nodes), so one visitor covers both.
*
* Design — a `visit_<node_type>` dispatch over the statement taxonomy. The
* classic CFG hazards (R10) are handled explicitly:
* - loops allocate a dedicated **loop-exit** block so `break` has a concrete
* target before the loop's successor is known; `continue` targets the
* header/increment; the back-edge closes the loop.
* - `switch` cases fall through naturally: a case body that does not `break`
* yields non-empty `exits`, which we wire to the next case as `fallthrough`;
* a case that `break`s wires to the switch exit (via {@link ControlFlowContext})
* and yields no fall-out.
* - `try/catch/finally` routes both normal completion AND a `throw` in the try
* through `finally` (the finally block post-dominates the try/catch); a
* `throw` with no catch propagates through finally to the enclosing handler.
* - EARLY EXITS THROUGH FINALLY (#2082 M2 U2, closes the M1 soundness gap): a
* `break`/`continue`/`return` whose jump CROSSES a `finally` is re-routed to
* the finally entry (keeping its bare jump kind), and the finally's exits
* gain a `finally-return`/`finally-break`/`finally-continue` completion edge
* to the resumed target. Threading is TARGET-RELATIVE via finalizer frames
* interleaved on the {@link ControlFlowContext} stack: only the finallys
* lexically between the jump and its target thread (a `break` whose loop is
* wholly inside the try keeps its direct edge — re-routing it would let a
* finally redefinition falsely kill in-loop defs for reaching-defs). Nested
* finallys chain inner→outer; finally-as-shared-join conflates exit paths
* (sound over-approximation; duplication-per-exit-path was rejected). An
* empty/comment-only finally pushes no frame — jumps keep direct edges.
* - labeled `break`/`continue` resolve against the labeled construct's frame:
* loops/switches carry their full label LIST (`outer: inner: for` resolves
* both), and a labeled NON-loop statement (`blk: { … break blk; … }`) gets
* a break-target frame whose target is a synthesized join after the body —
* the M1 route-to-EXIT fallback removed the real continuation and falsely
* killed defs for reaching-defs (tri-review P1).
*
* Known limitations:
* - A jump whose label STILL fails to resolve (malformed source) keeps the
* conservative route-to-EXIT + thread-all-finallys fallback in
* visitBreak/visitContinue — single-exit preserved, no finally bypassed,
* but the continuation path is approximate.
* - Exceptional flow stays the sound over-approximation: EVERY protected-region
* block edges to the handler (an exception may fire mid-block), which
* over-supplies reaching-defs facts into `catch` — extra facts, never false
* kills. Per-leader throw precision is deliberately deferred (M3 decides).
* - Def/use harvest scope (#2082 M2, see typescript-harvest.ts for the full
* v1 semantics table): member/property writes are not scalar defs; nested
* function bodies are opaque in BOTH directions (writes to and reads of
* captured outer variables are invisible — callback flows are M4 territory);
* `case x:` test uses attach to the switch dispatch block (sound
* over-approximation of in-order case evaluation).
*
* Block/edge accounting and reachability are pinned in
* `test/unit/cfg/cfg-builder.test.ts` (core) and
* `test/unit/cfg/typescript-visitor.test.ts` (this visitor, per hazard).
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { CfgBuilder } from '../cfg-builder.js';
import {
ControlFlowContext,
drainFinalizerPending,
wireJumpThroughFinalizers,
} from '../control-flow-context.js';
import type { TraversalResult } from '../traversal-result.js';
import type { CfgVisitor, FunctionCfg } from '../types.js';
import { TsHarvester } from './typescript-harvest.js';
/** TS/JS node types that own a CFG-bearing function body. */
const TS_FUNCTION_TYPES = new Set([
'function_declaration',
'function_expression',
'arrow_function',
'method_definition',
'generator_function_declaration',
'generator_function',
'async_function_declaration',
'async_arrow_function',
]);
/** Statement node types that break a basic block (everything else coalesces). */
const CONTROL_FLOW_TYPES = new Set([
'if_statement',
'while_statement',
'do_statement',
'for_statement',
'for_in_statement',
'for_of_statement',
'switch_statement',
'try_statement',
'return_statement',
'break_statement',
'continue_statement',
'throw_statement',
'labeled_statement',
'statement_block',
]);
const LOOP_OR_SWITCH_TYPES = new Set([
'while_statement',
'do_statement',
'for_statement',
'for_in_statement',
'for_of_statement',
'switch_statement',
]);
const startLineOf = (n: SyntaxNode): number => n.startPosition.row + 1;
const endLineOf = (n: SyntaxNode): number => n.endPosition.row + 1;
/** A statement sequence that produced no blocks (empty body) is "transparent". */
type SeqResult = TraversalResult | null;
/**
* Per-function walk state. One instance is created per function so the
* {@link ControlFlowContext}, exception-handler stack, and pending label are
* scoped to that function and never leak across functions.
*/
class TsCfgWalk {
private readonly cfc = new ControlFlowContext();
/** Stack of exception-handler entry blocks (catch/finally) a `throw` jumps to. */
private readonly handlers: number[] = [];
/** Labels awaiting the construct they precede (`outer: inner: for` = both). */
private pendingLabels: string[] = [];
constructor(
private readonly builder: CfgBuilder,
/** Def/use fact extractor (#2082 M2 U1) — phase-2 only; its scope tree is
* already complete, so any walk order resolves names correctly. */
private readonly harvest: TsHarvester,
) {}
/** Statements of a block node, ignoring comments. */
private statementsOf(block: SyntaxNode): SyntaxNode[] {
return block.namedChildren.filter((c) => c.type !== 'comment');
}
/** The `body` block of a node (field, or the first statement_block child). */
private bodyBlockOf(node: SyntaxNode): SyntaxNode | undefined {
return (
node.childForFieldName('body') ?? node.namedChildren.find((c) => c.type === 'statement_block')
);
}
/** Visit a body that may be a `statement_block` or a single statement. */
private visitBody(node: SyntaxNode | undefined | null): SeqResult {
if (!node) return null;
if (node.type === 'statement_block') return this.visitSeq(this.statementsOf(node));
return this.visitStmt(node);
}
/** Wire a sequence of statements, coalescing straight-line runs into blocks. */
visitSeq(stmts: SyntaxNode[]): SeqResult {
let entry: number | undefined;
let dangling: number[] = [];
let openSimple: number | undefined;
for (const stmt of stmts) {
if (CONTROL_FLOW_TYPES.has(stmt.type)) {
openSimple = undefined; // close any open straight-line block
const res = this.visitStmt(stmt);
if (res === null) continue; // transparent (empty nested block)
if (entry === undefined) entry = res.entry;
else this.builder.connect(dangling, res.entry, 'seq');
dangling = [...res.exits];
} else {
// Simple statement — coalesce into the current straight-line block.
if (openSimple === undefined) {
const idx = this.builder.newBlock(
startLineOf(stmt),
endLineOf(stmt),
stmt.text,
'normal',
this.harvest.facts(stmt),
);
if (entry === undefined) entry = idx;
else this.builder.connect(dangling, idx, 'seq');
openSimple = idx;
dangling = [idx];
} else {
this.builder.extendBlock(
openSimple,
endLineOf(stmt),
stmt.text,
this.harvest.facts(stmt),
);
}
}
}
if (entry === undefined) return null;
return { entry, exits: dangling };
}
/** Dispatch one statement to its handler. Non-null except for empty blocks. */
visitStmt(stmt: SyntaxNode): SeqResult {
switch (stmt.type) {
case 'if_statement':
return this.visitIf(stmt);
case 'while_statement':
return this.visitWhile(stmt);
case 'do_statement':
return this.visitDoWhile(stmt);
case 'for_statement':
return this.visitFor(stmt);
case 'for_in_statement':
case 'for_of_statement':
return this.visitForIn(stmt);
case 'switch_statement':
return this.visitSwitch(stmt);
case 'try_statement':
return this.visitTry(stmt);
case 'return_statement':
return this.visitReturn(stmt);
case 'throw_statement':
return this.visitThrow(stmt);
case 'break_statement':
return this.visitBreak(stmt);
case 'continue_statement':
return this.visitContinue(stmt);
case 'labeled_statement':
return this.visitLabeled(stmt);
case 'statement_block':
return this.visitSeq(this.statementsOf(stmt));
default:
return this.visitSimple(stmt);
}
}
private visitSimple(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(
startLineOf(stmt),
endLineOf(stmt),
stmt.text,
'normal',
this.harvest.facts(stmt),
);
return { entry: idx, exits: [idx] };
}
private visitReturn(stmt: SyntaxNode): TraversalResult {
// Harvest the argument expression's uses — `return x` blocks live in this
// dedicated handler, not visitSeq, and were a silently-missed site once.
const idx = this.builder.newBlock(
startLineOf(stmt),
endLineOf(stmt),
stmt.text,
'normal',
this.harvest.facts(stmt),
);
// A return crosses EVERY active finally before reaching EXIT.
wireJumpThroughFinalizers(
this.builder,
idx,
this.cfc.finalizersForReturn(),
this.builder.exitIndex,
'return',
);
return { entry: idx, exits: [] };
}
private visitThrow(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(
startLineOf(stmt),
endLineOf(stmt),
stmt.text,
'normal',
this.harvest.facts(stmt),
);
this.builder.edge(idx, this.currentHandler(), 'throw');
return { entry: idx, exits: [] };
}
private visitBreak(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
const res = this.cfc.resolveBreak(this.labelOf(stmt));
// An unresolved target — a label this visitor doesn't model (a stacked
// outer label like `outer: inner: for`, or a labeled non-loop block) —
// would otherwise leave this block with NO out-edge, stranding it and
// breaking the single-exit invariant a downstream post-dominator / PDG pass
// relies on. Conservatively route an unresolved jump to the function EXIT
// ("escapes the function") and thread ALL active finallys — a superset of
// the truly-crossed set (the real target is somewhere in the function, so
// execution provably runs every finally between the jump and wherever it
// lands... up to the ones the conservative EXIT routing over-includes).
// Sound for dataflow either way: extra paths, never a bypassed finally.
const { target, finalizers } = res ?? {
target: this.builder.exitIndex,
finalizers: this.cfc.finalizersForReturn(),
};
wireJumpThroughFinalizers(this.builder, idx, finalizers, target, 'break');
return { entry: idx, exits: [] };
}
private visitContinue(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
const res = this.cfc.resolveContinue(this.labelOf(stmt));
// See visitBreak: an unresolved label routes to EXIT (threading all
// active finallys) to preserve single-exit without bypassing a finally.
const { target, finalizers } = res ?? {
target: this.builder.exitIndex,
finalizers: this.cfc.finalizersForReturn(),
};
wireJumpThroughFinalizers(this.builder, idx, finalizers, target, 'continue');
return { entry: idx, exits: [] };
}
private visitLabeled(stmt: SyntaxNode): SeqResult {
const body =
stmt.childForFieldName('body') ?? stmt.namedChildren[stmt.namedChildren.length - 1];
const label = this.labelOf(stmt);
if (body && (LOOP_OR_SWITCH_TYPES.has(body.type) || body.type === 'labeled_statement')) {
// Loop/switch consumes the accumulated labels via takeLabels(); a nested
// labeled_statement keeps accumulating (`outer: inner: for` → both
// labels land on the loop frame).
if (label) this.pendingLabels.push(label);
const res = this.visitStmt(body);
this.pendingLabels = []; // clear leftovers if the construct didn't consume
return res;
}
// Labeled NON-loop statement (`blk: { … break blk; … }`): break-to-label
// targets a synthesized join after the body. Routing it to EXIT instead
// (the M1 behavior) removed the real continuation and falsely killed
// every def live at the jump for post-construct uses (tri-review P1).
const labels = [...this.pendingLabels, ...(label ? [label] : [])];
this.pendingLabels = [];
const join = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.cfc.pushLabeledBlock(join, labels);
const res = this.visitBody(body);
this.cfc.pop();
if (res) this.builder.connect(res.exits, join, 'seq');
return { entry: res?.entry ?? join, exits: [join] };
}
private visitIf(stmt: SyntaxNode): TraversalResult {
const cond = stmt.childForFieldName('condition') ?? stmt;
const condBlock = this.builder.newBlock(
startLineOf(stmt),
endLineOf(cond),
cond.text,
'normal',
this.harvest.facts(cond),
);
const exits: number[] = [];
const thenRes = this.visitBody(stmt.childForFieldName('consequence'));
if (thenRes) {
this.builder.edge(condBlock, thenRes.entry, 'cond-true');
exits.push(...thenRes.exits);
} else {
exits.push(condBlock); // empty then — true path falls through
}
const elseNode = this.elseBodyOf(stmt);
if (elseNode) {
const elseRes = this.visitBody(elseNode);
if (elseRes) {
this.builder.edge(condBlock, elseRes.entry, 'cond-false');
exits.push(...elseRes.exits);
} else {
exits.push(condBlock); // empty else block
}
} else {
exits.push(condBlock); // no else — false path falls through to the join
}
return { entry: condBlock, exits: [...new Set(exits)] };
}
/** The else body node (unwraps an `else_clause` wrapper if present). */
private elseBodyOf(ifStmt: SyntaxNode): SyntaxNode | undefined {
const alt = ifStmt.childForFieldName('alternative');
if (!alt) return undefined;
if (alt.type === 'else_clause') {
return alt.childForFieldName('body') ?? alt.namedChildren[0];
}
return alt;
}
private visitWhile(stmt: SyntaxNode): TraversalResult {
const labels = this.takeLabels();
const cond = stmt.childForFieldName('condition') ?? stmt;
const header = this.builder.newBlock(
startLineOf(stmt),
endLineOf(cond),
cond.text,
'normal',
this.harvest.facts(cond),
);
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.cfc.pushLoop(header, loopExit, labels);
const body = this.visitBody(this.bodyBlockOf(stmt));
this.cfc.pop();
if (body) {
this.builder.edge(header, body.entry, 'cond-true');
this.builder.connect(body.exits, header, 'loop-back');
} else {
this.builder.edge(header, header, 'loop-back'); // empty body re-tests
}
this.builder.edge(header, loopExit, 'cond-false');
return { entry: header, exits: [loopExit] };
}
private visitDoWhile(stmt: SyntaxNode): TraversalResult {
const labels = this.takeLabels();
const cond = stmt.childForFieldName('condition') ?? stmt;
const condBlock = this.builder.newBlock(
startLineOf(cond),
endLineOf(cond),
cond.text,
'normal',
this.harvest.facts(cond),
);
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.cfc.pushLoop(condBlock, loopExit, labels);
const body = this.visitBody(this.bodyBlockOf(stmt));
this.cfc.pop();
const backTarget = body ? body.entry : condBlock;
if (body) this.builder.connect(body.exits, condBlock, 'seq');
this.builder.edge(condBlock, backTarget, 'loop-back'); // cond true → run body again
this.builder.edge(condBlock, loopExit, 'cond-false');
return { entry: backTarget, exits: [loopExit] };
}
private visitFor(stmt: SyntaxNode): TraversalResult {
const labels = this.takeLabels();
const init = stmt.childForFieldName('initializer');
const cond = stmt.childForFieldName('condition');
const incr = stmt.childForFieldName('increment');
const header = this.builder.newBlock(
startLineOf(stmt),
cond ? endLineOf(cond) : startLineOf(stmt),
cond ? cond.text : 'for(;;)',
'normal',
cond ? this.harvest.facts(cond) : undefined,
);
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
let incrBlock = header;
if (incr) {
incrBlock = this.builder.newBlock(
startLineOf(incr),
endLineOf(incr),
incr.text,
'normal',
this.harvest.facts(incr),
);
this.builder.edge(incrBlock, header, 'loop-back');
}
this.cfc.pushLoop(incrBlock, loopExit, labels);
const body = this.visitBody(this.bodyBlockOf(stmt));
this.cfc.pop();
if (body) {
this.builder.edge(header, body.entry, 'cond-true');
// With no increment clause the body's exits ARE the back-edge — carry
// the loop-back kind on them (mirroring visitWhile/visitForIn) instead
// of a phantom header→header self-loop that models a path which never
// executes the body. With an increment, the body falls through to the
// increment (`seq`) and the increment carries the loop-back (:338).
this.builder.connect(body.exits, incrBlock, incr ? 'seq' : 'loop-back');
} else {
this.builder.edge(header, incrBlock, 'cond-true');
// Empty body with no increment: the header genuinely re-tests itself.
if (!incr) this.builder.edge(header, header, 'loop-back');
}
this.builder.edge(header, loopExit, 'cond-false');
let entry = header;
if (init) {
const initBlock = this.builder.newBlock(
startLineOf(init),
endLineOf(init),
init.text,
'normal',
this.harvest.facts(init),
);
this.builder.edge(initBlock, header, 'seq');
entry = initBlock;
}
return { entry, exits: [loopExit] };
}
private visitForIn(stmt: SyntaxNode): TraversalResult {
const labels = this.takeLabels();
// Header text is SYNTHESIZED, so facts come from the left/right AST nodes
// directly (the loop variable is a def, the iterated expression a use).
const header = this.builder.newBlock(
startLineOf(stmt),
startLineOf(stmt),
this.forInHeaderText(stmt),
'normal',
this.harvest.forInHeadFacts(stmt),
);
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.cfc.pushLoop(header, loopExit, labels);
const body = this.visitBody(this.bodyBlockOf(stmt));
this.cfc.pop();
if (body) {
this.builder.edge(header, body.entry, 'cond-true');
this.builder.connect(body.exits, header, 'loop-back');
} else {
this.builder.edge(header, header, 'loop-back');
}
this.builder.edge(header, loopExit, 'cond-false');
return { entry: header, exits: [loopExit] };
}
private forInHeaderText(stmt: SyntaxNode): string {
const left = stmt.childForFieldName('left')?.text ?? '';
const right = stmt.childForFieldName('right')?.text ?? '';
return left || right ? `for(${left} … ${right})` : 'for(… in/of …)';
}
private visitSwitch(stmt: SyntaxNode): TraversalResult {
const labels = this.takeLabels();
const value = stmt.childForFieldName('value') ?? stmt;
const dispatch = this.builder.newBlock(
startLineOf(stmt),
endLineOf(value),
value.text,
'normal',
this.harvest.facts(value),
);
const switchExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.cfc.pushSwitch(switchExit, labels);
const body = stmt.childForFieldName('body');
const cases = body
? body.namedChildren.filter((c) => c.type === 'switch_case' || c.type === 'switch_default')
: [];
// `case x:` test expressions live in no block (caseStatements filters the
// value node out) — harvest their uses onto the dispatch block, one record
// per case in source order (a sound over-approximation of JS's in-order
// case evaluation). Conditionally: a later case test only evaluates when
// earlier cases didn't match, so any def inside one is a may-def — as a
// must-def on the always-executed dispatch block it would falsely kill
// prior defs for earlier-matching arms (tri-review).
for (const c of cases) {
const caseValue = c.childForFieldName('value');
if (caseValue) this.builder.attachFacts(dispatch, this.harvest.factsConditional(caseValue));
}
const caseResults = cases.map((c) => this.visitSeq(this.caseStatements(c)));
const hasDefault = cases.some((c) => c.type === 'switch_default');
// entryOf[i] = block a dispatch/fallthrough INTO case i lands on (empty
// cases are transparent — they resolve to the next case, or the exit).
const entryOf: number[] = new Array(cases.length);
let after = switchExit;
for (let i = cases.length - 1; i >= 0; i--) {
entryOf[i] = caseResults[i]?.entry ?? after;
after = entryOf[i];
}
for (let i = 0; i < cases.length; i++) {
this.builder.edge(dispatch, entryOf[i], 'switch-case');
}
if (!hasDefault) this.builder.edge(dispatch, switchExit, 'switch-case'); // no-match path
for (let i = 0; i < cases.length; i++) {
const res = caseResults[i];
if (!res) continue;
const fallTarget = i + 1 < cases.length ? entryOf[i + 1] : switchExit;
this.builder.connect(res.exits, fallTarget, 'fallthrough');
}
this.cfc.pop();
return { entry: dispatch, exits: [switchExit] };
}
private caseStatements(caseNode: SyntaxNode): SyntaxNode[] {
const value = caseNode.childForFieldName('value');
return caseNode.namedChildren.filter((c) => c.id !== value?.id && c.type !== 'comment');
}
private visitTry(stmt: SyntaxNode): SeqResult {
const bodyNode = stmt.childForFieldName('body');
// Single pass over named children — tree-sitter's `namedChildren` getter
// allocates a fresh array on every access, so avoid the double `.find`.
let catchClause: SyntaxNode | undefined;
let finallyClause: SyntaxNode | undefined;
for (let i = 0; i < stmt.namedChildCount; i++) {
const c = stmt.namedChild(i);
if (c?.type === 'catch_clause') catchClause = c;
else if (c?.type === 'finally_clause') finallyClause = c;
}
// Build finally first so its entry is known as both a normal join and a
// handler target. The finally body runs in the OUTER handler context — and
// OUTSIDE this try's finalizer frame: a return inside the finally must not
// thread itself (it threads only outer finallys, matching JS semantics).
const finallyRes = finallyClause
? this.visitSeq(this.statementsOf(this.bodyBlockOf(finallyClause) as SyntaxNode))
: null;
// Finalizer frame for early-exit threading (#2082 M2 U2): active while the
// catch and protected bodies are walked, so a crossing `return`/`break`/
// `continue` inside either routes through the finally. An empty/comment-only
// finally (`finallyRes` null — the #2099-F2 empty-catch bug shape) pushes
// NO frame: it can define nothing, so jumps soundly keep direct edges.
const finFrame = finallyRes ? this.cfc.pushFinalizer(finallyRes.entry) : null;
// A throw inside catch propagates to finally (if any), else the outer handler.
let catchRes: SeqResult = null;
if (catchClause) {
if (finallyRes) this.handlers.push(finallyRes.entry);
catchRes = this.visitSeq(this.statementsOf(this.bodyBlockOf(catchClause) as SyntaxNode));
if (finallyRes) this.handlers.pop();
if (catchRes === null) {
// Empty (or comment-only) catch body — `catch {}`. The clause still
// CATCHES: handler semantics key off the syntactic clause, not the
// traversal result. Treating it as "no catch" sent the swallowed
// exception to the outer handler/EXIT and left post-try code
// unreachable when the body always throws — a hard false-negative
// for downstream taint. Synthesize one empty block spanning the
// clause (entry == sole exit) so exception flow lands in it and
// rejoins the normal continuation. Created BEFORE the protected
// region is walked, so it never receives a spurious throw edge.
const idx = this.builder.newBlock(startLineOf(catchClause), endLineOf(catchClause), '');
catchRes = { entry: idx, exits: [idx] };
}
// `catch (e)` has no header block — the param def gets its OWN
// facts-only block in front of the body entry. It must NOT be prepended
// into the body's entry block: when the catch body STARTS with a loop,
// that entry is the loop HEADER, re-entered on every iteration — the
// param def would re-gen there and falsely KILL loop-carried
// redefinitions of the param (`catch (e) { while (c) { e = fix(e); }
// sink(e); }` would lose the fix→sink fact, a taint false negative).
// The param block becomes the handler entry, which is also semantically
// right: the binding happens exactly once, on handler entry.
const paramFacts = this.harvest.catchParamFacts(catchClause);
if (paramFacts) {
const paramBlock = this.builder.newBlock(
startLineOf(catchClause),
startLineOf(catchClause),
'',
'normal',
paramFacts,
);
this.builder.edge(paramBlock, catchRes.entry, 'seq');
catchRes = { entry: paramBlock, exits: catchRes.exits };
}
}
// Handler for the try body: catch if present, else finally, else outer.
const tryHandler = catchRes?.entry ?? finallyRes?.entry ?? this.currentHandler();
const protectedStart = this.builder.blockCount;
this.handlers.push(tryHandler);
const bodyRes = bodyNode ? this.visitSeq(this.statementsOf(bodyNode)) : null;
this.handlers.pop();
// Conservative exceptional edges: ANY block in the protected region may raise
// to the handler — not just an explicit `throw`, and not just the body ENTRY.
// Edging every block created during the try-body walk keeps exception flow
// sound when the body BRANCHES: an `if` / nested-try / post-branch block whose
// interior blocks would otherwise have no path to the handler — i.e. a taint
// false-negative into `catch` for the downstream PDG analysis. The
// per-function edge cap bounds the count; explicit `throw`s add their own
// (idempotent) edge to the same handler.
if (catchClause || finallyClause) {
for (let b = protectedStart; b < this.builder.blockCount; b++) {
this.builder.edge(b, tryHandler, 'throw');
}
}
// The finalizer frame closes once the protected/catch walks are done; any
// jumps that crossed it left their completion legs on `pending`, wired
// here from the finally's exits (see drainFinalizerPending for the
// finally-override semantics of an always-jumping finally).
if (finFrame && finallyRes) {
this.cfc.pop();
drainFinalizerPending(this.builder, finFrame, finallyRes.exits);
}
const exits: number[] = [];
if (finallyRes) {
// Normal completion of try AND catch both flow through finally.
if (bodyRes) this.builder.connect(bodyRes.exits, finallyRes.entry, 'seq');
if (catchRes) this.builder.connect(catchRes.exits, finallyRes.entry, 'seq');
exits.push(...finallyRes.exits);
// No catch → an exception re-propagates out after finally runs.
if (!catchRes) this.builder.connect(finallyRes.exits, this.currentHandler(), 'throw');
} else {
if (bodyRes) exits.push(...bodyRes.exits);
if (catchRes) exits.push(...catchRes.exits);
}
const entry = bodyRes?.entry ?? finallyRes?.entry ?? catchRes?.entry;
if (entry === undefined) return null;
return { entry, exits: [...new Set(exits)] };
}
/** Nearest enclosing exception handler, or the function EXIT. */
private currentHandler(): number {
return this.handlers.length ? this.handlers[this.handlers.length - 1] : this.builder.exitIndex;
}
/** Consume the labels awaiting the loop/switch this call is building. */
private takeLabels(): string[] {
const labels = this.pendingLabels;
this.pendingLabels = [];
return labels;
}
private labelOf(stmt: SyntaxNode): string | undefined {
const id =
stmt.childForFieldName('label') ??
stmt.namedChildren.find((c) => c.type === 'statement_identifier');
return id?.text;
}
}
/** Build the CFG for one TS/JS function node (or `undefined` if not a function). */
function buildFunctionCfg(fnNode: SyntaxNode, filePath: string): FunctionCfg | undefined {
if (!TS_FUNCTION_TYPES.has(fnNode.type)) return undefined;
const startLine = startLineOf(fnNode);
const endLine = endLineOf(fnNode);
const startColumn = fnNode.startPosition.column;
const builder = new CfgBuilder(filePath, startLine, endLine, startColumn);
const body = fnNode.childForFieldName('body');
if (!body) return undefined; // overload signature / abstract method — no body
// Phase-1 declaration pre-scan (#2082 M2 U1) — must complete before any
// facts are extracted; the CFG walk below is not source-order.
const harvest = new TsHarvester(fnNode);
// Parameters define at ENTRY (facts only — never touch the entry block's
// text or span: bench fingerprints and CFG snapshots include block text).
const paramFacts = harvest.paramFacts();
if (paramFacts) builder.attachFacts(builder.entryIndex, paramFacts);
if (body.type !== 'statement_block') {
// Expression-bodied arrow: `() => expr` — one block whose value is returned.
// Lives outside the walk class, so it harvests explicitly.
const blk = builder.newBlock(
startLineOf(body),
endLineOf(body),
body.text,
'normal',
harvest.facts(body),
);
builder.edge(builder.entryIndex, blk, 'seq');
builder.edge(blk, builder.exitIndex, 'return');
return builder.finish(harvest.table());
}
const walk = new TsCfgWalk(builder, harvest);
const res = walk.visitSeq(body.namedChildren.filter((c) => c.type !== 'comment'));
if (!res) {
builder.edge(builder.entryIndex, builder.exitIndex, 'seq'); // empty body
return builder.finish(harvest.table());
}
builder.edge(builder.entryIndex, res.entry, 'seq');
builder.connect(res.exits, builder.exitIndex, 'seq'); // normal fall-off → EXIT
return builder.finish(harvest.table());
}
/** Whether a node is a TS/JS function this visitor builds a CFG for. */
function isFunction(node: SyntaxNode): boolean {
return TS_FUNCTION_TYPES.has(node.type);
}
/** The TS/JS CFG visitor (shared by TypeScript and JavaScript). */
export function createTypeScriptCfgVisitor(): CfgVisitor<SyntaxNode> {
return { buildFunctionCfg, isFunction };
}
export { TS_FUNCTION_TYPES };
@@ -35,7 +35,10 @@ import type { MethodExtractor } from './method-types.js';
import type { VariableExtractor } from './variable-types.js';
import type { ImportResolverFn } from './import-resolvers/types.js';
import type { SyntaxNode } from './utils/ast-helpers.js';
import type { CfgVisitor } from './cfg/types.js';
import type { NodeLabel } from 'gitnexus-shared';
import type Parser from 'tree-sitter';
import type { ExtractedDecoratorRoute } from './workers/parse-worker.js';
// ── Shared type aliases ────────────────────────────────────────────────────
/** Tree-sitter query captures: capture name → AST node (or undefined if not captured). */
@@ -185,6 +188,12 @@ interface LanguageProviderConfig {
* `undefined` when no constraints exist / the node isn't a templated
* function. Languages without SFINAE / concept semantics leave this
* undefined and the disambiguation is a pass-through.
*
* Cloneability contract: the returned payload crosses the worker boundary
* via structured clone, so it MUST be structured-clone-safe (no functions,
* symbols, or tree-sitter `SyntaxNode`s — only plain data). Wrap the return
* with `assertCloneable` from `workers/clone-safety.ts` so a future leak is a
* compile error at the source instead of a runtime DataCloneError (#2143).
*/
readonly extractTemplateConstraints?: (definitionNode: SyntaxNode) => unknown;
@@ -236,6 +245,22 @@ interface LanguageProviderConfig {
* Default: undefined (no route files). */
readonly isRouteFile?: (filePath: string) => boolean;
/**
* Extract decorator-style route annotations from a parsed file.
*
* When defined, the parse worker calls this after per-file capture processing
* to extract framework route definitions that require AST-level analysis beyond
* generic `@decorator` captures (e.g., Java Spring class-level prefix joining,
* multi-class handling). The returned routes are appended to `decoratorRoutes`.
*
* Default: undefined (no language-specific decorator route extraction).
*/
readonly extractDecoratorRoutes?: (
tree: Parser.Tree,
filePath: string,
lineOffset: number,
) => ExtractedDecoratorRoute[];
// ── Noise filtering ────────────────────────────────────────────────
/** Built-in/stdlib names that should be filtered from the call graph for this language.
* Default: undefined (no language-specific filtering). */
@@ -325,13 +350,28 @@ interface LanguageProviderConfig {
* disk store WITHOUT a main-thread re-parse. The main thread restores them
* via the matching `ScopeResolver.applyCaptureSideChannel` hook.
*
* MUST return plain data (objects / arrays / primitives) so it round-trips
* through `JSON.stringify` + the parsedfile-store interning reviver.
* Cloneability contract: MUST return plain data (objects / arrays /
* primitives — no functions, symbols, or tree-sitter `SyntaxNode`s) so it
* survives BOTH the worker→main structured clone AND `JSON.stringify` + the
* parsedfile-store interning reviver. Wrap the return with `assertCloneable`
* from `workers/clone-safety.ts` so a future non-serializable leak is a
* compile error at the source instead of a runtime DataCloneError (#2143).
*
* Default: undefined (provider has no capture-time module-level side effects).
*/
readonly collectCaptureSideChannel?: (filePath: string) => unknown;
/**
* Per-language control-flow-graph builder (#2081 M1, PDG/taint substrate).
* Invoked IN THE PARSE WORKER (where the AST lives) for each function node,
* gated on the `--pdg` opt-in; the resulting per-function CFGs are serialized
* onto `ParsedFile.cfgSideChannel` and emitted as BasicBlock nodes + CFG
* edges during scope-resolution. `TNode` is `SyntaxNode` for the tree-sitter
* languages. Default: undefined (language has no CFG support yet — TS/JS are
* the M1 set).
*/
readonly cfgVisitor?: CfgVisitor<SyntaxNode>;
/**
* Interpret a raw `@import.statement` capture group into a `ParsedImport`.
* The central finalize algorithm resolves `ParsedImport.targetRaw` to a
+18 -5
View File
@@ -65,7 +65,11 @@ import {
cppReceiverBinding,
collectCppCaptureSideChannel,
} from './cpp/index.js';
import { extractCppTemplateConstraints } from './cpp/constraint-extractor.js';
import {
extractCppTemplateConstraints,
type CppConstraintPayload,
} from './cpp/constraint-extractor.js';
import { assertCloneable } from '../workers/clone-safety.js';
const C_BUILT_INS: ReadonlySet<string> = new Set([
'printf',
@@ -405,7 +409,11 @@ export const cProvider = defineLanguage({
// `static` functions look non-file-local on the main thread and leak into
// cross-file global free-call resolution / wildcard imports. See
// `c/capture-side-channel.ts`.
collectCaptureSideChannel: collectCStaticLinkageSideChannel,
// `assertCloneable` is a runtime identity; it makes a future non-serializable
// value in the side-channel payload a compile error here, at the source, rather
// than a DataCloneError at the worker boundary (#2143).
collectCaptureSideChannel: (filePath) =>
assertCloneable(collectCStaticLinkageSideChannel(filePath)),
interpretImport: interpretCImport,
interpretTypeBinding: interpretCTypeBinding,
bindingScopeFor: cBindingScopeFor,
@@ -480,7 +488,7 @@ export const cppProvider = defineLanguage({
// just populated for this file into plain data on `ParsedFile.captureSideChannel`,
// so the main thread can restore them via `applyCaptureSideChannel` WITHOUT a
// re-parse (#1983). See `cpp/capture-side-channel.ts`.
collectCaptureSideChannel: collectCppCaptureSideChannel,
collectCaptureSideChannel: (filePath) => assertCloneable(collectCppCaptureSideChannel(filePath)),
interpretImport: interpretCppImport,
interpretTypeBinding: interpretCppTypeBinding,
bindingScopeFor: cppBindingScopeFor,
@@ -501,7 +509,9 @@ export const cppProvider = defineLanguage({
* functions whose constraints the extractor can't model — both cases
* result in no constraint suffix on the node ID.
*/
function extractCppTemplateConstraintsForProvider(definitionNode: SyntaxNode): unknown {
function extractCppTemplateConstraintsForProvider(
definitionNode: SyntaxNode,
): CppConstraintPayload | undefined {
// Walk up to the enclosing template_declaration. Bound the walk so we
// can't accidentally land on a far-ancestor template_declaration that
// wraps an unrelated function.
@@ -530,5 +540,8 @@ function extractCppTemplateConstraintsForProvider(definitionNode: SyntaxNode): u
}
break;
}
return extractCppTemplateConstraints(templateDecl, declarator);
// Guard the boundary at the source: a future non-cloneable member of the
// constraint payload becomes a compile error here, not a runtime
// DataCloneError at the worker post (#2143).
return assertCloneable(extractCppTemplateConstraints(templateDecl, declarator));
}
@@ -222,8 +222,14 @@ function findFuncDeclarator(node: SyntaxNode): SyntaxNode | null {
}
return null;
}
// Unwrap pointer_declarator / reference_declarator
while (decl.type === 'pointer_declarator' || decl.type === 'reference_declarator') {
// Unwrap declarator wrappers. Deleted free functions are represented as
// `init_declarator(function_declarator, delete_expression)` by
// tree-sitter-cpp 0.23.
while (
decl.type === 'pointer_declarator' ||
decl.type === 'reference_declarator' ||
decl.type === 'init_declarator'
) {
const next = decl.childForFieldName('declarator');
if (next === null) {
// reference_declarator may not use field name
@@ -163,6 +163,13 @@ export function emitCppScopeCaptures(
'true',
);
}
if (hasDeletedMethodClause(fnNode, grouped['@declaration.name']?.text)) {
grouped['@declaration.is-deleted'] = syntheticCapture(
'@declaration.is-deleted',
fnNode,
'true',
);
}
// Detect static storage class (file-local linkage)
if (hasStaticStorageClass(fnNode)) {
@@ -1685,7 +1692,13 @@ function extractDeclaratorLeafName(node: SyntaxNode): string | null {
let cur: SyntaxNode = node;
let safety = 16;
while (safety-- > 0) {
if (cur.type === 'identifier' || cur.type === 'type_identifier') return cur.text;
if (
cur.type === 'identifier' ||
cur.type === 'type_identifier' ||
cur.type === 'operator_name'
) {
return cur.text;
}
// Common wrapper nodes — follow the 'declarator' field when present.
const next =
cur.childForFieldName('declarator') ??
@@ -1713,6 +1726,25 @@ function hasExplicitSpecifier(node: SyntaxNode): boolean {
return /\bexplicit\b/.test(node.text.slice(0, 128));
}
function hasDeletedMethodClause(node: SyntaxNode, callableName: string | undefined): boolean {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type === 'delete_method_clause') return true;
// tree-sitter-cpp 0.23 parses a deleted free-function declaration as
// `declaration > init_declarator > delete_expression`, while class
// members use the dedicated `delete_method_clause`.
if (
child?.type === 'init_declarator' &&
child.childForFieldName('value')?.type === 'delete_expression' &&
callableName !== undefined &&
extractDeclaratorLeafName(child.childForFieldName('declarator') ?? child) === callableName
) {
return true;
}
}
return false;
}
/**
* Check if a C++ function_definition or declaration has `static` storage class.
*/
@@ -194,6 +194,29 @@ const CPP_SCOPE_QUERY = `
declarator: (function_declarator
declarator: (identifier) @declaration.name)) @declaration.function
;; tree-sitter-cpp 0.23 represents a deleted free function as an
;; init_declarator whose value is a delete_expression.
(declaration
declarator: (init_declarator
declarator: (function_declarator
declarator: (identifier) @declaration.name)
value: (delete_expression))) @declaration.function
;; Deleted free operator declaration.
(declaration
declarator: (init_declarator
declarator: (function_declarator
declarator: (operator_name) @declaration.name)
value: (delete_expression))) @declaration.function
;; Deleted free function with a pointer return type.
(declaration
declarator: (init_declarator
declarator: (pointer_declarator
declarator: (function_declarator
declarator: (identifier) @declaration.name))
value: (delete_expression))) @declaration.function
;; Free operator prototype: std::ostream& operator<<(std::ostream&, T)
(declaration
declarator: (function_declarator
@@ -13,6 +13,7 @@ import { javaClassConfig } from '../class-extractors/configs/jvm.js';
import { defineLanguage } from '../language-provider.js';
import type { AstFrameworkPatternConfig } from '../language-provider.js';
import { javaTypeConfig } from '../type-extractors/jvm.js';
import { extractSpringRoutes } from '../route-extractors/spring.js';
import { javaExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
import { javaImportConfig } from '../import-resolvers/configs/jvm.js';
@@ -126,4 +127,7 @@ export const javaProvider = defineLanguage({
arityCompatibility: javaArityCompatibility,
resolveImportTarget: resolveJavaImportTarget,
orderSameNameTypeCandidates: orderJavaSameNameTypeCandidates,
// ── Route extraction ──
extractDecoratorRoutes: extractSpringRoutes,
});
@@ -11,6 +11,7 @@ import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { kotlinClassConfig } from '../class-extractors/configs/jvm.js';
import { defineLanguage } from '../language-provider.js';
import { assertCloneable } from '../workers/clone-safety.js';
import { kotlinTypeConfig } from '../type-extractors/jvm.js';
import { kotlinExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
@@ -182,7 +183,11 @@ export const kotlinProvider = defineLanguage({
// so the main thread can restore them via `applyCaptureSideChannel` WITHOUT a
// re-parse (#1983). Without this, companion/static dispatch emits no CALLS
// edges on the worker path. See `kotlin/capture-side-channel.ts`.
collectCaptureSideChannel: collectKotlinCaptureSideChannel,
// `assertCloneable` is a runtime identity; it makes a future non-serializable
// value in the side-channel payload a compile error here, at the source, rather
// than a DataCloneError at the worker boundary (#2143).
collectCaptureSideChannel: (filePath) =>
assertCloneable(collectKotlinCaptureSideChannel(filePath)),
interpretImport: interpretKotlinImport,
interpretTypeBinding: interpretKotlinTypeBinding,
bindingScopeFor: kotlinBindingScopeFor,
@@ -1,7 +1,8 @@
import Parser from 'tree-sitter';
import { SupportedLanguages } from 'gitnexus-shared';
// `tree-sitter-kotlin` is an optionalDependency that may be absent on a default
// install (or fail its native build). Loaded lazily + guarded via parser-loader
// `tree-sitter-kotlin` is a vendored grammar (loaded from vendor/ by absolute
// path, never node_modules — vendored-grammars.ts / #2111) that may be absent on
// a platform without a matching prebuild. Loaded lazily + guarded via parser-loader
// rather than statically imported: this module is pulled onto the main thread
// eagerly by the scope-resolution registry and the language-provider index, so
// a top-level `import Kotlin from 'tree-sitter-kotlin'` would throw
@@ -16,6 +16,7 @@ import {
javascriptClassConfig,
} from '../class-extractors/configs/typescript-javascript.js';
import type { SyntaxNode } from '../utils/ast-helpers.js';
import { createTypeScriptCfgVisitor } from '../cfg/visitors/typescript.js';
import { typeConfig as typescriptConfig } from '../type-extractors/typescript.js';
import { tsExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
@@ -351,6 +352,8 @@ export const typescriptProvider = defineLanguage({
// canonical capture vocabulary in ./typescript/query.ts
// (TYPESCRIPT_SCOPE_QUERY constant).
emitScopeCaptures: emitTsScopeCaptures,
// CFG/PDG substrate (#2081 M1) — runs in the worker on a --pdg run.
cfgVisitor: createTypeScriptCfgVisitor(),
interpretImport: interpretTsImport,
interpretTypeBinding: interpretTsTypeBinding,
bindingScopeFor: tsBindingScopeFor,
@@ -412,6 +415,8 @@ export const javascriptProvider = defineLanguage({
// JSDoc type bindings) live in ./javascript/captures.ts.
// See ./javascript/index.ts for the full per-module rationale.
emitScopeCaptures: emitJsScopeCaptures,
// CFG/PDG substrate (#2081 M1) — TS and JS share the same grammar family.
cfgVisitor: createTypeScriptCfgVisitor(),
interpretImport: interpretJsImport,
interpretTypeBinding: interpretJsTypeBinding,
bindingScopeFor: jsBindingScopeFor,
@@ -42,18 +42,11 @@ function findFunctionDeclarator(node: SyntaxNode): SyntaxNode | null {
return null;
}
/**
* Detect `= delete` and `= default` special member function declarations.
* These are not callable methods and should be suppressed from extraction.
* tree-sitter-cpp ^0.23.4 emits `delete_method_clause` / `default_method_clause`
* as named children of the function_definition node.
*/
function isDeletedOrDefaulted(node: SyntaxNode): boolean {
/** Detect a C++ special member clause by its tree-sitter node type. */
function hasSpecialMethodClause(node: SyntaxNode, clauseType: string): boolean {
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type === 'delete_method_clause' || child?.type === 'default_method_clause') {
return true;
}
if (child?.type === clauseType) return true;
}
return false;
}
@@ -67,10 +60,6 @@ function extractCppMethodName(node: SyntaxNode): string | undefined {
const funcDecl = findFunctionDeclarator(node);
if (!funcDecl) return undefined;
// Suppress `= delete` and `= default` special members — these are not callable
// methods and should not appear in HAS_METHOD edges.
if (isDeletedOrDefaulted(node)) return undefined;
const nameNode = funcDecl.childForFieldName('declarator');
if (!nameNode) return undefined;
// destructor_name: ~ClassName
@@ -387,6 +376,10 @@ export const cppMethodConfig: MethodExtractionConfig = {
}
return false;
},
isDeleted(node) {
return hasSpecialMethodClause(node, 'delete_method_clause');
},
};
// ---------------------------------------------------------------------------
@@ -252,6 +252,7 @@ function buildMethod(
...(config.isAsync?.(node) ? { isAsync: true } : {}),
...(config.isPartial?.(node) ? { isPartial: true } : {}),
...(config.isConst?.(node) ? { isConst: true } : {}),
...(config.isDeleted?.(node) ? { isDeleted: true } : {}),
annotations: config.extractAnnotations?.(node) ?? [],
sourceFile: context.filePath,
line: node.startPosition.row + 1,
@@ -33,6 +33,7 @@ export interface MethodInfo {
isAsync?: boolean;
isPartial?: boolean;
isConst?: boolean;
isDeleted?: boolean;
annotations: string[];
sourceFile: string;
line: number;
@@ -84,6 +85,7 @@ export interface MethodExtractionConfig {
isAsync?: (node: SyntaxNode) => boolean;
isPartial?: (node: SyntaxNode) => boolean;
isConst?: (node: SyntaxNode) => boolean;
isDeleted?: (node: SyntaxNode) => boolean;
/** Owner node types where member functions are effectively static (e.g.
* Ruby singleton_class, Kotlin companion_object / object_declaration).
* When the ownerNode matches one of these types, isStatic is forced true. */
@@ -131,6 +131,7 @@ export interface AddMetadata {
templateArguments?: string[];
ownerId?: string;
qualifiedName?: string;
isDeleted?: boolean;
}
/**
@@ -285,6 +286,7 @@ export const createSymbolTable = (): InternalSymbolTable => {
? { templateArguments: metadata.templateArguments }
: {}),
...(metadata?.ownerId !== undefined ? { ownerId: metadata.ownerId } : {}),
...(metadata?.isDeleted === true ? { isDeleted: true } : {}),
};
// A. File Index — unconditional.
@@ -7,6 +7,7 @@ import { accumulateExportedTypesFromParsedNode, type ExportedTypeMap } from './c
import type { ParsedFile } from 'gitnexus-shared';
import { WorkerPool } from './workers/worker-pool.js';
import type { SkippedPath } from './workers/clone-safety.js';
import { logger } from '../logger.js';
import type {
ParseWorkerResult,
@@ -103,6 +104,7 @@ export const mergeChunkResults = (
templateArguments: sym.templateArguments,
ownerId: sym.ownerId,
qualifiedName: sym.qualifiedName,
isDeleted: sym.isDeleted,
});
}
if (exportedTypeMap) {
@@ -196,6 +198,29 @@ export const dispatchChunkParse = async (
logger.warn(` Skipped unsupported languages: ${summary}`);
}
// Clone-safety telemetry (#2112): files whose parse output carried a value
// the structured-clone algorithm couldn't serialize across the worker
// boundary. The worker sanitized/dropped the offending value so the run
// could complete; surface the (rare) data loss so it's visible and the
// offending extractor can be fixed at source.
const skippedPaths: SkippedPath[] = [];
for (const result of chunkResults) {
for (const entry of result.skippedPaths ?? []) skippedPaths.push(entry);
}
if (skippedPaths.length > 0) {
// Keep the per-file reason ("stripped N value(s) from nodes" /
// "dropped non-serializable parsedFiles entry") — it distinguishes a
// recoverable strip from a whole-record drop, which a path-only line loses.
const shown = skippedPaths
.slice(0, 10)
.map((e) => `${e.path} (${e.reason})`)
.join(', ');
const more = skippedPaths.length > 10 ? ` …and ${skippedPaths.length - 10} more` : '';
logger.warn(
` Sanitized ${skippedPaths.length} file(s) with non-serializable parse output: ${shown}${more}`,
);
}
onFileProgress?.(total, total, 'done');
return chunkResults;
};
@@ -36,6 +36,7 @@ import {
restoreDurableParsedFileShard,
} from '../../../storage/parsedfile-store.js';
import type { ParseWorkerResult } from '../workers/parse-worker.js';
import { DEFAULT_PDG_MAX_FUNCTION_LINES } from '../cfg/collect.js';
import type { WorkerExtractedData } from '../parsing-processor.js';
import {
processRoutesFromExtracted,
@@ -461,6 +462,10 @@ export async function runChunkedParseAndResolve(
// Initialized below before the chunk loop (same deferred-init pattern
// as `parsedFileStorePath`); this closure only runs from the loop.
durableParsedFileStoragePath: durableParsedFileDir,
// CFG/PDG opt-in (#2081 M1) — baked into each worker's workerData so the
// worker builds + attaches cfgSideChannel. Off by default.
pdg: options?.pdg === true,
pdgMaxFunctionLines: options?.pdgMaxFunctionLines,
// Fan each chunk across the whole pool (#worker-idle): without this a
// chunk smaller than the 8 MB sub-batch cap became a single job on a
// single worker. Honors an explicit `subBatchMaxBytes` / env override.
@@ -737,7 +742,21 @@ export async function runChunkedParseAndResolve(
filePath: f.path,
contentHash: fileContentHash(f.content),
}));
chunkHash = computeChunkHash(entries);
chunkHash = computeChunkHash(
entries,
// Only worker-visible pdg config participates in the key —
// pdgMaxEdgesPerFunction is emit-time-only and deliberately
// excluded (see PdgCacheKey in parse-cache.ts; #2099 F3). The line
// cap is RESOLVED to the worker's default before folding so an
// explicit-default run shares the default run's keys (the worker
// output is byte-identical either way).
options?.pdg === true
? {
pdg: true,
maxFunctionLines: options?.pdgMaxFunctionLines ?? DEFAULT_PDG_MAX_FUNCTION_LINES,
}
: false,
);
}
const cachedRaw =

Some files were not shown because too many files have changed in this diff Show More