Commit graph

5 commits

Author SHA1 Message Date
Felipe
c2ca132620
fix: web citation/code panel bugs and serve analyze/route hardening (#3348)
* chore: ignore local Vercel link artifacts

Keep .vercel and env files out of the repo after a local preview link.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): repair citation chips, code panel line math, stale agent state

Audit findings in the web client, each verified against the source:

- RightPanel: citation chips ([[path:10-20]], [[Class:Foo]]) were wired to a
  stub resolver that always returned null, so clicking any citation did
  nothing. Expose resolveFilePath from useAppState and use it.
- CodeReferencesPanel: graph startLine/endLine are 1-based but were treated
  as 0-based, so the highlighted range and scroll target were off by one
  line; AI citation cards always rendered "code not available" because the
  snippet loader was a stub. Fetch per-citation snippets via /api/file.
- useAppState: sendChatMessage read llmSettings.activeProvider outside its
  deps (stale provider capabilities after switching provider);
  initializeAgent trapped projectName at '' for callers without an override
  (system prompt labelled the codebase "project"); the embeddings 409 dedup
  matched a message the server never sends for same-repo jobs.
- tools.ts impact: for path targets every symbol defined in the file shares
  the filePath, so the disambiguation always picked the first row and could
  analyze an arbitrary symbol while reporting a file impact. Prefer the File
  node.
- useSigma: the layout timeout called stop() but never kill(), leaking one
  ForceAtlas2 Web Worker plus four graph listeners per completed layout.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(server): release repo lock on cancel, IPC-first worker cancel, route hardening

Audit findings in gitnexus/src, each verified against the source:

- analyze-launch: cancelJob marks the job failed before the worker exits,
  so the exit handler's terminal early-return skipped releaseLockOnce and
  the repo stayed locked ("Another job is already active") until restart.
  Release on terminal exit and when forkWorker bails on a terminal job.
- analyze-job: cancellation now sends { type: 'cancel' } over IPC first and
  signals only after a 15s grace. On Windows child.kill('SIGTERM') is a
  forceful termination, so leading with it could kill the worker inside a
  LadybugDB write. Mirrors core/auto-sync/analysis-worker-launch.
- api resolveRepo: a job that FAILED during the hold-queue wait fell through
  to the { __timedOut } sentinel ("taking longer than expected") instead of
  404; only /api/repo checked the sentinel, so graph/query/search/file/grep/
  embed/delete crashed on entry.storagePath with a 500 after a 5 minute hang.
  Return null on failed jobs and check the sentinel in every consumer.
- api processes/process/clusters/cluster: resolve ?repo= through the HTTP
  resolver (documented policy on resolveRegisteredRepoEntry) and pass the
  registered absolute path to the backend; add the standard rate limiter.
  The raw param previously reached the MCP resolver, which runs a
  cwd-relative realpathSync probe + registry refresh on a bare-name miss
  and accepts unambiguous partial names.
- api body handling: Express 5 leaves req.body undefined without a JSON
  content type, turning "Missing X" 400s into TypeError 500s; body-parser
  4xx errors (malformed JSON, over-limit) were also reported as 500.
- /api/file: the lexical path.relative check cannot see symlinks; re-check
  containment on realpath so a cloned repo containing evil -> /etc/passwd
  cannot read outside the root.
- repo-manager unregisterRepo: used the lenient reader, so a transient read
  error (EBUSY/EPERM racing another process's atomic rename) turned into
  writing [] and deregistering every repo. Use the strict-if-present reader.
- clean --branch: compared registry paths with raw path.resolve instead of
  the canonical registryPathEquals used everywhere else (macOS /private/var,
  Windows short names / drive-letter case) and reported indexed branches as
  not indexed.

Tests: cancelJob IPC-before-signal contract; /api/file symlink escape (403)
and in-repo symlink (200), skipped where the host cannot create symlinks.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: keep example env files visible and ignore local Cursor config

.env* also hid gitnexus/.env.example and eval/.env.example. .vercel was already ignored. The web app's .cursor/ stays local, including its MCP file.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add realtime execution ops dashboard for Vercel monitoring.

Expose /api/ops snapshots over the local serve process and a ?view=ops SPA panel so analyze/embed jobs can be watched live from the hosted web UI.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: address gitnexus-check review on cite/embed/resolve paths

Pass req into resolveRepo for process/cluster routes, tighten same-repo embed 409 handling, guard empty citation paths, fix snippet retry races, and drop the lone-File ambiguity fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ops): harden CORS, redact paths, and bound ops streams

Restrict Vercel CORS to exact production hosts, omit raw repo paths/URLs from the unauthenticated ops feed, rate-limit and cap SSE connections, and fix dashboard SSE/poll edge cases.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): stabilize citation fetches and file impact matching

Retry cancelled snippet loads without duplicate in-flight reads, cap range-less citation downloads, and make impact file matching unique-suffix-aware with a synthetic File target when LIMIT drops the File node.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web/ops): close gitnexus-check review threads on SSE and redaction

Cap citation reads, reconnect ops on applied server URL, skip SSE onError after abort, and strip URL query/fragment from public repoName.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ops): abort SSE on poll fallback and harden repoName parsing

Prevent dual SSE+poll after a failed safety snapshot, skip overlapping poll ticks, ignore aborted streamSSE onError, and basename Windows drive-letter URLs so ops never leaks path segments.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ops/web): close gitnexus-check threads on SSE budget and credential leak

Keep finite SSE retries across short 200s, strip backend URL userinfo before ?server=, and redact progress messages on the public ops feed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3348)

Restore 0-based GraphNode line math, redact public job poll/error fields, fix omit-?repo= 400, hold the analyze lock across cancel-during-settle, and restore the Vercel shared compile.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): contain leftover-slot reclaim to the slot and keep --stale status honest

Preview and force now share one branches/ containment rule, nested junctions cannot walk a sibling index, and a mid-loop git failure no longer claims leftovers were not deleted after a successful rm.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(cli): share leftover-slot helpers without changing reclaim behavior

Pull the rolling I/O pool and owned-cwd storage lookup into one place so clean --stale/--branch and leftover listing stop restating the same ownership and concurrency paths.

Co-authored-by: Cursor <cursoragent@cursor.com>

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

* Address PR review feedback (#3348)

Keep the omitted-repo snapshot instead of re-listing, stop citation and ops races, redact full public repo URLs, and restore fake timers in teardown.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address remaining PR review feedback (#3348)

Store graph node citation lines as 0-based offsets and correct the default-port origin comment.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address remaining PR review feedback (#3348)

Redact public SSE progress text, stop citation append retries from
cancelling in-flight reads, and keep the ops dashboard from showing a
stale snapshot or clearing a failed Connect.

Note: pre-existing failure in incremental-index-extension-dml-gate and other lbug/env unit tests not addressed by this PR.
Co-authored-by: Cursor <cursoragent@cursor.com>

* Address remaining PR review feedback (#3348)

Prune citation snippets when AI refs are cleared, and poll /api/ops at 2s so the fallback stays under the 60/min limit.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ci): apply prettier class order for format check (#3348)

CI quality/format runs root-only npm ci, so prettier-plugin-tailwindcss
sorts scrollbar-thin without the web Tailwind catalog.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3348)

- Require a unique suffix match for graph-backed citation paths so
  ambiguous names like index.ts no longer open the first graph file.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3348)

- Require a path-component boundary so unique citation suffixes cannot match filename substrings like myindex.ts

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(server): redact filesystem paths in ops text

Unauthenticated /api/ops and poll replay worker errors. URLs were
scrubbed but home-directory and Windows paths still leaked.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(server): omit repoPath from public SSE frames

/api/ops lists job ids, so the unauthenticated progress stream
must not replay the analyzed filesystem path.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): treat embed lock 409 as a busy error

Analyze and embed share the same lock string. Mapping that 409 to
embedding hid an in-flight analyze as a successful embed start.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): bound impact File path suffix matches

Unbounded endsWith let lib/foo.ts select src/mylib/foo.ts. Require
an exact path or a unique /suffix, matching resolveUniqueIndexedPath.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(server): keep canceled analyze slot until exit

Marking failed before the worker exited let a second POST start
cloneOrPull against a LadybugDB file still being written.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(server): skip Windows SIGTERM on job dispose

child.kill('SIGTERM') is TerminateProcess there. Ask over IPC first
and leave the 15s grace timer to SIGKILL.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(server): let process routes skip the analyze hold

GET /api/processes and /api/clusters always waited up to 300s.
?awaitAnalysis=false fails fast; default still waits like /api/repo.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(web): cover public ops and analyze SSE user flows

Lock the unauthenticated dashboard and analyze complete/fail/cancel paths so a leaked repoPath, token, or home path cannot ship unnoticed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3348)

Keep caller cancel reasons over the worker's generic IPC, skip publish while cancel is pending, and redact scp-style remotes on the public ops feed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address remaining PR review feedback (#3348)

Release the analyze slot when a worker fails to spawn, and omit branch refs from the unauthenticated ops feed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(server): do not reuse an analyze job that is pending cancel

A dying same-repo job still occupies the single slot; 202-reuse would
attach a new client to a cancel in flight.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(server): hold the analyze lock until the worker exits after cancel

Cancel error IPC used to drop the repo lock while the child was still
checkpointing. Abort settle immediately on pending cancel so the slot
is not held for a 60s disk poll.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(server): redact known repo paths with spaces in public ops text

Known repoPath/repoUrl literals are replaced first so a clone dir with
spaces cannot leak past the whitespace-bounded path regex.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(server): return the live job on analyze and embed DELETE

Hard-coding failed made clients retry immediately and 409 while the
child still occupied the slot. resolveRepo now returns not-found as
soon as that job fails instead of waiting out the hold timeout.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(web): drive public analyze and ops through a live gitnexus serve

Spawn the real backend and observe requests instead of intercepting
them, so slot occupancy, redaction, and reconnect stay honest.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(web): reuse code-panel helpers and drop dead UI aliases

Citation fetches already had selectedNodeFileRange and snippetRepoKey;
the impact File suffix filter already handled exact paths.

Co-authored-by: Cursor <cursoragent@cursor.com>

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

* Address PR review feedback (#3348)

- Skip createJob reuse after cancel IPC is consumed while the child remains
- Hold the repo lock until exit when complete IPC races a pending cancel
- Scrub full remote URLs before known repoUrl prefixes in public ops text
- Reject unique impact File suffix matches from a truncated LIMIT 10 page
- Make live e2e helpers bound probes, clean up failed startups, and wait out the cancel slot

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): open live e2e pages on the Vite host CI actually bound

Absolute 127.0.0.1:5173 navigation refused on Actions because wait-on
and Vite use localhost (often ::1). Honor FRONTEND_URL when set, else
pick the first of localhost / 127.0.0.1 that answers.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3348)

Hold the analyze lock until worker exit when cancel aborts settle, and assert GitLab failure chrome does not leak host or path.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): keep live analyze e2e under the analyze rate limit

POST /api/analyze allows 10 requests per minute per IP. The slot-free
helper re-POSTed every 400ms while a cancelled worker was exiting, spent
that budget, and the lock test's hold request got 429 instead of 202.

- postAnalyze waits out a 429 using the RateLimit reset and retries
- slot polling backs off to 2s and leaves a small POST budget for callers
- the slot probe is a clone that fails before any worker fork, so the
  probe itself no longer holds the slot after reporting failed
- the lock test holds the slot with a real local analyze
- token and GitLab tests wait for a free slot before posting from the UI
- request fetches carry a timeout; teardown signals the serve process group
- an empty FRONTEND_URL falls back to the default base URL

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

* Address PR review feedback (#3348)

- ops view: a `?server=` link no longer auto-connects to another origin
  while a deploy token is held; it prefills and waits for Connect
- analyze completion: a local-path run reconnects by the path this client
  submitted, so duplicate basenames stay collision-safe without repoPath
  on the public SSE frame
- stale slot cleanup: revalidate each nested directory (lstat + realpath)
  right before readdir, so a mid-cleanup junction swap aborts instead of
  walking an outside tree; list phases run sequentially so the slot-I/O
  cap is global
- e2e: 429 backoff honours the caller deadline; the unreachable-backend
  ops test navigates through the resolved frontend URL
- drop the unused `repo` member from the clean integration helper

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

* feat(server): reconnect after analyze by an opaque repo id

Public job views and the SSE terminal frame no longer carry repoPath, and
repoName is not unique, so a post-analyze reconnect by name could load a
same-named sibling. The server now issues `repoId`: an HMAC of the
canonical registry path under a per-process random key. It is set on
complete jobs (ops view, analyze poll, SSE terminal frame) and matches
the new `id` on `GET /api/repos` entries. The web client resolves it to
the exact entry path on completion; unknown ids fall back to the name.
This covers URL clones and folder uploads, and replaces the local-path
only fallback.

With reconnect off the label, public `repoName` for a branch-pinned URL
clone is the repository name, not the `<repo>__<branch slug>` registry
name, so the requested branch stays off the unauthenticated feed.

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

* Address PR review feedback (#3348)

- stale slot cleanup: a descendant that vanishes before its unlink is
  treated as removed instead of aborting the reclaim
- e2e teardown: escalate to SIGKILL on the process group when the live
  backend ignores SIGTERM for 5s
- ops view: drop the dead initial value CodeQL flagged

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

* Address PR review feedback (#3348)

- public redaction: a known repoPath now also consumes its descendant
  tail, so `<repoPath>/src/secret.ts` becomes `[path]` instead of
  `[path]/src/secret.ts`; a same-prefix sibling is left to the path scrub
- e2e: the cancel test waits for the analyze slot the previous failed
  local-path job still holds; `fetchOps` carries the request timeout
- docs: SSE terminal payload and RepoAnalyzer `onComplete` describe
  `repoId` resolution

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

* Address PR review feedback (#3348)

- RepoAnalyzer: drop a completion that resolves after unmount, so a slow
  /api/repos lookup cannot switch repos after the sheet was dismissed
- e2e: select the local-path input by test id (the placeholder differs on
  Windows); the slot probe's job wait honours the caller's deadline

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

* test(server): assert the public SSE terminal frame in analyze-api

The #2790 terminality tests still expected `repoPath` on the terminal
frame. This PR replaced it with the opaque `repoId`, so assert that shape
and that the analyzed path never appears in the stream.

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

* test(web): wait for the analyze slot between duplicate-repo setup runs

The server now keeps the single analyze slot until the worker exits, even
after its job reports complete. repo-path-identity posted the second
duplicate's analyze immediately and got 409 in CI. Use the shared
slot-aware POST, which also waits out a 429.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 13:27:22 +01:00
mengkaka
79543c8f83
feat(storage): add configurable index storage and content retention tiers (#3060)
* feat(storage): add configurable index storage and content retention tiers

Rebase #3060 onto current origin/main. Keep GITNEXUS_STORAGE_PATH,
GITNEXUS_STORAGE_ROOT, and GITNEXUS_CONTENT_RETENTION, and fold in
main's FTS skip, embed-session, and help-text updates.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Keep legacy registry rows on the local storage fallback, resolve
symlinks before the destructive-path guard, and align hook lookup
with CLI branch slugs, branch-slot metadata, and longest-path match.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Only list swept upload directories after a successful removal so
callers cannot treat a permission or transient rm failure as gone.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Document that getStoragePath may consult registered storage while
this module still does not mutate the global registry.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(storage): close review findings for external indexes and retention

Re-inspect ownership under the analyze lock, fail-closed when the
registry file is missing, and keep skip-git hook discovery plus
retention fields on HTTP/MCP list surfaces. /api/file stays 410
unless contentRetention is full.

Co-authored-by: Cursor <cursoragent@cursor.com>

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

* Address PR review feedback (#3060)

Treat lock-only index dirs as empty, honor HTTP --force storage policy, and prefer registered plus branch-aware slots in hooks and augment.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3060)

Keep hook fallbacks inside the current worktree, compare foreign-local slots canonically, and make storage fixtures survive ownership validation.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix macOS hook test expecting realpath'd registry paths.

resolveHookRepo returns the written registry path, not a filesystem realpath, so the assertion must match that.

* Address gitnexus-check warnings on hook install docs and slot tests.

The Cursor troubleshooting list omitted registry-query.cjs, and the writable-slot test only checked that isDirectory exists instead of that the path is a directory.

* Align the HTTP catalog source-scan with skippable resolveRepo validation.

resolveRepo lists fresh repos with validate: options.validateStorage !== false so DELETE can skip prune; the test still required a literal validate: true.

* Harden storage path sinks so CodeQL path-injection and ReDoS alerts clear.

Contain every filesystem probe inside the resolved storage slot with the inline path.relative idiom, reject filesystem-root slots, and trim slot basenames in linear time.

* Settle bridge stamps before writing so CI size/mtime matches stay stable.

LadybugDB can still flush into bridge.lbug after close+rename; persist whole-millisecond mtimes and wait for consecutive stats to agree so a freshly written pair matches.

* Type the settled bridge stat as fs.Stats so tsc does not see bigint.

Awaited<ReturnType<typeof fsp.stat>> collapsed the bigint overload and broke prepare/typecheck on CI.

* Keep the bridge mtime stamp exact so same-size swaps still fail the pair check.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Wrap the bridge stamp predicate so prettier --check stays green.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Require a quiet interval before stamping a settled bridge file.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Reuse shared storage and settle helpers instead of local copies.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-12 20:31:55 +00:00
Ankit Verma
8ddab9aed5
feat(api): honor branch on POST /api/analyze (#3199)
* feat(api): honor branch on POST /api/analyze

The serve route accepted a `branch` field in the request body, returned
202 and reported the job `complete` — while indexing the remote's default
branch. Express drops unknown body fields, so the caller got no error and
no warning; the only way to notice was to inspect the checked-out clone.

Both ends of the plumbing already existed: CloneOrPullOptions.branch is
honored by cloneOrPull, and AnalyzeOptions.branch already drives
resolveBranchPlacement. Only the HTTP layer was missing, so this wires
`branch` from the route through cloneOrPull and LaunchOptions into the
worker's AnalyzeOptions. StartMessage.options is already typed as
AnalyzeOptions, so the IPC protocol is unchanged.

Validation reuses validateBranchName — the same function backing the
CLI's `--branch` — so both entry points accept exactly the same refs and
a malformed value is rejected with 400 before it can reach git.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(api): make branch part of job identity and complete ref validation

Addresses the review on #3199.

1. Job dedup ignored `branch`, so a request for branch B while branch A was
   in flight was answered with A's job and a 202. The caller would read that
   as "B is indexed" — the same silent wrong-branch outcome honoring `branch`
   was meant to remove. Branch is now part of the dedup identity; a
   different-branch request falls through to the single-slot guard and gets a
   truthful 409 instead.

2. validateBranchName implemented only a subset of git's ref rules, so
   `feature.lock`, `/feature`, `feature/`, `feature//next`, `@`, `@{` and
   dot-prefixed components passed validation and failed later in the git
   subprocess — a 202 plus a background failure rather than the advertised
   400. The remaining `git check-ref-format` rules are now enforced at the
   same chokepoint, which fixes the CLI and `.gitnexusrc` paths too. No
   branch git can create is affected.

3. The LaunchOptions doc claimed an explicit branch always pins
   `branches/<slug>/`. resolveBranchPlacement keeps the run on the flat slot
   when that slot has no owner, or when its owner is already this label.
   Comment and CHANGELOG corrected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(server): describe cloneOrPull's branch path in its contract comment

The header comment predated `options.branch` and still said an existing
clone is only ever `git pull --ff-only`. The implementation has a second
path: with a branch it fetches that ref and runs
`checkout -B <branch> origin/<branch>`, so the requested branch — not the
one already checked out — ends up in the working tree.

The stale comment is actively misleading: a reviewer reading it concludes
that requesting a branch on an existing clone silently analyzes the
default branch, which is not what happens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(server): scope the origin check to the existing-clone path

My previous comment said remote.origin is verified "in both cases", which
is wrong: assertRemoteMatchesRequestedUrl runs inside `if (exists)`, so a
fresh clone has no origin to check. Restructured around whether targetDir
exists, which is what actually selects the behavior.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(server): cover branch job identity in JobManager's own suite

The branch dedup tests were sitting in analyze-api.test.ts, but they
exercise JobManager directly, so they belong beside the existing
"returns existing job for same repoUrl when active" case in
analyze-job.test.ts. Moved, and extended to cover the callers that omit
branch entirely — the upload route, the embed manager and the existing
tests — which compare undefined === undefined and are unaffected.

Also pins that branch survives the whole clone -> analyze -> terminal
update sequence, since it is now part of dedup identity and must not
drift mid-flight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(api): give a pinned branch its own clone and settle the slot it wrote

Addresses the review on #3199.

Per-branch clone directories (review option 2). One checkout per repo made
`branch` one-shot: after any analyze the tree is dirty with generated
AGENTS.md / CLAUDE.md / .claude/, so a pinned request 202'd and then died on
cloneOrPull's porcelain refusal. Worse, a later request that OMITTED `branch`
pulled whatever branch the last pin left checked out and indexed it as the
default — silent wrong content, the same class as #3198 one request later.
A pinned run now clones into `<repo>__<branchSlug>`, so the two requests no
longer share a tree. The pinned clone registers under its directory name,
because both dirs share an origin and the inferred name would otherwise
collide; that name re-derives through getCloneDir, so DELETE still finds it.

Finalization gate. registerRepo always records the flat `.gitnexus`, but a
pinned run whose label differs from the flat slot's owner writes
`branches/<slug>/`. The gate probed the flat path regardless, so it never
settled, and the worker's normal exit 0 — sent ~500ms after `complete`, while
the job is deliberately still non-terminal — was classified as a crash and a
successful analysis was retried three times and failed. The gate now follows
the placement the worker reports (isPrimaryBranch, added to the IPC allowlist
under the rule that module already documents), and an exit after a terminal
IPC counts as winding down, not dying. Reported by the maintainer and
reproduced independently by @azizur100389.

analyzeCloneOptions extracted so the token/branch combination is asserted.
Inline, the branch-only case — a public URL with no token — was untested, and
dropping it there would silently reindex the default branch while every other
test stayed green.

CHANGELOG: the previous entry claimed the newly-400'd payloads "would have
failed at git", which is true of CLI --branch but wrong for HTTP, where they
succeeded on the default branch. Documented as an explicit behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(server): bound the branch clone-dir name to one path component

`validateBranchName` allows a 255-character ref and `branchSlug` appends a
dash plus 8 hash characters, so `<repo>__<slug>` reached 267 — past the
255-byte component limit on ext4/APFS/NTFS. The clone would then fail to
create its target directory, which the character-only regex could not catch.

Only the readable half is trimmed. The hash is a digest of the full ref and
is always kept, so two long branches sharing a prefix still resolve to
different directories rather than silently sharing an index. `branchSlug`
itself is untouched: the per-branch index slots already use those names on
disk, and shortening them there would orphan existing indexes.

Also corrects two comments: the forwarding test claimed a branch selector
always pins `branches/<slug>/` (it keeps the flat slot when that slot has no
owner or already owns the label), and the web client's `branch` doc said
omitting it means the remote default — true for a `url` request, but a `path`
request is never cloned and indexes whatever that tree has checked out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(server): bound the branch clone-dir name and unblock same-branch re-index

Two defects found by testing this branch end to end, plus the review nits.

1. Path length. `validateBranchName` allows a 255-character ref and
   `branchSlug` appends a dash plus 8 hash characters, so `<repo>__<slug>`
   reached 267 — past the 255-byte component limit on ext4/APFS/NTFS, and the
   clone could not create its directory. Only the readable half is trimmed;
   the hash is a digest of the full ref and is always kept, so two long
   branches sharing a prefix still get separate directories. `branchSlug`
   itself is untouched — the per-branch index slots already use those names on
   disk and shortening them there would orphan existing indexes.

2. Same-branch re-index. Per-branch clone dirs stopped branches from
   contaminating each other, but a REPEAT pin still failed: analyze writes
   AGENTS.md / CLAUDE.md / .claude/ into the clone, so the second pinned run
   met its own dirt at the porcelain check and asked for
   `overwrite_local_changes`. When HEAD already matches the requested branch
   there is nothing to switch, so the run now takes the same `pull --ff-only`
   path an unpinned request takes — review option (1), alongside (2). The
   refusal is untouched where it matters: a real switch, or a detached HEAD,
   still goes through the checkout path and can still refuse.

Also: repositions getCloneDir's JSDoc, which an inserted constant had
orphaned; gates the pinned `registryName` on the same condition as the clone,
so supplying both `url` and `path` no longer renames the operator's local
repo; corrects a test comment that claimed a branch selector always pins
`branches/<slug>/`; and corrects the web client's `branch` doc, which said
omitting it means the remote default — true for `url`, but a `path` request
is never cloned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: drop the CHANGELOG edits from this PR

Requested in review. Checking the history, no feat/fix PR here touches
gitnexus/CHANGELOG.md — the only recent commit on it is `chore: release
v1.6.11`, and CONTRIBUTING says release notes are generated from the merged
PR title via .github/release.yml. Hand-editing an [Unreleased] section from a
feature branch was my mistake, not the project's convention.

The behavior change it documented (branch: null / "" / non-string now 400
where they were previously dropped and the default branch indexed) is stated
in the PR description instead, which is what feeds the release notes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(server): align the clone/pull contract with the same-branch fast path

Two comments I wrote went stale against my own later change.

`cloneOrPull`'s header still said an existing clone with `options.branch`
always fetches and runs `checkout -B`. Since the same-branch fast path landed
that is only true when the branch actually differs; when it is already checked
out the run takes `pull --ff-only` and no dirty-tree check applies. The header
now splits on whether the branch differs, which is what the code branches on.

The web client's `branch` doc said omitting it on a `url` request clones the
remote default. That holds only when there is no clone yet — an existing
unpinned clone is pulled on whatever branch it already has checked out.

Comments only; no behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Address PR review feedback (#3199)

Pin a same-branch re-index to `git pull --ff-only origin <branch>` so the job cannot follow an unverified `branch.<name>.merge` while still skipping the dirty-tree refuse.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(server): close the #3199 holes on pinned analyze re-index

Same-ref updates were a raw pull dest (force-fetch via +) or a shallow ff-merge that could not move, and a tag pin compared the tag-object SHA so re-index refused a dirty tree. Fetch the mapped remote-tracking ref, stay put on a peeled SHA match, restore only GitNexus overlays, skip the 60s settle on alreadyUpToDate, and keep branch validation in core so the HTTP route does not import the CLI.

Co-authored-by: Cursor <cursoragent@cursor.com>

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-08 15:15:45 +01:00
MyShining
c217a0f257
feat(spring): import optional Actuator runtime data with Kotlin JVM mapping (#3107) 2026-09-01 06:22:38 +01:00
DuduPhudu
fa31a7d824
fix: close the nine follow-up review findings from #2856 (routes, receiver typing, truncation honesty) (#2899)
* fix(typescript): a type parameter shadows a declared type of the same name (W2-8)

First item of wave 2, promised to the reviewer on #2856.

`export function unwrap<Result>(value: Result): Result` names the PARAMETER, not
the `interface Result` beside it — tsc resolves both annotations to the
parameter. The type-reference capture that makes a contract answerable ("what
breaks if I remove this field?") had no notion of a parameter binding, so every
annotation mentioning `Result` inside `unwrap` minted a `USES` edge into the
interface, at the same confidence as a real consumer and indistinguishable from
one. Measured on the new fixture: `unwrap` produced TWO false edges while the
genuine consumer produced one.

Blast radius is every generic whose parameter name collides with a declared
type, and the colliding names are ordinary choices for both: `Result`, `Key`,
`Value`, `Item`, `Node`, `Options`, `Config`, `Props`, `State`, `Response`.

TWO HALVES, and the first is why upstream's fix could not reach this. #2833
introduced `bindsTypeParameter` for the CALL-receiver path, where a workspace
`class T` was answering for `<T>`. Reusing it here changed nothing at first, and
the reason is its own documented contract: `@declaration.type-parameters` was
captured for class/interface declarations ONLY, so a generic FUNCTION recorded
no parameter list and the predicate correctly returned false — absence is not
evidence. The data was missing, not the logic. So:

  - TYPESCRIPT_SCOPE_QUERY now captures type parameters on `function_declaration`,
    `generator_function_declaration` and `type_alias_declaration`;
  - the graph bridge consults `bindsTypeParameter` before emitting `USES`.

Both are load-bearing — removing either one fails the fixture.

The fixture carries two controls, because the obvious wrong fix is to stop
emitting: a genuine consumer of the interface must still link, and a generic
whose parameter does NOT collide must still link its real reference. Both are
asserted, and the "genuine consumer" case is asserted FIRST so the absences
below it cannot pass vacuously.

SCHEMA_BUMP 53 -> 54: parse-time capture change. A warm cache replays defs with
no parameter list, so the guard reads nothing and the feature is inert while
looking implemented.

Capture fingerprint re-baselined with justification. NO NEW CAPTURE NAME —
diffing the capture-name sets against the wave-1 branch returns empty; the tag
existed and now fires on more declarations. capture_groups_fp 2338 -> 2371,
fixture_count 151 -> 152, scaling 1.06 < 1.5, and JavaScript's fingerprint does
not move at all, which is the check that this is the TS declaration rules rather
than something broader.

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

* fix(analyze): close the four false-success paths in the graph-write-collapse guard (W2-6)

Second wave-2 item, promised on #2856. All four were reported; all four
reproduced by reading the code they name.

(a) A SAME-COMMIT RE-RUN REPORTED SUCCESS FOREVER. Every other meta-driven
    trigger — schema fingerprint, PDG mode, runner identity, CJK segmentation,
    embedding dims — has a block that forces a rebuild before the
    `alreadyUpToDate` fast path. `graphWriteCollapsed` had none; `grep -rn` found
    writes and no reads. So the one state meaning "most of your edges are gone"
    was the one state that repaired itself only if the user happened to pass
    `--force`. Now forces a full rebuild, and forcing is right rather than merely
    re-running: the persisted graph disagrees with what the pipeline produced, so
    an incremental pass over unchanged files would write nothing and re-stamp the
    same broken index as fresh.

(b) AN INCREMENTAL RE-RUN ERASED THE STAMP. `saveMeta` is a full atomic
    overwrite, and the field was spread in only when the CURRENT run had a
    verdict. `undefined` meant two different things at that site — "full run, no
    collapse" (a positive all-clear) and "incremental write, not comparable" (no
    opinion) — so the second case silently dropped `graph-write-collapsed` from
    meta.json while the edges were still missing. Now three-way: stamp on
    detection, CLEAR on a healthy full run, CARRY FORWARD when there is no
    verdict. That is the shape `branch: branchLabel ?? existingMeta?.branch` two
    lines away had all along.

(c) THE SERVER PATH NEVER CONSUMED IT. `analyze-worker-ipc.ts` projects the field
    "so a server-side caller sees the same degraded outcome the CLI does" — but
    nothing read it, so the comment described an intention and every collapsed
    run reported `complete` to the UI and to every API consumer. Now reports
    `failed` with the counts and the remedy, matching the CLI, which prints
    `Repository indexed INCOMPLETELY` and exits non-zero. A consumer that reads
    "complete" will query the index and get confident wrong answers.

(d) --pdg ROWS MASKED TOTAL STRUCTURAL LOSS. `expected` counts the in-memory
    graph plus the streamed STRUCTURAL manifest; the streamed PDG layers never
    enter `graph.relationshipCount`. But `persisted` was `stats.edges`, a count
    of EVERY `CodeRelation` row, and PDG writes into that same table. With 1,000
    structural edges expected and 4,000 PDG rows persisted, losing every
    structural edge still read `persisted = 4000`, cleared the ratio, and stayed
    silent — on exactly the large repos `--pdg` is used for.

    Worth recording that the OBVIOUS fix does not work. Padding `expected` with
    the PDG rows makes the two universes match but leaves the ratio judging a
    minority population: 4,000 of 5,000 still clears 0.5. I wrote that first, and
    the test I wrote to prove it failed. Only comparing structural against
    structural asks the question the check exists to ask, so `getLbugStats` gains
    a `structuralEdges` count excluding `PDG_EDGE_TYPES`. `TAINT_PATH` is
    deliberately NOT in that set — it is a whole-program Function→Function edge
    persisted by the normal emit, so it is structural and stays counted on both
    sides.

    `index-freshness-graph-collapse.test.ts` had pinned the masking as correct
    (`detectGraphWriteCollapse(1000, 4000)` → undefined, "PDG layers write into
    the same table, so persisted > expected is normal"). True about the table,
    and it licensed the hole. Replaced with the case that matters and a note on
    why the fix is at the caller.

The new `structuralEdges` assertion in `lbug-core-adapter` is there because the
failure mode is silent: the query sits in a try/catch that yields `undefined`,
and `undefined` makes the collapse check decline to compare — so a typo in the
Cypher would throw nothing, fail nothing, and switch the guard off. Verified
against a real LadybugDB and mutation-checked by breaking the query.

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

* fix(processes): make process selection insertion-order invariant (W2-5)

Third wave-2 item. Reproduced before fixing: two equal three-step flows with
`maxProcesses: 1` select `handleAlpha`; inserting the identical nodes and CALLS
edges in reverse select `handleBeta`. Same repository, same commit, a different
persisted graph — so a filesystem that enumerates differently, or an incremental
run that reorders assembly, silently changes what the tool reports.

Four sorts ranked by score or length alone and returned 0 on a tie.
`Array.prototype.sort` is stable, so a 0 preserves INPUT order, which traces
back to `graph.iterNodes()`. Under `maxProcesses` capping that decided which
`Process` and `STEP_IN_PROCESS` nodes were persisted at all. Each now falls
through to a totally-ordered, content-derived key — node id for entry points,
the joined path for traces.

WHAT IS ACTUALLY VERIFIED, stated precisely because "four fixes" would overclaim:

  - the ENTRY-POINT sort is individually mutation-verified;
  - the two DEDUP sorts are collectively mutation-verified;
  - the TRACE-RANK tiebreak is NOT individually observable, and the source says
    so. The dedup sorts already impose a total order on the list that reaches
    it, so removing it alone fails nothing. Kept as defence in depth: it cannot
    misbehave — it only makes an already-deterministic order explicit — and it
    is what stops a change to dedup ordering from silently re-opening this.

Finding that out took two fixtures. The first (three chains, three entry points)
is separated by the entry-point sort before trace ranking is reached, so it never
exercises the trace comparator at all; the second gives ONE entry point two
equal-length branches to different terminals, which is the only shape where the
trace comparator decides. Both are kept — they gate different sites.

The invariance tests assert the INVARIANT rather than any single sort, so they
cover all four sites and any future one without needing to know where they are.
Three assertions: same selection under a cap, identical set uncapped, and
identical ORDER — the last because order is what the cap consumes, so a set-only
assertion would pass while the defect persisted.

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

* fix(impact): UNKNOWN dominates a mixed candidate set, instead of reporting the known floor (W2-4)

Fourth wave-2 item. The all-UNKNOWN branch here was reasoned about carefully and
is correct — its comment even names the two ways a set can be all-UNKNOWN. The
MIXED case fell straight through it.

`RISK_ORDER` is `['LOW','MEDIUM','HIGH','CRITICAL']` and has no `UNKNOWN` entry,
so `indexOf('UNKNOWN')` is -1 and an UNKNOWN candidate can never win the reduce.
An ambiguous name with one caller-less candidate (UNKNOWN, per the round-1 fix)
beside one single-caller candidate (LOW) reported `maxRisk: 'LOW'` — a confident
floor over a set containing an interpretation nobody measured. That is the same
false-safe the all-UNKNOWN branch exists to prevent, one case over, and it
surfaced in the UI as "Max blast radius N (LOW risk)".

`maxRisk` answers "how bad could this be?", and an unresolved candidate could be
CRITICAL — so any UNKNOWN in the set makes the aggregate UNKNOWN. Narrowing it
that way would normally cost information, so the measured part travels alongside
as `knownMaxRisk`, present only when the two differ: absent on a fully-resolved
set, where it would duplicate `maxRisk`, and absent on a fully-unknown one, where
there is no measured part. A reader gets "at least LOW among what resolved, and
one interpretation could not be walked at all", which is strictly more than
either value alone. The human-readable message says the same thing.

The seed gained a mixed pair because the existing one could not reach this: both
its twins are caller-less, so it only ever exercises the all-UNKNOWN branch —
which is precisely why the gap survived a round of review. Three assertions,
both halves mutation-verified.

`eval-server.ts` needs no change: it renders `result.maxRisk ?? 'UNKNOWN'`, so it
now shows UNKNOWN where it previously showed the floor.

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

* fix(routes): track ternary polarity in dispatch guards, so a selected verb cannot be inverted (W2-9)

`if ((req.method === 'GET' ? false : true) && pathname === '/api/i')` emitted
`GET /api/i` — the one method that branch guarantees the request does NOT have.
A ternary SELECTS between its arms, so a verb inside one is not reached merely
because the whole condition is truthy, but `findVerbInSubtree` descended into
both arms and returned the first verb it saw. Same inversion `!` produced before
d4dcba8c, one level up.

Handled by folding the ternary where an arm is a boolean literal, which is what
collapses the selection into a conjunction:

    c ? A : false  ==  c && A     both hold, so search both
    c ? false : B  ==  !c && B    c must NOT hold, so search it at flipped parity
    c ? true : B   ==  c || B     a disjunction guarantees neither operand
    c ? A : true   ==  !c || A    likewise

Two non-literal arms leave the verb chosen by an unknown condition, so the
ternary guarantees nothing. Refusing every ternary would also have fixed the
reported bug, but three of the four shapes measured were ALREADY correct and
would have silently lost their verb; they are pinned now.

A second defect in the same walk, found while reproducing: the `!` rule was
keyed on PRESENCE, returning null at the first negation it saw, while
`isNegatedContext` two functions above states the rule is PARITY and says so
outright — `!!x` is `x`. So `!!(req.method === 'GET')` dropped a verb the source
states plainly. The existing double-negation test covered the PATH position,
where the parity walk already ran, and so never saw it. The verb walk now tracks
parity too, and the two agree.

Verb-less, not route-less: the path comparison is untouched evidence that the
branch serves that path, so an inverted verb becomes a missing verb rather than
a missing route.

SCHEMA_BUMP 54 -> 55. Routes are emitted at parse time and replayed verbatim
from a warm cache, so without the bump an already-indexed repo keeps serving the
inverted verb and the fix looks inert. Free against origin/main (48).

Every rule mutation-checked: removing the ternary dispatch, either literal-arm
rule, the negated-ternary guard, or the parity walk each fails exactly the tests
that claim it. One assertion I wrote survived all five mutations and was removed
rather than kept.

Not a recall win on crypto-trading-bot, which contains neither shape — this is
precision insurance for dispatchers that do.

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

* feat(routes): report every method a dispatch guard serves, not just the first (R3-8 part 1)

`if ((req.method === 'GET' || req.method === 'POST') && bundlesMatch)` is two
routes. The verb walk returned the FIRST verb it found, so `route_map` presented
a two-method route as GET-only and `impact` on the POST path found nothing.
Taken verbatim from the reporting repo's researchRunRoutes.js.

`governingVerb` -> `governingVerbs`, returning a list; `findVerbInSubtree` and
`verbFromTernary` likewise. A guard with several verbs emits one route per verb
via the new `pushPerVerb` — they share a path and a handler but not a method,
and `(method, url)` is the key every downstream consumer dedups and looks up on.

A disjunction yields ALL its verbs or NONE, which also fixes an over-attribution
the first-match rule had:

    req.method === 'GET' || req.method === 'POST'   ->  GET, POST
    req.method === 'GET' || isAdmin                 ->  no verb

The second is reached for ANY method when `isAdmin` holds. Reporting `GET` — as
first-match did — describes a route open to everything as single-method, which
is the direction this module treats as more expensive than saying nothing.
Negated, `!(A || B)` is `!A && !B`, so it excludes verbs rather than offering
them and yields none.

Generic descent deliberately stays FIRST-match rather than unioning across
children: an arbitrary node says nothing about how its children combine, and two
verbs found under one are far more likely unrelated than alternatives. `||` is
the one construct that genuinely means "either of these".

Pinned against regression: the pre-existing rule that distributes ONE verb
across an OR of PATHS must not start multiplying methods, and switch arms
inherit the full method set.

SCHEMA_BUMP 55 -> 56. Routes are parse-time output replayed verbatim from a warm
cache. Free against origin/main (48).

Four mutations, each failing exactly the tests that claim it: removing the
disjunction dispatch, dropping the all-operands rule, allowing a disjunction at
odd parity, and emitting only the first verb.

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

* feat(routes): read `.match()` dispatch, and the capturing wildcard it needs (R3-8 part 2)

`RE.test(pathname)` and `pathname.match(RE)` are the same test with the operands
swapped. Only `.test` was read, which is why 28 of the reporting repo's 75 routes
still named the shared route table as their handler rather than the module that
serves them: those modules dispatch with `.match`.

THE CAPTURING WILDCARD, which is the part that made the rest inert.
`regexToRoutePath` accepted `[^/]+` and refused `([^/]+)` — `(` fell through to
the metacharacter bail. So the non-capturing form translated and the capturing
form produced nothing, and every existing test passed because every existing
test used the non-capturing form. The tests were written against the
implementation rather than against the corpus, and the reporting repo contains
no non-capturing path wildcard at all: a dispatcher captures the segment because
it needs the id. This alone also repairs the already-shipped `.test` rule.
A capture around anything that is NOT one segment still bails — `(.+)` spans
slashes — and the alternation is balanced, so `([^/]+` unclosed is not a match.

`.match` differs from `.test` in one way that matters: its result is USED, so it
is almost always BOUND, and the verb then lives in a later `if`:

    const runMatch = pathname.match(/^\/api\/research-runs\/([^/]+)$/)
    if (req.method === 'GET' && runMatch) { … }

Reading the verb off the CALL would report every one of those verb-less. So a
bound match records `name -> path` and the route is emitted where the binding is
TESTED, once per test site — one binding tested for GET and for PUT is two
routes. A reference counts only in a truthiness position (`&&`/`||` operand, or
a whole `if` condition), which is what separates `if (m && …)` from `m[1]`: a
read of the captured segment says nothing about dispatch and would otherwise
mint a duplicate route per use of the id. A binding never tested still emits one
verb-less route — the code did compute an anchored match against the path.

Regexes named by a same-file const resolve too (`pathname.match(POSITION_REPLAY_RE)`),
with the same ambiguity refusal the string-constant map uses: bound twice to
different patterns means dropped, because a half-right regex is a wrong route.

SCHEMA_BUMP 56 -> 57. Free against origin/main (48).

Nine mutations, each failing exactly the tests that claim it. TWO of my own
tests initially survived their mutation and were rewritten, not kept:
- the non-path-receiver case had no path token anywhere in the fixture, so
  PATH_TOKEN_HINT skipped the file and the assertion was satisfied by a file
  that was never examined;
- the negation case used `!m`, which never reaches the negation check at all —
  a `unary_expression` parent is not a truthiness position to begin with. The
  shape that exercises it is `!(req.method === 'GET' && m)`.
A declaration-site skip written alongside them proved unreachable for the same
reason and was removed rather than left to imply a hazard.

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

* feat(processes): report what the detection ceilings dropped, instead of logging it at debug (W2-3)

`processProcesses` has five ceilings - the entry-point trace quota, the
per-entry trace budget, `maxTraceDepth`, `maxBranching` and `maxProcesses` -
and every one of them fired silently. The result came back looking whole and no
consumer could tell it was a sample. The code's own comment already said so:

    // A silently truncating cap reads as "this is everything", which is the
    // same class of confident-empty answer this work is about.

and then only called `logger.debug`. A log nobody has enabled is not a
disclosure.

`stats.truncation` is additive, so every existing consumer of `totalProcesses` /
`crossCommunityCount` / `avgStepCount` / `entryPointsFound` is unchanged. It
carries one boolean to branch on plus a counter per ceiling, kept SEPARATE
rather than summed because they mean different things: unexplored entry points
mean whole flows are missing, while a depth-capped trace means a flow is present
but shorter than it really is.

`processesDropped` counts against the DEDUPED population, not the raw trace
list - the gap between those two is deduplication doing its job, and counting it
as truncation would report a permanent non-zero on every healthy repo.

`truncated` is DERIVED from the counters rather than set at each site, so a
ceiling added later only has to increment its own counter to be reported.

Surfaced at `warn` and NOT gated on `isDev`: "823 flows" printed without it
reads as the complete set, which is the confident-empty failure wearing its
other face - a confident-COMPLETE one. The debug line stays for the per-entry
detail it carries.

Seven mutations, each failing exactly the tests that claim it, including BOTH
directions of the flag: hardcoding `truncated` false fails the four positive
cases, and hardcoding it true fails the nothing-was-truncated case, which is
asserted first precisely so the positives cannot pass vacuously. The
`walksCutByBudget` fixture gives every node exactly `maxBranching` callees so it
asserts its own counter and not a neighbour's.

Also fixes a defect this work exposed: 10b0c7a1 (W2-5) embedded a RAW NUL BYTE
in `trace.join(...)` instead of the backslash-u escape the rest of the repo
uses. It behaves identically at runtime, but `file` reports the source as
`data`, and grep, git diff and code search treat it as binary - several greps
against this file silently returned nothing while I was reading it. main was
clean here; two other files carry the same raw byte from before this branch and
are left alone.

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

* feat(scope-resolution): resolve members through a MEMBER-CALL producer's return shape (W2-1)

    const svc = new SignalService()
    const r = svc.make()
    return r.secretFlag        // <- no edge

`return-shape-members` types `r` to the producer that made it, but a member call
binds the spelling `svc.make`, and slicing that to its last segment leaves
`make` — a METHOD, never a callable binding in scope. The producer lookup failed
and the pass declined.

The limit shipped documented as needing inter-procedural receiver typing. It does
not. Measured on a fixture, the pipeline had already done the hard part:

  - `readMake -> Method:...SignalService.make#0` already resolves as an ordinary
    CALLS edge, so the receiver is already typed; and
  - `Property:...SignalService.make.secretFlag@N:C` already exists, because R3-4
    anchors a returned literal's keys to the METHOD that returns them, not only
    to free functions.

Both halves were present and unjoined — the same shape as R3-5 itself.

ADDITIVE, not a reroute. The new branch sits inside `if (producerFile ===
undefined)`, so it can only fire where the callable lookup already declined;
every reference that resolved before resolves identically, by construction
rather than by test.

Nothing new is inferred. The receiver is typed by the SAME predicate that typed
`r`, and it must itself resolve to a class — a receiver that cannot be typed
still declines, so `make.<member>` is never matched by name across the graph.
That fabrication is what the existing guards exist to stop and they all carry
over unchanged: the owner must resolve, its file must match the candidate's, and
`ownFilePaths` keeps the polyglot class registry from walking a JS read into a
Java field.

The owner segment is TWO parts for a method (`SignalService.make`) and one for a
free function (`makeSignal`), which is exactly how R3-4 qualifies each. That is
what separates two methods of one class returning the same key name from each
other AND from a free function of that name — the fixture gives `secretFlag`
three owners so a wrong resolution is detectable rather than a coin flip that
happens to look right.

Four mutations, each failing exactly the two tests that claim it: removing the
fallback, using the method alone as the owner segment, taking the producer file
from the reading file instead of the owner class, and dropping the
receiver-type requirement. 3,408 resolver tests pass, including
`polyglot-property-isolation`, which is the one this could plausibly break.

No SCHEMA_BUMP: this is a resolution pass over ParsedFiles, not parse-time
output, so a warm cache replays the same input and produces the new edges.

Measured on crypto-trading-bot: ZERO new edges, byte-identical at 62,158. Its
170 `const x = new Y()` bindings are overwhelmingly built-ins (Map, Set,
Promise, S3Client) rather than workspace classes whose methods return object
literals — it is a module-style JS codebase. Correctness fix for class-shaped
code, not a recall win on this corpus, and it should not be presented as one.

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

* feat(scope-resolution): type a bare parameter from what its callers pass (W2-2)

    function readSpike(spike) { return spike.wickRatio }

had nothing to type `spike` from, so the read fell through to the 0.5 name tier.
That is the standing limit of R3-5 and, measured, by far the largest: 11,012 of
13,672 property edges on the reporting repo (81%) rest on that name guess.

The two facts needed were already extracted, for a different consumer. For JS
and TS among others, `callable-flow-captures` synthesizes:

    formal    owner=readSpike  binding=spike  parameter-index=0
    argument  source=s  parameter-index=0  direct-callee-name=readSpike

Joining them on (callee, parameterIndex) says which cell reaches which
parameter, and the argument's own binding is typed by the same
`findReceiverTypeBinding` a directly-bound receiver already uses. So the
parameter inherits the producer and `spike.wickRatio` resolves as evidence
rather than inference.

No new capture, no parse-time change, NO SCHEMA_BUMP. And deliberately not a
change to the callable-value-flow solver that owns these sites: that pass is
guarded by a fingerprint CORRECTNESS gate plus a timing budget, so this reads
the same facts and computes its own map.

AMBIGUITY DECLINES. A parameter whose callers pass different producers resolves
to nothing. Picking one would fabricate at the 0.9 PRECISE tier, which no
`minConfidence` floor can filter out — the same reason `buildConstantMap` drops
an ambiguous constant instead of taking the first.

Keyed by the formal's (scope, name), not by a definition id. The first attempt
used a def and measured `paramDef=NONE`: a parameter is not reachable through
`findValueBindingInScope` (its predicate is `isOwnableValueLabel`, which lists
Const/Variable/Property/Static because it exists for OWNERSHIP registration, and
a parameter is owned by nothing) and it is not a `local` binding either. The
formal site already states the scope its parameter binds in, which is enough.

Formals carry their DECLARING FILE in the key, so two same-named functions in
different files cannot answer for each other — dropping it makes both go
ambiguous and both readers silently lose their edge.

COVERAGE, counted rather than assumed. The synthesis skips an argument that is
itself a call result (an explicit `continue` in `callable-flow-captures`), so
`f(makeSignal())` emits no argument site and only the bound spelling
`const s = makeSignal(); f(s)` is served. That looked fatal until measured: in
the reporting repo, bare-identifier arguments outnumber call-result arguments
2,563 to 50 — 51:1. Extending the shared, benched capture synthesis for the 2%
case is not worth its risk.

Four mutations, each failing exactly the tests that claim it: keeping the first
producer instead of declining on conflict, dropping the read-site lookup,
matching a formal at index 0 regardless of the argument's index, and dropping
the declaring file from the formal key. Two of those could not be caught by the
first fixture at all — it had a single parameter and a single consumer file — so
the fixture gained a two-parameter callee and a same-named twin in a second file
before they were meaningful. The test helper also had to start filtering by
source FILE, or two different `readSpike` symbols merged into one count.

Measured on crypto-trading-bot: 36 reads left the 0.5 name-guess tier. 26 became
precise 0.9 edges (return-shape reads 1,130 -> 1,156, which is the whole delta),
and 10 became honest absences — the receiver was typed, the producer's shape was
known, and the member is NOT on it, so the site is claimed as disproved rather
than left for the name fallback to invent an answer for.

That is ~0.3% of the 11,012, and it should be reported as such. The 81% figure
is the size of the PROBLEM, not of this fix: the shape requires a bound
argument, a producer that returns an object literal, and a parameter read as a
receiver, and that intersection is narrow. The remaining name-tier reads are
mostly receivers no workspace producer types at all.

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

* fix(processes,ci): anchor trace subsumption, cover the sink wiring, stop one bench guard hiding the rest (#2894, #2896, #2895)

Three follow-ups reported against #2856 after it merged. Each was reproduced
before it was fixed.

#2894 — trace subsumption matched mid-identifier.

`deduplicateTraces` decided whether one trace is a sub-path of another with an
UNANCHORED `String.includes`, so a match could begin in the middle of a node id:

    'X->AA->B'.includes('A->B')   ->   true

and `A -> B` was discarded as redundant against a chain `A` is not a step of at
all. Reproduced directly against the function before fixing.

Padding both keys with the separator makes `includes` match whole steps only.
Reported as measured-inert and that holds — the collision needs one node id to
be a strict suffix of another at a `->` boundary, which real ids
(`Function:<path>:<name>`) do not produce. Fixed anyway because the predicate
did not mean what the surrounding code says it means, in a function whose entire
job is deciding what to delete, and nothing pinned it.

`deduplicateTraces` is exported for the test, matching how `traceFromEntryPoint`
and `buildSinkFunctionSet` are already reached. The tests use bare ids because
the shape cannot be built from realistic ones — which is exactly why nothing
caught it. Alongside the regression case, two tests pin that GENUINE subsumption
still happens, prefix and suffix, so the fix cannot degenerate into "subsume
nothing" and pass the first test trivially. Mutation-checked: reverting the
padding fails the mid-identifier test and only that one.

The encoding assumes `->` never appears IN a node id; a C++ `operator->` would
defeat the join regardless of padding. Out of scope, but the assumption is now
written down where the join happens.

#2896 — the sink wiring was only ever exercised through its fail-open catch.

`processesPhase` reads `allFetchCalls` / `allORMQueries` off the parse output
inside a try/catch that falls open to "no sinks", and every phase-level test
omitted `parse` — so all of them took the CATCH branch and the success path had
no coverage. `getPhaseOutput` is a raw `as T` cast, so a field rename would make
the phase detect zero sinks while every test still passed, because zero sinks is
what they already assert.

The new test asserts the one thing only the success path can produce: a flow
ENDING at the sink while a longer chain continues past it. Its control is the
same graph with no `parse` dep, which must NOT produce that terminal — without
the control the assertion could pass for an unrelated reason. Also asserts
`processesPhase.deps` contains `parse`, so the read and the declaration cannot
diverge, and that a parse output missing those fields still fails open rather
than losing every process.

Mutation-checked, including the exact drift scenario reported: renaming
`allFetchCalls` at the read site, dropping `parse` from `deps`, and passing no
sinks to `processProcesses` each fail exactly the test that claims them.

#2895 — a failing bench guard aborted the job and masked every later guard.

Every step in the benchmarks job was fail-fast, so the first failing `--check`
aborted it and the rest reported `skipped`, which reads identically to "nothing
to do". Audited over 13 runs on #2856: the job succeeded zero times and the last
two guards executed zero times for the life of the PR, while two reviews read
the checks summary and saw nothing wrong. Both guards did in fact pass — that
was luck, not verification.

`if: ${{ !cancelled() }}` on all ten steps after the first, so one stale
baseline reports one red step instead of hiding nine. `!cancelled()` rather than
`always()` so an explicit cancel still stops the job instead of running seven
minutes of benchmarks nobody is waiting for.

The two steps easiest to miss are covered: `Receiver-resolution drop guards`,
whose `run:` sits twenty lines below its `name:` behind a long comment, and the
final `Cross-language pipeline benchmarks` step, which is not a `--check` and so
falls outside any grep for one — and is one of the two that never ran.

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

* fix(parse): capture a fetch call site even when its URL is not a literal (#2897)

The `fetch` rule required the argument to be a string or template literal:

    arguments: (arguments
      [(string (string_fragment) @route.url)
       (template_string) @route.template_url])

so `fetch(url)` with a variable matched nothing at all. Measured across this
repository's own TypeScript sources: **44 of 47 fetch calls pass a variable**, so
94% produced no site.

That is what makes R3-6 look inert. The sink set is built entirely from
`allFetchCalls` / `allORMQueries`, so a function performing an outward call
through a computed URL was never a sink, no flow could terminate there, and the
sink-first ranking rule never changed an ordering. The feature was fine; the
signal underneath it was almost always empty.

The URL alternation is now OPTIONAL, so one match covers both shapes. The R3-6
sink set needs only WHERE the program reaches outward, not where to.

Route linking is untouched, by construction rather than by hope:
`processNextjsFetchRoutes` normalizes the URL first and skips anything that
yields nothing, so a URL-less entry cannot mint a FETCHES edge. Verified on this
repo — FETCHES went 8 -> 9 across the change, i.e. the widening added sink sites
without inventing route edges, which was the one real risk here.

Tested in BOTH JavaScript and TypeScript, since the rule is duplicated in each
query block and fixing one would have left the other blind:

  - a variable argument is captured, with no URL   <- the regression case
  - a computed argument (`fetch(buildUrl(), {...})`) likewise
  - a literal URL is still captured WITH its URL   <- route linking depends on it
  - a template URL likewise
  - exactly ONE site per call — an optional alternation must not make a literal
    match twice, which would double-count the site and could mint two edges
  - `prefetch('/x')` is still not a fetch

Mutation-checked: restoring the mandatory alternation fails six of the twelve,
three in each language.

SCHEMA_BUMP 57 -> 58. Parse-time capture output is replayed verbatim from a warm
cache, so without the bump an already-indexed repo keeps its empty sink set and
the fix looks inert — which is the failure this constant exists to prevent, and
would have reproduced the very symptom being fixed.

Not addressed here, and worth stating: this widens `fetch` only. The reporter's
broader point stands — anything keyed on FETCHES / QUERIES is only as good as
the extraction underneath it, and the ORM side has not been measured. A guard
that fails when a corpus known to contain outward calls yields zero sites is the
right follow-up; this change makes such a guard meaningful rather than
tautological.

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

* test(bench): re-baseline receiver-resolution for the two fixtures this PR adds

`receiver-resolution --check` failed on:

    countArm.totalDropsAllKinds: 140 -> 148
    countArm.bySiteKind.write:    11 ->  19

Investigated before touching the baseline, because a guard that exists to catch
unexplained movement should not be silenced by an unverified story.

WHAT IT IS: the count arm runs the real pipeline over a corpus that includes
`test/fixtures/lang-resolution/`, and this PR adds two fixtures there —
`member-call-producer` (W2-1) and `parameter-producer` (W2-2). Each returns an
object literal with two keys, and a producer writing its own returned key is a
write site the receiver recorder logs. Four each, eight total.

Attributed by dumping the individual drops rather than reading the aggregate:

    member-call-producer/src/producer.js   secretFlag, wickRatio  (2 lines) = 4
    parameter-producer/src/producer.js     source, wickRatio      (2 lines) = 4

The eleven drops already in the baseline are all `javascript-object-properties`
fixtures of exactly the same shape, so the new ones are not a new KIND of drop —
they are more of one the baseline already records. This is the first case the
guard's own failure message names: "a fixture was added".

WHAT IT IS NOT: `callDrops` — THE gate number, and `call`-only by deliberate
design because reads and writes "would inflate it" — is unchanged at 102. `read`
drops unchanged at 27. The SHAPE ARM shows no drift at all: no receiver spelling
moved between RESOLVES / VISIBLE-GAP / INVISIBLE-GAP, so no resolution
regressed.

HOW IT WAS ISOLATED, since the first attempt was misleading and the record is
worth having: reverting `return-shape-members.ts` alone did NOT reproduce it and
pointed away from W2-1/W2-2. Only a commit-level bisect was trustworthy —
`origin/main` OK, W2-8 OK, W2-3 OK, then W2-1 +4 and W2-2 +4, which matches the
fixture count exactly. A file-level revert leaves the fixtures in the tree, and
the fixtures are the cause.

The update is two numbers. Nothing else in the baseline moves.

Worth noting where this failure became visible at all: under the fail-fast
benchmarks job it would have aborted the run and shown the five guards after it
as `skipped`. It is legible here because #2895 — fixed in this same PR — now
lets every later guard run.

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

* fix(analyze): stop `--pdg` runs reporting a healthy index as INCOMPLETE

Every `gitnexus analyze --pdg` reported a graph-write collapse and exited 1 on an
index where every row had persisted. Reported by a user hitting it on a real
repo; introduced by this PR's own W2-6(d).

    Repository indexed INCOMPLETELY
    the pipeline produced 200,501 relationships but only 64,764 are readable

The index was complete: 200,190 rows present, 109,905 PDG and the rest
structural, all queryable.

WHAT WENT WRONG. W2-6(d) made the persisted side count STRUCTURAL rows only —
correct, and the reason is in its own comment: PDG writes into the same table, so
counting everything let PDG surplus mask real structural loss. But the expected
side kept using `graphEmitManifest.totalRows`, and that is a BUFFER-POOL SIZE
HINT which counts every streamed row. PDG streams through that same sink, so the
check compared a structural-plus-PDG expectation against a structural
measurement. On any repo with a PDG layer that is a guaranteed false collapse.

It compounds rather than merely misreporting: the run stamps
`graph-write-collapsed`, and W2-6(a)'s rebuild trigger — added alongside it —
forces a full re-analyze next run, which collapses again. A permanent rebuild
loop, on an index that was never damaged, at ~100s a cycle.

MEASURED RATHER THAN ASSUMED, because the first attempt was wrong. I first
subtracted PDG edges RESIDENT in `graph.relationshipCount`, rebuilt, re-ran the
failing command and got byte-identical numbers. Instrumenting the three terms
showed why:

    relationshipCount=20,825  graphManifestTotalRows=179,676
    pdgEmitManifest=absent    residentPdgInGraph=0

PDG is not resident in the graph AND has no separate manifest — it streams
through the ordinary `GraphEmitSink`. The reverted attempt is not in this diff.

THE FIX. A pair key cannot separate them: it is `From|To` NODE LABELS, and a CFG
edge shares `Function|Function` with CALLS. Only the write path sees
`relationship.type`, so the sink now counts a `structuralRows` subtotal there and
publishes it on the manifest. `totalRows` is unchanged — it still sizes the
buffer pool, which is what it was for.

WHY THIS SHIPPED UNCAUGHT, and what changed about that. The wiring test kept a
LOCAL MIRROR of the expected-count expression "because the production expression
is inline in a 3000-line function". A mirror cannot catch a term the original got
wrong. That expression is now an exported
`computeExpectedStructuralRelationships` which production calls and the test
imports.

It also takes the MANIFEST rather than a pre-selected number, deliberately: the
defect was choosing the wrong FIELD, and a numeric parameter leaves that choice
at a call site no unit test can reach. Verified — with the helper taking a
number, reverting to `totalRows` failed nothing; taking the manifest, the same
revert fails four tests.

Verified end to end on the reported command: `analyze --force --embeddings 0
--pdg` now exits 0 with "indexed successfully", 86,963 nodes / 200,217 edges, and
the run clears the stale collapse stamp.

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

* fix(routes): scope match bindings, and intersect ternary conjunctions

Two ways the dispatch-guard walk minted a route that does not exist — the one
thing this module's header says is worse than missing one.

MATCH BINDINGS WERE KEYED BY BARE NAME, FILE-WIDE. `collectFromMatchBindings`
walked from `tree.rootNode` and resolved `matchBindings.get(node.text)` at every
identifier in a truthiness position, so a same-named binding in ANOTHER function
answered for it. The poison check only fired on a second REGEX match with a
different URL; a non-match binding never entered `collectFromRegexDispatch`, so
nothing refused it. Reproduced:

    function handleReplay(req, res) {
      const m = pathname.match(/^\/api\/live\/positions\/([^/]+)\/replay$/);
      if (req.method === 'GET' && m) { … }
    }
    function handleSettings(req, res) {
      const m = req.headers['x-mode'];        // unrelated value, same name
      if (req.method === 'DELETE' && m) { … }
    }

    GET    /api/live/positions/{param1}/replay  handler=handleReplay    correct
    DELETE /api/live/positions/{param1}/replay  handler=handleSettings  FABRICATED

Wrong in method, handler and line. `m`, `match`, `result` are the ordinary names
here. Two ways the truth was then lost: the fabricated route is VERBED, so
`reconcileDispatchGuardRoutes` kept it and dropped the true verb-less one — the
#2856 `/api/report` shape, through the channel this series added — and `tested`
was name-keyed too, so the tail loop suppressed the real binding's own honest
verb-less emit before reconciliation ever ran.

`matchBindings` and `tested` are now keyed on (enclosing function, name).
`enclosingFunction` is extracted from the walk `enclosingHandlerName` already
did, so there is one function-boundary mechanism, not two. A second declarator
for a key refuses it, and an assignment refuses the name in its own scope and
every enclosing one. `buildRegexConstantMap` refuses a name rebound to anything
that is not a regex literal, closing `let RE = /…/; RE = buildDynamic(req)` and
the `new RegExp(prefix + '/x')` twin.

A use resolves only within its own function. Resolving outward would need a
complete declaration model — params, imports, catch bindings — and a miss there
fabricates exactly the route this fixes. Declining costs the verb, not the path.

THE TERNARY TOOK FIRST-MATCH WHERE THE ALGEBRA IS INTERSECTION. The docblock
proves `c ? A : false ≡ c && A` and says "both hold, so search both", but
`firstNonEmpty` returned one operand's set unintersected:

    (req.method === 'GET' || req.method === 'POST')
      ? (req.method === 'POST' || req.method === 'PUT')
      : false                                    emitted GET and POST
                                                 only POST is reachable
    req.method === 'GET' ? req.method === 'POST' : false
                                                 emitted GET, unsatisfiable

`intersectVerbs` replaces it for both conjunction shapes. An empty side still
yields to the other — "names no method" is not "admits none", which is what the
`isAdmin && POST` fallthrough is for — but two non-empty sides intersect, and an
empty intersection is an unsatisfiable guard that yields no verb.

Both changes strictly REMOVE routes, so SCHEMA_BUMP 58 -> 59: routes are
parse-time output replayed verbatim from a warm cache, and without the bump an
indexed repo keeps serving the fabricated verbed route while the fix looks
implemented.

10 tests added, 9 of which fail without the change. All 86 existing assertions
pass unchanged; none was weakened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZu3G5USo5Rs9myaaxVWjK

* fix(scope-resolution): bind a type parameter only inside the scope it opened

W2-8 captured `@declaration.type-parameters` on EVERY `type_alias_declaration`,
but an alias becomes a SCOPE only when its value is an `object_type`
(`typescript/query.ts:149`). For a union, array, conditional, mapped, tuple or
function alias there is no scope, so the def — now carrying `typeParameters` —
attached to the innermost enclosing scope, which is the MODULE. And
`typeParameterNamesInScope` folds each scope's set from its PARENT'S, so the
name landed in every scope in the file. The `USES` guard then deleted every edge
whose target had that simple name:

    export interface Result { ok: boolean }
    export type Maybe<Result> = Result | null      // one ordinary line
    export function readResult(r: Result) { … }    // its USES edge is DELETED

Silent data loss, in the edge class whose whole purpose is answering "what
breaks if I remove this field?". Measured: adding two scope-less generic aliases
emptied the fixture of USES edges entirely.

The existing fixture could not see it — it wrote `type Box<Result> = { held: Result }`,
the ONE alias form that opens a scope.

`typeParameterNamesInScope` now reads a def's `typeParameters` only when that
declaration OPENED the scope owning it: `scope.kind !== 'Module'` and the def-id
position equals the scope range start, via the canonical `definitionIdPosition`
rather than slicing the id. That is the same alignment test `pickCallerCallableDef`
uses to tell a closure from a nested function, and it is language-neutral — it
also covers `function f() { type W<Result> = Result[] }`, which a module-scope-only
stopgap would miss.

Every language populating the capture was audited (ts, java, csharp, kotlin,
rust, cpp): all anchor it on a declaration that IS a scope node, including C++
where the capture rides `template_declaration` but the anchor is the inner
`class_specifier`. Go uses a separate sidecar. The TypeScript non-object alias
was the only mismatch in the codebase. `query.ts` is untouched.

THE GUARD ALSO SAT AT THE WRONG LAYER, which forced three defects at once. It
keyed on `edgeType === 'USES'` — and `mapReferenceKindToEdgeType` maps THREE
kinds there, `type-reference`, `value-ref` (#2437) and `macro` (#1934) — and,
because `Reference` carries no spelled name, substituted the resolved def's name
via `simpleNameOfDefId`. So `import { Result as ApiResult }` inside
`function unwrap<Result>()` deleted a REAL edge, while a namespace-qualified
target (`Host.Result`) kept a FALSE one, and a positional `@row:col` suffix broke
the last-colon parse outright.

Moved to `lookupForSite`'s `case 'type-reference'` in `resolve-references.ts`,
which has the spelled `site.name` and the reference kind in hand. One line closes
all three, deletes `simpleNameOfDefId` — a byte-identical duplicate of
`simpleNameOfGraphId` — and removes the only `graph-bridge/` -> `scope/` import
in that directory.

Honest scope: all three sub-defects are real in the code but none is observable
end-to-end today (`value-ref` never reaches this path; TypeScript emits no
cross-file USES for a type annotation at all — a separate pre-existing gap). Those
arms are labelled forward guards in the test rather than claimed as repros.

Fixture grows 1 file -> 4; 3 of 9 assertions fail without the change. The
scope-capture TypeScript fingerprint moves for FIXTURE-CORPUS GROWTH ONLY, with
per-file accounting that sums to the delta and JavaScript unchanged as the
control — see the `_rebaselined_` key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZu3G5USo5Rs9myaaxVWjK

* fix(scope-resolution): refuse an ambiguous formal, stop the walk at the nearest binding

Two ways W2-2 typed a parameter from the wrong caller, both at the PRECISE 0.9
tier — above every `minConfidence` floor, so nothing downstream can filter them.

`formals` WAS LAST-WRITE-WINS. The key is (filePath, ownerName, parameterIndex),
`ownerName` is a bare identifier, and `emitFormalFacts` emits one site per
parameter of EVERY function collected, nested functions and class methods
included. A plain `.set` let two same-named callables in one file collide — a
free `parse` and a nested `parse`, a free `apply` and `Runner.apply` — so the
last one visited won, fabricating an edge on the loser and leaving the genuine
consumer untyped. The file's own comment covers only the cross-FILE axis.

The correct shape was thirty lines below, in the `producers` map, which does
`producers.delete(cell); conflicted.add(cell)`. `formals` now refuses the same
way: a key claimed by two DIFFERENT parameters is deleted and recorded, so a
third same-named formal cannot re-claim it. Re-stating the same cell is not a
disagreement, so a benign duplicate capture cannot poison a real key.

THE SCOPE WALK CLIMBED PAST A NEARER BINDING. The docblock claimed it stops at
the first scope carrying the name, but it consulted only `parameterProducers` —
a shadowing `const`, a catch binding or an arrow parameter is not in that map, so
the walk went straight past it to the enclosing formal:

    function readSpike(spike) { … items.map((spike) => spike.wickRatio) … }

typed the ARRAY ELEMENT from the outer parameter. `parameterProducerFor` now
stops at the first scope that binds the name AT ALL — reading the scope's own
tables, the same channels and the same reasoning as the sibling
`isNamespaceNameShadowed` — and then stops at a Function boundary. That boundary
is what covers the anonymous arrow: `collectFunctions` drops a callable it cannot
name, so an anonymous arrow emits no formal site and its scope looks empty while
in fact rebinding the name. The cost — a closure genuinely reading an enclosing
parameter now declines — is documented as the deliberate trade.

No cycle guard, deliberately and with the reason stated: both constructions of
`indexes.scopeTree` validate through `buildScopeTree`, which enforces strict
parent-contains-child ranges, so a cycle needs a scope strictly containing
itself. A per-site Set on every read/write site in the repo would defend against
a state the builder rejects.

5 fixtures, 5 assertions; 4 fail without the change and the control passes both
ways. Still uncovered and not faked: a `for (const x of …)` binder shadow — the
binder lives in the loop header, so JS emits no scope to stop at and no Function
boundary intervenes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZu3G5USo5Rs9myaaxVWjK

* fix(analyze): measure one population in every config, split the stamp on the verdict

`1b41c9df6` fixed the collapse check for the STREAMED configuration by giving
the sink a `structuralRows` subtotal. It does not cover the other one.

`resolveStreamGraphEmit` and `resolveStreamPdgEmit` both open with a
`force === true` gate, so a run without `--force` streams nothing: there is no
manifest, `structuralRows ?? 0` contributes 0, and
`scope-resolution/pipeline/run.ts:1222` (`input.pdgEmitSink ?? graph`) writes PDG
into the ordinary in-memory graph, where `relationshipCount` counts it. And
`isIncremental` requires an existing meta, so a FIRST run is a full write and the
check runs. A first-time `gitnexus analyze --pdg` on a fresh repo therefore
compared structural+PDG against structural and exited non-zero with
"Repository indexed INCOMPLETELY" on a healthy index.

MEASURED, not assumed — `runScopeResolution({ pdg: true })` with no sink:

    pdgEmitSink        = absent (non-force shape)
    relationshipCount  = 1
    residentPdgInGraph = 1
    byType             = [["CFG",1]]

The prior `residentPdgInGraph=0` was taken on a `--force` run, where
`input.graph` IS the sink; it never spoke to this case. `graph-collapse-wiring.test.ts`
had pinned the gap, asserting a PDG-inclusive in-memory count was a valid
structural expectation.

`countStructuralRelationships(graph)` filters `PDG_EDGE_TYPES` over
`forEachRelationshipFields` — the same predicate the sink uses for
`structuralRows` and the adapter for `structuralEdges` — so all three terms
measure one population in every configuration. Declining whenever
`pdg && !streaming` was rejected: that is the DEFAULT PDG shape, so the guard
would be off for every non-force run including the only full write most users
ever do. An unscannable graph (mocked pipelines) yields NaN, the same fact the
old `undefined + rows` produced and one `detectGraphWriteCollapse` already
documents as expected input.

THE THREE-WAY STAMP WAS A TWO-WAY. The comment enumerated collapse -> stamp,
healthy -> clear, no verdict -> carry forward, but the code split on the WRITE
MODE. `graphWriteCollapsed` is undefined for two different reasons, and one of
them is "the structural query threw" — so on a full run where the count could
not be READ, the code took "healthy, clear it" and erased a stamp recording real
edge loss. Run 3 then printed "Already up to date" forever: the exact failure the
comment says it fixed, reachable through the new code's own `catch {}`.

`detectGraphWriteCollapse` now returns `'collapsed' | 'healthy' | 'unmeasurable'`
with a reason, and `selectPersistedCollapseStamp` is a pure exported function
production calls. Two boundaries worth naming: `expected === 0` is unmeasurable
(its own docstring calls it "could not report a total"), but the small-repo
exemption and a cleared ratio are HEALTHY — both counts were taken. Making the
exemption a non-verdict would leave a stamp unclearable on any repo that shrank
below 100 edges, relocating the wedge rather than fixing it.

`getLbugStats` now reports `structuralEdgesError` and warns, and `run-analyze`
falls back to `stats.edges` only when the run had no PDG layer, where the two are
equal by construction. With `--pdg` on there is no substitute, so the absence
becomes an explicit unmeasurable verdict — which preserves the stamp.

13 tests added; 8 fail without the change. The integration suite now seeds a CFG
row and asserts `edges` moves while `structuralEdges` does not — the exclusion
filter was previously unexercised, its own comment conceding "structural == total
here".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZu3G5USo5Rs9myaaxVWjK

* fix(server): check the collapse before publishing the index

W2-6 marked a collapsed run's job `failed`, but the check ran INSIDE
`.then(() => backend.init())` — after the publish. `LocalBackend.init()` is the
publish step: it refreshes the registry and atomically swaps the in-memory repo
map every MCP tool and HTTP route resolves through, and its `validate` pass
prunes only entries whose metadata is provably gone, so it can publish but never
quarantine. The known-incomplete database was therefore live and queryable before
the job was ever marked failed — the job status was a label on a published index,
not a gate. The pre-existing comment two lines above says so outright: "the repo
is actually queryable when the client receives the SSE complete event."

`backend-client.ts` routes `failed` to `onError` and never calls `onComplete`, so
the UI showed an error toast while every query against that repo answered from
the incomplete graph — precisely the confident-wrong-answers failure this guard
exists to prevent.

The collapse branch now returns before publishing; the healthy path publishes via
a nested `backend.init()` so the trailing `.catch` still converts init failures
into the same message. `closeDbHandle()` runs on both paths — it is eviction, not
publication, and the worker rewrote the DB files regardless of outcome, so
skipping it would leave a stale pre-rewrite handle.

Honest limit, stated in the error string rather than overclaimed: this keeps a
FIRST-TIME analyze unpublished, which is the UI's main flow. On re-analysis of an
already-published repo the existing map entry survives and points at the same
storagePath. A real quarantine needs an un-register hook on `LocalBackend`, which
does not exist today — follow-up.

`'partial'` was considered and rejected on evidence: it is not a status. It is an
embedding-specific detail object in the `updateJob` allowlist; the status union
excludes it. Adding it would make `isTerminalJobStatus` false, so `sse-progress`
never writes a terminal frame and never calls `res.end()` — the stream hangs
open — while `backend-client` falls through to `onMessage` and `api.ts` spins the
full hold-queue timeout. `failed` at least terminates.

The failure branch also now sets `repoName`, which only the success path did.

First tests this file has ever had: 4, of which 2 fail without the change. They
assert the ORDERING, not just the final status, and build the worker message by
calling the production `projectAnalyzeResultForIpc` so a field rename breaks the
test instead of silently disabling the branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZu3G5USo5Rs9myaaxVWjK

* fix(processes): count the entry-point cap, and make the disclosure proportionate

W2-3 added a truncation disclosure and then missed the largest ceiling it was
written to report. `findEntryPoints` ends `.slice(0, 200)` and
`entryPointsUnexplored` counted against the POST-slice list, so candidates
201..N were invisible — while the derivation docblock claimed "a new ceiling
added later cannot be forgotten here". An existing one was. On this repo's own
corpus the new counter reads 780 of 980 candidates never ranked in.

`entryPointCandidatesDropped` reports the pre-slice count, folded into
`truncated`, with `ENTRY_POINT_CANDIDATE_LIMIT` extracted and `findEntryPoints`
taking the same optional out-parameter `traceFromEntryPoint` already uses. Its
return contract is unchanged.

THE WARN FIRED ON EVERY RUN. At the shipped defaults — only `maxProcesses` is
overridden — `calleesDropped` fires for any function with 5+ callees and
`tracesDepthCapped` for any chain deeper than 10, so an ungated `logger.warn`
was constant background noise, and a warning that always fires is one nobody
reads. The split is the module's own, from the `ProcessTruncationStats` docblock:
"unexplored entry points mean whole flows are missing, while a depth-capped trace
means a flow is present but shorter than it really is."

So `warn` iff whole flows are absent — candidates dropped, entry points never
traced, or flows dropped at `maxProcesses` — and `debug` for a run truncated only
in depth or breadth. `stats.truncation` still carries all six counters; the
machine-readable channel is unchanged, only the log level moves.
`entryPointCandidatesDropped` stays in the loud set deliberately: it is the only
ceiling that GROWS with repo size, while the other two can only fire while
`maxProcesses` is small enough to bind, so gating on those alone would go silent
on exactly the large repos where 200-of-several-thousand is the thinnest sample.
The message leads with the ratio so the line carries a fact, not an alarm.

THREE COMPARATORS ALLOCATED PER COMPARISON, in the function whose own comment
explains the hoist that removed this shape (`deep_chain` 1233 -> 102 ms).
Measured here: +99 ms once per analyze at 80k functions — small, because `n` is
capped at 200 entry points x a 12-trace budget = 2,400 traces regardless of repo
size. Worth fixing anyway: 23,851 comparisons cost 70,524 joins.

One shared `sortByDepthThenPath` (Schwartzian, key built once per trace) now
serves all three sites, and `rankedByInterest` additionally hoists the `isSink`
test that ran twice per comparison. It also settles a separator inconsistency:
`deduplicateByEndpoints` joined on a SPACE while `traceOrderKey` used NUL, and
node ids embed file paths, so two different traces could produce the same key and
the tiebreak fell back to the insertion order it exists to remove — the same
hazard this series' own `->`-padding fix addresses. Order identity is pinned by a
seeded 200-trace corpus asserting the new sort equals the old one exactly.

11 tests added, 9 failing without the change, including two end-to-end
insertion-order arms. The W2-5 determinism block is unregressed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZu3G5USo5Rs9myaaxVWjK

* fix(docs): restore the agent guidance, and put it in the generator that deleted it

Commit `9e602aef0` — whose message is entirely about the fetch capture — also
regenerated the machine-managed `<!-- gitnexus:start -->` block from a local
non-`--pdg` index, deleting from both AGENTS.md and CLAUDE.md:

  - the whole `MUST treat risk: UNKNOWN as unresolved, not as low` bullet
  - the `pdg_query({mode:"controls"/"flows"})` bullet
  - the `mode: "pdg"` text on the impact bullet
  - `…never read UNKNOWN as an all-clear…` from Never Do

and regressing the stats 248612/565510/918 -> 42853/135955/758. All four document
SHIPPED features: `pdg_query` at `mcp/tools.ts:675`, dispatched at
`local-backend.ts:2233`; `mode: "pdg"` at `tools.ts:448`; `riskNote` at eight
sites.

It matters more than a docs nit because the SAME series makes `UNKNOWN` dominate
a mixed candidate set (`local-backend.ts:6058`) — correct, and it makes UNKNOWN
far more common. The surviving rule only warns on HIGH/CRITICAL, so a set
measuring CRITICAL now reports UNKNOWN and that rule no longer fires, while the
rule that covered the gap was deleted in the same commit range, from all three
files agents actually read.

ROOT CAUSE, which is why restoring the files alone would not have held.
`cli/ai-context.ts` is the template. The `pdg_query` and `mode: "pdg"` text IS in
it, correctly `hasPdg`-gated — a non-PDG analyze SHOULD drop those. The
`risk: UNKNOWN` rules were never in the template at all: they had been hand-added
INSIDE the machine-managed region, so every `gitnexus analyze` on any repo
silently deleted them. This was the second occurrence; #2856's `8f8261021` was
the first. Both lines are now generated unconditionally — they describe impact's
risk semantics, which are not PDG-dependent — so regeneration restores them
instead of removing them.

AGENTS.md and CLAUDE.md are byte-identical to origin/main again, and the fixed
template reproduces that block exactly for `hasPdg: true` plus the real stats.
`.claude/skills/gitnexus-guide/SKILL.md` regains the "Inline staleness signal"
section for a live feature (`local-backend.ts:921`, `:1017-1024`, `:1995`); the
npm mirror's lack of it is pre-existing drift and is left alone, so the new sync
guard is scoped to the canonical and plugin copies.

Guards added, both demonstrated failing against the unrestored files: the managed
block must contain the UNKNOWN policy and its Always-Do/Never-Do bullet counts
must not fall below a floor, and `generateGitNexusContent` must render both lines
for `hasPdg` true AND false while keeping `pdg_query` gated. The existing
fragment lists could never have caught this — they assert presence, and this was
a deletion.

One deliberate loosening, called out rather than buried: the restored text pushes
`ai-context.test.ts`'s block-size ratio past 0.55, so it moves to 0.65. That test
argues against exactly this nudge-the-number pattern. The defence is that the
wording is origin/main's own and the 0.55 budget was calibrated against a block
already missing it; trimming shipped guidance to fit a budget would be the wrong
direction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LZu3G5USo5Rs9myaaxVWjK

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-08-09 11:44:52 +01:00