Compare commits

..
Author SHA1 Message Date
copilot-swe-agent[bot]andmagyargergo 740d3b9b83 chore(parse): clarify test description and warning message phrasing
Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/5591f1ba-9032-4706-b007-effb0df46eb8

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
2026-05-18 06:47:41 +00:00
copilot-swe-agent[bot]andmagyargergo 350b1ece63 chore(parse): use template literal for warning, add .hh test case
Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/5591f1ba-9032-4706-b007-effb0df46eb8

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
2026-05-18 06:46:45 +00:00
copilot-swe-agent[bot]andmagyargergo af81512f61 chore(parse): rename chunkHasCppFiles → chunkHasWorkerUnsafeFiles, add hpp/hxx test
Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/5591f1ba-9032-4706-b007-effb0df46eb8

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
2026-05-18 06:45:36 +00:00
copilot-swe-agent[bot]andmagyargergo 950f41bfdf fix(analyze): per-chunk C/C++ guard — workers for all other languages unchanged
Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/5591f1ba-9032-4706-b007-effb0df46eb8

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
2026-05-18 06:44:14 +00:00
copilot-swe-agent[bot] f1593016da chore(parse): refine C/C++ worker guard naming and warning guidance
Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/1417a00f-d72b-4659-8c8e-e0c8a1cbfa4c
2026-05-18 06:28:15 +00:00
copilot-swe-agent[bot] f2cbf27219 refactor(parse): move worker language guard helper and clarify warning
Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/1417a00f-d72b-4659-8c8e-e0c8a1cbfa4c
2026-05-18 06:27:01 +00:00
copilot-swe-agent[bot] 676f91ebf5 fix(analyze): disable worker pool by default for C/C++ parse workloads
Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/1417a00f-d72b-4659-8c8e-e0c8a1cbfa4c
2026-05-18 06:25:26 +00:00
copilot-swe-agent[bot] 8c5648563f Initial plan 2026-05-18 06:04:20 +00:00
Copilot 7d500390b9 fix: Use Ladybug native read-only enforcement and prepared statement execution for Cypher query paths (#1655) 2026-05-18 06:54:24 +01:00
Nilotpal Kashyap bdc0439a10 feat(detect-changes): support git worktrees (#1654) 2026-05-17 20:54:41 +01:00
Shane Thurston Wijaya 105efd0f7c feat(wiki): added --lang <lang> flags to gitnexus wiki for multilanguage wiki generation support (#1613) 2026-05-17 19:54:02 +01:00
Copilot 493827222d fix(ingestion): Raise analyze auto-heap to 16GB and tighten cross-platform OOM guidance for UE5-scale repositories (#1652) 2026-05-17 16:28:07 +01:00
Copilot ed50a6729f fix(wiki): Remove the hidden 60s default timeout, validate gitnexus wiki timeout/retry flags, and surface timeout errors (#1651) 2026-05-17 12:03:54 +01:00
Nilotpal Kashyap dfbe68ad24 fix(lbug): issue #1647, detect WAL corruption in schema init and surface recovery (#1650) 2026-05-17 10:46:45 +01:00
azizur100389andGergő Magyar 2376912ca7 feat(ingestion): Add C++ parameter type class sidecar (#1642)
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-16 21:44:26 +01:00
Zander Raycraft a4dfebd073 feat(cpp): sfinae filter (#1623)
* feat(cpp): SFINAE-aware overload filter — drops candidates whose enable_if_t / requires constraints fail (#1579)

* fix(cpp):  SFINAE follow-ups for is_integral_v/is_arithmetic_v bool and char support, an unqualified F1 test fixture, and parameter-lookup gap documentation (#1579) -> claude feedback

* revert: reverting all changes to .md files
2026-05-16 20:23:13 +01:00
Gergő Magyar 42d4fcaf6f chore: release v1.6.5 (#1645) 2026-05-16 17:11:25 +01:00
a26ac55fb0 fix(lbug): Recover gitnexus analyze from orphan LadybugDB sidecars when main DB file is missing (#1622)
* Initial plan

* fix: recover from orphan lbug sidecars on init

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/6e8ea6e8-f9ab-46ff-9c1b-4d2c73a6452c

* test: strengthen orphan sidecar recovery coverage

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/6e8ea6e8-f9ab-46ff-9c1b-4d2c73a6452c

* fix(lbug): only clean orphan sidecars when DB is missing

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/a34217b5-0e98-4949-bae1-2a50933f291e

* test(lbug): cover no-cleanup path when db file exists

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/a34217b5-0e98-4949-bae1-2a50933f291e

* test(lbug): use errno-shaped ENOENT mocks for sidecar recovery

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/a34217b5-0e98-4949-bae1-2a50933f291e

* test(lbug): cover partial sidecar and unlink-failure recovery cases

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/a34217b5-0e98-4949-bae1-2a50933f291e

* refactor(lbug): tighten ENOENT detection and test naming

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/a34217b5-0e98-4949-bae1-2a50933f291e

* test(lbug): normalize errno mock helpers across sidecar tests

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/a34217b5-0e98-4949-bae1-2a50933f291e

* docs(lbug): annotate orphan `.wal.checkpoint` cleanup provenance

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/7edf5156-43e0-412d-87a4-bf4b2934deac

* test(lbug): clarify unlink-failure path test intent

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/7edf5156-43e0-412d-87a4-bf4b2934deac

* fix(lbug): handle orphan-sidecar cleanup error paths explicitly

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/93294b2c-57f6-459c-8eb2-86e3b8920fb0

* refactor(lbug): extract errno and error-summary helpers

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/93294b2c-57f6-459c-8eb2-86e3b8920fb0

* test(lbug): expand non-ENOENT lstat coverage and remove magic number

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/93294b2c-57f6-459c-8eb2-86e3b8920fb0

* test(lbug): add native integration test for orphan sidecar recovery

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/2dd28264-4604-430a-a249-af52afd29245

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* test(lbug): annotate best-effort catch in integration test cleanup

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/2dd28264-4604-430a-a249-af52afd29245

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

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

* fix(lbug): add cross-process init lock for orphan sidecar cleanup with integration tests

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e4cbcfec-a252-449d-8d65-2f3570a253f8

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* refactor(lbug): use INIT_LOCK_STALE_MS in stale lock detection and address review feedback

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/e4cbcfec-a252-449d-8d65-2f3570a253f8

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* style(lbug): fix Prettier line-length violation in acquireInitLock fs.open call

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/a140b567-0e9b-4ec9-a158-9fe6b8685ec2

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix(lbug): ensure parent directory exists before creating init lock file

acquireInitLock tried to create `${dbPath}.init.lock` using O_CREAT | O_EXCL,
but on a fresh repo the parent directory (`.gitnexus/`) doesn't exist yet —
the mkdir call was inside the locked section. This caused ENOENT failures
on all platforms (Windows, macOS, Ubuntu) during `gitnexus analyze`.

Move mkdir to before the lock file creation attempt.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/6883dc3c-36eb-4907-bcd8-61d23e2c641a

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* test(lbug): verify acquireInitLock succeeds when parent directory does not exist

Adds an integration test proving the fix from the previous commit:
acquireInitLock now creates the parent directory before attempting
to create the lock file, preventing ENOENT on fresh repos.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/6883dc3c-36eb-4907-bcd8-61d23e2c641a

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-16 11:45:32 +01:00
azizur100389andGergő Magyar 467c14caa2 feat(cpp): standard-conversion-sequence ranking for overload resolution (#1606)
* feat(cpp): add standard-conversion-sequence ranking to overload resolution (#1578)

Introduce `ConversionRankFn` abstraction and `cppConversionRank` implementation
to disambiguate C++ overloaded calls by argument-to-parameter conversion cost.
Exact type match (rank 0) beats standard arithmetic conversion (rank 2), which
beats non-viable mismatch (Infinity). Thread the rank function through
`narrowOverloadCandidates`, `pickImplicitThisOverload`, `pickOverload`, and
`pickUniqueGlobalCallable` via the `ScopeResolver.conversionRankFn` contract.
Add `findAllCallableBindingsInScope` scope walker for collecting all overloads
at the first binding scope. Guard against false ambiguity suppression when
candidates span different files (local-shadows-import preservation).

* fix: address Claude review findings on conversion-rank PR

Finding 1 (HIGH): add tests that exercise the conversion ranker.
  - p('a') with p(int)/p(double): char→int promotion (rank 1) beats
    char→double conversion (rank 2), forcing step 4b in
    narrowOverloadCandidates. Exact-type filter misses both overloads.
  - h(42, 2.5) with h(int,int)/h(double,double): multi-arg tied total
    score forces the ranker, both candidates score 2 → suppressed.

Finding 2 (HIGH): unify multi-candidate suppression across all paths.
  - Non-ADL free-call: suppress when narrowed.length > 1 (same-file
    guard), mirroring ADL merged-candidate behavior.
  - ADL ordinary-only: same pattern.
  - pickOverload: return OVERLOAD_AMBIGUOUS when candidates.length > 1
    after normalized-ambiguity check.
  - Case 0.5 (this receiver): set ambiguous=true when narrowed > 1.

Finding 3+4 (MEDIUM): implement rank-1 integral promotions.
  - char→int and bool→int now return rank 1 (ISO C++ [conv.prom]).
  - Updated comment to remove misleading ISO table header; document
    only the post-normalization ranking that is actually implemented.
  - Updated ConversionRankFn JSDoc in overload-narrowing.ts.

218/218 C++ tests pass (registry-primary). Legacy: 186+32.

* fix: implement pairwise dominance comparison for overload ranking

Replace the summed per-slot conversion cost with ISO C++-aligned
pairwise dominance comparison ([over.ics.rank]). F1 is better than
F2 only when F1 is not worse for every argument and strictly better
for at least one. Non-dominated candidates are returned; if multiple
remain they are genuinely ambiguous.

This fixes false CALLS edges for asymmetric multi-arg overloads:
h('a', 2.5) against h(int,int) / h(double,double) — the old summed
cost picked h(double,double) (cost 2 < 3), but ISO C++ considers
the call ambiguous because h(int,int) is better at arg 0 via char
promotion. The pairwise check correctly finds neither dominates.

Add h('a', 2.5) test case asserting zero CALLS edges alongside
the existing h(42, 2.5) symmetric-tie test.

218/218 C++ tests pass (registry-primary). Legacy: 186+32.

* docs: update step 4b JSDoc to reflect pairwise dominance

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-16 11:15:21 +01:00
fa06c5610b fix: resolve cross-file type propagation stall on large repos (#1626)
* Initial plan

* fix: add time-based deadline to cross-file type propagation to prevent stalling on large repos

Adds a 2-minute wall-clock time limit (DEFAULT_CROSS_FILE_ELAPSED_MS) to
runCrossFileBindingPropagation. When exceeded, the phase gracefully stops
and logs a warning. Users can override via GITNEXUS_CROSS_FILE_TIMEOUT_MS
env var. This prevents the analyze command from stalling for hours on very
large repositories where per-file re-resolution is expensive.

Fixes the reported issue where gitnexus analyze stalls at "Cross-file type
propagation" for several hours on repos with 15000+ files.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b8341947-557c-4111-a3a8-991ba455ab01

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: root cause - cache tree-sitter queries across files, add live progress reporting

Root cause: cross-file propagation called processCalls() with 1 file at a time,
causing Parser.Query to be recompiled from the query string for every single file
(O(N) compilations vs O(1) for the whole phase). Additionally, progress was only
reported once at the start, making the phase appear completely frozen.

Fixes:
- Add optional `compiledQueryCache` parameter to `processCalls` so callers that
  invoke it with single-file batches can share compiled query objects across calls.
  The cross-file phase now compiles each language's query string exactly once and
  reuses it for all files of that language (e.g. 1 TypeScript compile for 595+ files).
- Pre-count candidate files and emit onProgress every 25 files showing
  "Cross-file type propagation (N/M files)..." so the UI shows real movement
  instead of a frozen bar.
- Keep the wall-clock deadline (GITNEXUS_CROSS_FILE_TIMEOUT_MS) as a safety
  net for pathological inputs.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/f5028cc8-4bc9-4309-8ffb-798fe2bd7a0a

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: address code review - use SupportedLanguages key type, rename queryCache to compiledQueryCache

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/f5028cc8-4bc9-4309-8ffb-798fe2bd7a0a

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

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

* fix(cross-file): remove wall-clock timeout from type propagation

The query compilation cache and live progress reporting address the
original stall; the 2-minute deadline could truncate cross-file work on
large repos. MAX_CROSS_FILE_REPROCESS (2000) remains as the only cap.

* test(cross-file): verify compiledQueryCache is shared across all processCalls invocations

Finding 1: O(N) query recompilation was fixed by sharing a compiledQueryCache Map
across all processCalls invocations in runCrossFileBindingPropagation. This test
verifies the fix is correctly wired: the same Map instance is passed as the
12th argument to every call, proving queries are compiled once per language,
not once per file.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/3ab768d9-3993-4882-9d8f-17f7fcbd086e

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* test(cross-file): verify live progress events are emitted with N/M format

Finding 2: frozen progress display was fixed by emitting onProgress every 25 files
with "Cross-file type propagation (N/M files)..." messages instead of calling it
once at phase start. This test verifies the fix with 50 candidate files: expects
onProgress called 3 times (1 initial + at 25 + at 50) with correct N/M counters.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/3ab768d9-3993-4882-9d8f-17f7fcbd086e

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix(cross-file): skip registry-primary language files before readFileContents

Finding 3 (from comment 4466231612): cross-file-impl was calling processCalls
for every candidate file even when that file's language is registry-primary
(TypeScript, C++, Python, Go, C#, PHP, C — since AGENTS.md v1.7.0). processCalls
would immediately skip those files via its own isRegistryPrimary guard, but
cross-file-impl still paid the full cost: readFileContents I/O, buildImportedReturnTypes,
buildImportedRawReturnTypes, and Map allocation — all discarded.

Fix: check isRegistryPrimary(lang) in both the totalCandidates pre-count loop
and the levelCandidates builder, before any file I/O or map building. This
eliminates 595+ no-op processCalls invocations on large TypeScript repos.

Test: mocks isRegistryPrimary to always return true and verifies that
processCalls is never invoked and result is 0. The mock also defaults to false
in beforeEach so existing tests using .ts files are unaffected.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/3ab768d9-3993-4882-9d8f-17f7fcbd086e

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* refactor(test): address code review - simplify mock factory, name the arg index constant

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/3ab768d9-3993-4882-9d8f-17f7fcbd086e

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-16 10:02:40 +01:00
Gergő Magyar f28185d67e fix(ci): bump publish job to Node 24 for npm OIDC support (#1628)
PR #1627's npm install -g npm@latest step crashed mid-install with MODULE_NOT_FOUND: promise-retry — a known fragility when npm self-upgrades. Node 22's bundled npm is 10.9.x (no OIDC). Fix: bump publish job's node-version to 24, which ships with npm 11.x natively. Package consumers unaffected (this Node version is only used during publish; engines.node is >=22.0.0; ci-tests.yml continues testing on Node 22).
2026-05-16 09:18:08 +01:00
Gergő Magyar f69c382bcb fix(ci): engage npm Trusted Publishing OIDC properly (#1627)
First live-fire RC publish after #1610 failed at npm publish with E404. The if: failure() cleanup correctly auto-deleted the partial v-tag and rc-marker, but OIDC never engaged. Root cause: two coordinated upstream bugs.

1. actions/setup-node@v6 with registry-url: writes _authToken into the runner .npmrc AND exports NODE_AUTH_TOKEN from its token: input (defaulting to github.token). npm publish sends GITHUB_TOKEN as the bearer and the registry returns 404. OIDC never tried because npm thinks it already has a credential. See actions/setup-node#1440.

2. The Node 22 runner ships with npm 10.9.x. npm Trusted Publishing OIDC support requires npm >= 11.5.1.

Fix: omit registry-url: from the setup-node step (per the consensus workaround in community discussion #176761), and add npm install -g npm@latest before publish. --provenance flag is NOT added; npm auto-attaches provenance under Trusted Publishing.

Sources:
- https://github.com/actions/setup-node/issues/1440
- https://github.com/orgs/community/discussions/176761
- https://docs.npmjs.com/trusted-publishers/
2026-05-16 08:36:49 +01:00
Gergő Magyar 83fbd4be26 refactor(ci): unify release pipeline under publish.yml (#1610)
Collapse release-candidate.yml into publish.yml so there is exactly one workflow that publishes gitnexus to npm, creates GitHub Releases, and triggers Docker builds — for both release candidates and stable releases. Closes #1609 architecturally.

A first-stage `route` job classifies push-to-main / push-tag / workflow_dispatch into `rc` / `stable` modes and fails closed on malformed shapes. RC path runs rc-guard → ci.yml → publish (mint GitHub App token → checkout with persist-credentials:false → resolve next rc version → atomic v-tag + rc/<SHA> marker push → vtag integrity gate → npm publish via OIDC → GitHub prerelease → if: failure() cleanup) → docker.yml. Stable path verifies package.json matches the tag and publishes to `latest` via OIDC (no docker).

Hardening:

  • Self-trigger prevention via negative-glob `tags: ['v*', '!v*-rc.*']` — the bug class behind #1609 cannot recur.
  • Two distinct actions/checkout steps per mode (no conditional `token:` expression footgun).
  • Workflow-level `permissions: {}` deny-all + per-job grants; `id-token: write` only where OIDC is used.
  • npm Trusted Publishing replaces NPM_TOKEN (delete the secret after the first successful publish).
  • GitHub App installation token (actions/create-github-app-token@v3.2.0) replaces the long-lived RELEASE_PUSH_TOKEN PAT (delete after first successful RC).
  • vtag integrity gate fails closed on empty / mode-mismatched output (prevents Release named `main` from a github.ref fallback).
  • Annotation-injection sanitization on every logged ref.
  • Explicit `secrets:` passthrough on docker.yml (DOCKERHUB_USERNAME, DOCKERHUB_TOKEN); ci.yml no longer inherits anything.
  • `if: failure()` cleanup auto-deletes v-tag + rc-marker on partial failure (eliminates the external-consumer phantom-version ingestion window).
  • ACTIONS_STEP_DEBUG window closed via `set +x` wrap on the inline auth-header compute.
  • Curated retry-loud error handling on `gh api` bot-user-id lookup and `npx semver`.

Pre-merge validation:

  • 10-reviewer multi-agent code-review pass; 14 findings fixed inline (commit 820cefae), 6 deferred to follow-ups.
  • End-to-end dry-run rehearsal via workflow_dispatch (run 25919563064) validated route classification, rc-guard, App token mint, RC checkout, version resolver, vtag synthetic-regex check, and faithful tarball pack at the bumped version.
  • All zizmor findings on the unification commits closed.
  • Branch-protection required checks all green.

Post-merge actions:

  • After the first successful RC, delete the `NPM_TOKEN` and `RELEASE_PUSH_TOKEN` secrets — they are no longer used.
  • The first real RC after merge is the live-fire test for steps dry-run could not exercise (atomic tag push, real npm OIDC handshake, GitHub Release creation, docker.yml under explicit secrets passthrough). The if: failure() cleanup step handles the partial-failure recovery automatically; the Rollback Runbook in CONTRIBUTING.md covers the rare cases auto-cleanup can't reach.
2026-05-16 07:46:56 +01:00
263ca353a6 fix: shard parse cache persistence on large repos (#1580)
* fix: shard parse cache persistence on large repos

* fix(parse-cache): validate shard keys, docs, and sharded-cache tests

- Reject non-sha256-hex keys from index.json before path.join (path traversal).

- saveParseCache: skip invalid keys defensively; try/catch per-shard JSON.stringify.

- Clarify save comment (tmp dir + rename vs atomic).

- Tests: hex keys throughout, traversal keys, multi-shard, version-mismatch+legacy, second save, legacy removal.

- AGENTS.md / GUARDRAILS.md: document .gitnexus/parse-cache/ vs legacy parse-cache.json.

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

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

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-16 07:19:06 +01:00
azizur100389 8500f18e5f fix(cpp): detect same-name ambiguity across inline namespace children (#1564) (#1600) 2026-05-15 19:11:09 +01:00
Copilotandmagyargergo aed370b931 feat: C++ ADL V2: merge ordinary and ADL free-call candidates before overload selection (#1599)
* Initial plan

* Merge C++ ADL and ordinary free-call candidate sets

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/1aea3511-3471-4ec2-9819-0fb27ac40b89

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* Address review feedback on merged ADL ambiguity suppression

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/1aea3511-3471-4ec2-9819-0fb27ac40b89

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: apply prettier to C++ ADL resolver fallback files

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9b9c1494-bc69-4db5-a89d-69eb816bab82

* docs: update ADL ambiguity comments to merged narrowing flow

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9b9c1494-bc69-4db5-a89d-69eb816bab82

* fix: suppress global fallback when merged ADL narrowing yields zero candidates

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9b9c1494-bc69-4db5-a89d-69eb816bab82

* docs: clarify free-call fallback comment for ADL merged path

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/9b9c1494-bc69-4db5-a89d-69eb816bab82

* feat: ADL Gap 2 — enum-typed arguments contribute enclosing namespace

ISO C++ [basic.lookup.argdep] §2: "If T is an enumeration type, its
associated namespace is the namespace in which it is defined."

- Add Enum to findCppClassDefBySimpleName type filter
- Map Enum defs to enclosing namespace in populateCppAssociatedNamespaces
- Add test fixture cpp-adl-enum-arg with color::Channel enum

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/ca8987b6-365e-4034-af56-ca3f9b439902

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* feat: ADL Gap 6 — inline namespace expansion in associated set

ISO C++ inline namespaces are transparent for ADL: if a namespace is
in the associated set, candidates declared in its inline-namespace
children are also reachable.

- Expand pickCppAdlCandidates to scan inline-namespace children of
  associated namespaces (via isCppInlineNamespaceScope predicate)
- Add test fixture cpp-adl-inline-ns-expansion: Event in outer audit,
  record in inline v1, other::record(int) forces arity disambiguation

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/ca8987b6-365e-4034-af56-ca3f9b439902

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* feat: ADL Gap 1 — hidden friend functions visible via ADL

ISO C++ [basic.lookup.argdep] §2: friend functions declared inside a
class body are visible via ADL when the class is an associated class.

- Exempt friend_declaration from cppLabelOverride's class-body function
  suppression (c-cpp.ts) so friend function defs are captured
- Scan Function scopes that are direct children of associated Class
  scopes in pickCppAdlCandidates (adl.ts) to find hidden friends
- Add test fixture cpp-adl-hidden-friend: `friend void process(Foo&)`
  declared inside lib::Foo, resolved via ADL from app::run()

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/ca8987b6-365e-4034-af56-ca3f9b439902

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* feat: ADL Gap 3 — non-function ordinary lookup suppresses ADL

ISO C++ [basic.lookup.unqual] §7: if ordinary unqualified lookup finds
a name that is not a function or function template, ADL is not performed.

- Add hasNonCallableBindingInScope walker in walkers.ts
- In free-call-fallback, check for non-callable binding before invoking
  ADL; when found, bypass resolveAdlCandidates entirely
- Add test fixture cpp-adl-non-function-blocks: variable `int record`
  shadows the function name, blocking ADL from finding audit::record

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/ca8987b6-365e-4034-af56-ca3f9b439902

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: use nearest-scope semantics for ADL non-callable blocker check

Finding 1: `hasNonCallableBindingInScope` walked the entire scope chain,
which could incorrectly suppress ADL when an inner scope had a callable
and an outer scope had a non-callable for the same name. Per ISO C++
`[basic.lookup.unqual]` §7, ADL is blocked only when ordinary lookup
itself finds a non-function — if ordinary lookup stops at an inner scope
where only callables exist, ADL should still fire.

Replace the separate `hasNonCallableBindingInScope` + `findAllCallable
BindingsInScope` calls with a combined `findCallableBindingsAndAdlBlocker`
walker that stops at the first scope with ANY binding for the name and
returns both `{ callables, nonCallableFound }`. One pass, one stop.

Fixture: cpp-adl-inner-callable-outer-noncallable — inner scope has
callable `swap(int,int)`, outer scope has `int swap = 0`. ADL fires and
resolves to `data::swap(Pair&,Pair&)` via argTypes narrowing.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/a2f97daf-17fd-4891-8b10-a81e44d32808

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix: block-scope function declaration suppresses ADL

Finding 2: ISO C++ [basic.lookup.argdep] lists three ADL blockers:
1. class member declaration (handled by pickImplicitThisOverload)
2. block-scope function declaration NOT a using-declaration (NEW)
3. non-function/non-template declaration (handled by nonCallableFound)

Extend `findCallableBindingsAndAdlBlocker` to return `blockScopeDeclFound`
when a callable is found at a Function or Block scope — indicating a local
forward declaration that should suppress ADL per standard.

`free-call-fallback.ts` now checks both `nonCallableFound` and
`blockScopeDeclFound` to determine ADL suppression.

Fixture: cpp-adl-block-scope-decl-blocks — `void record(int);` declared
inside function body prevents ADL from discovering audit::record.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/a2f97daf-17fd-4891-8b10-a81e44d32808

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* docs: update stale ADL_AMBIGUOUS comment in unqualified-ref-collision fixture

Finding 3: The `ADL_AMBIGUOUS` sentinel was removed by this PR (replaced
by `isOverloadAmbiguousAfterNormalization` in merged-narrowing). Update
the fixture comment to reference the current mechanism.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/a2f97daf-17fd-4891-8b10-a81e44d32808

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* test: add legacy-parity expected failures for ADL blocker tests

The new ADL nearest-scope blocker and block-scope function declaration
tests rely on scope-resolution-only mechanisms not present in the legacy
DAG path. Register them in LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/a2f97daf-17fd-4891-8b10-a81e44d32808

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* chore: revert unrelated prettier-plugin-tailwindcss devDep addition

The `prettier-plugin-tailwindcss` dependency was accidentally added while
running local prettier; it is not needed for the C++ ADL changes.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/a2f97daf-17fd-4891-8b10-a81e44d32808

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
2026-05-15 15:41:14 +01:00
99b8c7b03b feat: C++ ADL V2: free-function reference args contribute enclosing namespace (#1598)
* Initial plan

* cpp ADL V2: free-function reference args contribute enclosing namespace

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/24805583-c0c4-4ef8-978f-b874bd917947

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* merge: resolve conflicts with origin/main and fix overloaded fixture app.cpp

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/136aeffe-45da-47e2-95dd-e3883e85fad7

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix(finding-1): replace ISO C++ [basic.lookup.argdep] misstatement with GitNexus-approximation label

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/fa37c0dd-65f9-4dd4-9811-617227a37073

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix(finding-2): verify Function/Method exists in namespace before contributing via qualified_identifier arg

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/fa37c0dd-65f9-4dd4-9811-617227a37073

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix(finding-3): function parameters in parameter_list no longer misclassified as free-function refs

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/fa37c0dd-65f9-4dd4-9811-617227a37073

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* doc(finding-4): document typedef/using-aliased function-pointer limitation in lookupAdlIdentifierType

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/fa37c0dd-65f9-4dd4-9811-617227a37073

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* test(finding-5): add negative fixtures for local-fp shadowing free-func and unqualified namespace collision

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/fa37c0dd-65f9-4dd4-9811-617227a37073

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

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

* fix(legacy-parity): skip two new negative-fixture tests from legacy DAG parity run

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/64ffbaf1-f442-4a2b-8542-4afa500d9182

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-15 10:55:44 +01:00
813acd7ec5 feat: C++ ADL V2: include base-class associated namespaces via MRO (#1597)
* Initial plan

* fix(cpp): include base-class namespaces in ADL candidate selection

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/7df6f692-1af9-43e6-82de-099ed43a60cb

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* test(cpp): clarify ADL base-namespace test names

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/7df6f692-1af9-43e6-82de-099ed43a60cb

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* test(cpp): remove stale legacy parity expected-failure entry

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/1a55a5e8-ae91-44bc-9b21-9324cdfea3de

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* test(cpp): assert base-namespace ADL tests are not parity skips

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b3726b70-e797-4f37-955d-7d61fd28d338

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* fix(cpp): avoid MRO amplification on ambiguous class-name ADL lookup

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b3726b70-e797-4f37-955d-7d61fd28d338

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* test(cpp): strengthen ADL base-namespace target identity assertions

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b3726b70-e797-4f37-955d-7d61fd28d338

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* test(cpp): add ADL negative cases for anonymous and unresolved bases

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/b3726b70-e797-4f37-955d-7d61fd28d338

Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>

* test(cpp): fix anonymous-base parity expectation and formatting

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/6a2e3cf9-beea-435c-8494-6a7a00af0f1e

* fix(cpp): propagate unnamed-namespace members through #include in registry-primary resolver

Anonymous-namespace contents in a header (e.g. `namespace { void f(); }`)
are reachable by unqualified lookup in any TU that #includes the header
per ISO C++ [basic.namespace.anon]/1 (the unnamed namespace behaves as
if a `using namespace unique;` is inserted into the enclosing scope, with
per-TU `unique`). The registry-primary path was filtering these defs out
of `expandCppWildcardNames` via both the structural Namespace-owner check
and the `isFileLocal` mark, so `hidden_probe(d)` from a TU including the
header resolved to nothing while the legacy DAG returned the correct edge.

Track anonymous-`namespace_definition` source ranges at capture time,
resolve them to ScopeIds in `populateOwners` (parallels inline-namespace
handling), and exempt those scopes from the two wildcard-expansion filters
plus the `populateCppNonGloballyVisible` structural set. `markFileLocal`
is preserved so the global free-call fallback still blocks cross-TU leaks
for files that do NOT #include the declaring file (cpp-anon-ns-cross-file
guard still passes).

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
2026-05-15 07:54:23 +01:00
Copilot 7fbf302018 feat: C++ ADL V2: include template-specialization associated namespaces (with nested template args) (#1596) 2026-05-15 05:04:42 +01:00
dependabot[bot] 5ee1122330 chore(deps)(deps-dev): bump vitest from 4.1.5 to 4.1.6 in /gitnexus (#1605) 2026-05-14 22:28:31 +01:00
Copilot cdac8a691a feat: C++ ADL V2: include class-typed reference args (incl. rvalue refs) in associated-namespace lookup (#1595) 2026-05-14 20:25:12 +01:00
143 changed files with 9516 additions and 1397 deletions
+8 -8
View File
@@ -11,14 +11,14 @@ permissions:
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
# Hardcoded `CI-` prefix (not `${{ github.workflow }}`) because this workflow is
# invoked as a reusable workflow from publish.yml and release-candidate.yml. In
# called-workflow context `github.workflow` evaluation is ambiguous across GitHub
# Actions versions, and a prefix that could resolve to the caller's name would
# share a concurrency group with the caller → deadlock. A literal prefix is
# immune. Direct `pull_request` invocations use `CI-<ref>`; invocations from a
# reusable-workflow caller fall into a per-run-unique group that never serializes
# with the caller. `push` to main is handled by release-candidate.yml, which
# calls this workflow once before publishing.
# invoked as a reusable workflow from publish.yml. In called-workflow context
# `github.workflow` evaluation is ambiguous across GitHub Actions versions, and a
# prefix that could resolve to the caller's name would share a concurrency group
# with the caller → deadlock. A literal prefix is immune. Direct `pull_request`
# invocations use `CI-<ref>`; invocations from a reusable-workflow caller fall
# into a per-run-unique group that never serializes with the caller. `push` to
# main is handled by publish.yml (RC mode), which calls this workflow once
# before publishing.
concurrency:
group: ${{ github.event_name == 'pull_request' && format('CI-{0}', github.ref) || format('CI-nested-{0}', github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+10 -1
View File
@@ -25,6 +25,15 @@ on:
a gitnexus/package.json whose version matches the tag.
required: true
type: string
# Explicit secret contract — callers pass these by name. Replaces the
# blanket `secrets: inherit` pattern (zizmor `secrets-inherit` audit).
# GHCR auth uses the implicit GITHUB_TOKEN; only Docker Hub credentials
# need to be passed through.
secrets:
DOCKERHUB_USERNAME:
required: true
DOCKERHUB_TOKEN:
required: true
permissions:
contents: read
@@ -73,7 +82,7 @@ jobs:
steps:
# Only the workflow_call path requires a non-empty `inputs.tag` — callers
# (e.g. release-candidate.yml) must pass the RC tag explicitly. On direct
# (publish.yml in RC mode) must pass the RC tag explicitly. On direct
# tag pushes the tag comes from `github.ref`, so `inputs.tag` is always
# empty and validating it here would break every real release (#1064).
# The downstream "Verify tag matches gitnexus/package.json version" step
+830 -34
View File
@@ -1,62 +1,421 @@
name: Publish to npm
name: Publish
# ─────────────────────────────────────────────────────────────────────────────
# Sole publisher for the `gitnexus` npm package, GitHub Releases, and Docker
# images. Replaces the former two-workflow design — see issue #1609 for the
# double-publish race this unification closes.
#
# Two release modes, both routed through this file:
# • Release candidate (rc) — triggered by push to `main` or workflow_dispatch.
# The RC path computes the next rc version, applies it in-CI, pushes a
# detached release commit with v<X.Y.Z>-rc.<N> + rc/<SHA> marker
# atomically, then publishes to npm with --tag rc and creates a GitHub
# prerelease. RC-only docker.yml invocation follows.
# • Stable — triggered by push of a v<X.Y.Z> tag (no -rc.*
# suffix). Verifies package.json matches the tag, publishes to npm with
# --tag latest, creates a stable GitHub Release. No docker (RC-only).
#
# ⚠️ SELF-TRIGGER INVARIANT — DO NOT WEAKEN ⚠️
# The `tags:` filter below uses a negative glob `'!v*-rc.*'` to prevent the
# workflow from re-triggering itself when the RC path pushes its own v-tag.
# Without this exclusion, every RC publish double-fires (the bug fixed by
# #1609). If a NEW prerelease channel is introduced (e.g. `-beta.N`,
# `-alpha.N`, `-next.N`), the negative-glob list MUST be extended in
# lock-step or self-trigger returns. The same invariant applies to the
# `Classify` step further below — its accepted-tag regex must align with
# the trigger filter's exclusion list.
# ─────────────────────────────────────────────────────────────────────────────
on:
push:
branches: [main]
paths-ignore:
- '**.md'
- 'docs/**'
- 'LICENSE'
tags:
# Negative-globbed exclusion of RC tags this workflow itself produces
# (see the SELF-TRIGGER INVARIANT in the header comment).
- 'v*'
# No workflow-level permissions — scoped per job below.
- '!v*-rc.*'
workflow_dispatch:
inputs:
bump:
description: >-
Cycle policy. 'auto' (default) continues the active rc cycle on
this branch if there is one, otherwise bumps patch from latest.
Choose 'patch' / 'minor' / 'major' to explicitly start or reset
an rc cycle.
required: false
default: 'auto'
type: choice
options:
- auto
- patch
- minor
- major
force:
description: 'Publish even when HEAD already has an rc marker'
required: false
default: 'false'
type: choice
options:
- 'false'
- 'true'
# Workflow-level deny-all; each job declares the minimum it needs.
permissions: {}
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
# Tag refs are unique per release, so distinct tags run in parallel. Re-pushes of the
# same tag serialize. cancel-in-progress: false — never cancel a publish mid-flight.
# Distinct refs (refs/heads/main, refs/tags/v*) run in parallel. The
# release-PR-skip in rc-guard is the load-bearing invariant that prevents
# an RC main-push and a stable tag-push colliding on the same release commit.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
# ── Phase 1: classify the triggering event into a release mode ─────────────
route:
name: Classify release event
runs-on: ubuntu-latest
timeout-minutes: 2
permissions:
contents: read
outputs:
mode: ${{ steps.classify.outputs.mode }}
head_sha: ${{ steps.classify.outputs.head_sha }}
bump_input: ${{ inputs.bump }}
force_input: ${{ inputs.force }}
steps:
- name: Classify
id: classify
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
GH_REF: ${{ github.ref }}
GH_REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
HEAD_SHA="${GITHUB_SHA}"
echo "head_sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
# Sanitize before logging (annotation-injection defense in depth).
REF_SAFE="${GH_REF//::/__}"
REF_NAME_SAFE="${GH_REF_NAME//::/__}"
echo "event=${EVENT_NAME} ref=${REF_SAFE} ref_name=${REF_NAME_SAFE}"
MODE=""
case "${EVENT_NAME}" in
workflow_dispatch)
# Manual dispatch is only valid on main — that's the only ref
# where a real publish makes sense.
if [ "${GH_REF}" = "refs/heads/main" ]; then
MODE="rc"
else
echo "::error::workflow_dispatch is only permitted on refs/heads/main (got ${REF_SAFE})."
exit 1
fi
;;
push)
case "${GH_REF}" in
refs/heads/main)
MODE="rc"
;;
refs/tags/v*)
# The trigger filter already excluded v*-rc.* tags. Anything
# reaching here is either a stable semver or a malformed v*.
TAG="${GH_REF#refs/tags/}"
if [[ "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
MODE="stable"
else
echo "::error::malformed v* tag rejected: ${REF_NAME_SAFE}"
echo "::error::stable tags must match ^v[0-9]+\\.[0-9]+\\.[0-9]+\$"
exit 1
fi
;;
*)
echo "::error::unexpected push ref ${REF_SAFE} reached publish workflow."
exit 1
;;
esac
;;
*)
echo "::error::unsupported event ${EVENT_NAME}."
exit 1
;;
esac
echo "mode=${MODE}" >> "$GITHUB_OUTPUT"
echo "Classified as mode=${MODE}"
# ── Phase 2 (RC only): dedup marker + release-PR skip ──────────────────────
rc-guard:
name: RC guard (marker + release-PR skip)
needs: route
if: needs.route.outputs.mode == 'rc'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
pull-requests: read
outputs:
should_run: ${{ steps.decide.outputs.should_run }}
head_sha: ${{ steps.decide.outputs.head_sha }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
fetch-tags: true
# rc-guard reads only — no git pushes from this job. Skip the
# default extraheader credential persistence (artipacked audit).
persist-credentials: false
- name: Decide
id: decide
shell: bash
env:
FORCE: ${{ inputs.force }}
BUMP_INPUT: ${{ inputs.bump }}
EVENT_NAME: ${{ github.event_name }}
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
HEAD_SHA=$(git rev-parse HEAD)
echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT"
if [ "$FORCE" = "true" ]; then
echo "Force flag set — running regardless of marker tag."
echo "should_run=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Explicit cycle reset on dispatch bypasses dedup.
if [ "$EVENT_NAME" = "workflow_dispatch" ] \
&& [ -n "${BUMP_INPUT:-}" ] \
&& [ "${BUMP_INPUT:-auto}" != "auto" ]; then
echo "Explicit bump=$BUMP_INPUT — bypassing marker dedup."
echo "should_run=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# ── Skip when the merge commit corresponds to a release ───────────
# This skip is load-bearing: it prevents an RC build firing on the
# release-PR commit from racing the imminent stable-tag push on the
# same SHA. Two complementary checks:
# 1. HEAD subject matches `chore: release vX.Y.Z` (the canonical
# release-PR title). Anchored to require the bare title or the
# squash-merge `(#NNNN)` suffix exactly. Case-insensitive so
# `Chore: Release v1.2.3` (IDE auto-capitalization) still
# matches — prior commit-author conventions left the door open.
# 2. Squash-merged PR carries the `release` label.
# Either match suppresses the rc build — stable releases publish on
# the v-tag instead.
HEAD_SUBJECT="$(git log -1 --pretty=%s HEAD)"
# Sanitize GitHub-Actions annotation prefixes before logging — even
# though %s strips newlines, a crafted subject containing `::error::`
# could forge log annotations.
HEAD_SUBJECT_SAFE="${HEAD_SUBJECT//::/__}"
RELEASE_SUBJECT_RE='^chore:[[:space:]]*release[[:space:]]+v[0-9]+\.[0-9]+\.[0-9]+([[:space:]]+\(#[0-9]+\))?$'
shopt -s nocasematch
if [[ "$HEAD_SUBJECT" =~ $RELEASE_SUBJECT_RE ]]; then
shopt -u nocasematch
echo "HEAD commit subject matches a release commit — skipping rc."
echo " subject (sanitised): $HEAD_SUBJECT_SAFE"
echo "should_run=false" >> "$GITHUB_OUTPUT"
exit 0
fi
shopt -u nocasematch
# Squash-merge commits include `(#NNNN)` at the end of the subject.
if [[ "$HEAD_SUBJECT" =~ \(#([0-9]+)\)[[:space:]]*$ ]]; then
PR_NUM="${BASH_REMATCH[1]}"
echo "Detected squash-merge of PR #$PR_NUM — checking labels."
if LABELS_JSON="$(gh pr view "$PR_NUM" --repo "$REPO" --json labels 2>/dev/null)"; then
if printf '%s' "$LABELS_JSON" | jq -e '.labels[] | select(.name == "release")' >/dev/null; then
echo "PR #$PR_NUM has the 'release' label — skipping rc."
echo "should_run=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "PR #$PR_NUM has no 'release' label — proceeding."
else
# Lookup failure is not fatal — fall through to dedup check.
echo "::warning::Could not read labels for PR #${PR_NUM} — falling through."
fi
fi
# Dedup: is there already an rc/<HEAD_SHA> marker pointing at HEAD?
MARKER="rc/${HEAD_SHA}"
if git rev-parse "refs/tags/$MARKER" >/dev/null 2>&1; then
echo "HEAD already has marker $MARKER — skipping."
echo "should_run=false" >> "$GITHUB_OUTPUT"
else
echo "No marker on HEAD — proceeding."
echo "should_run=true" >> "$GITHUB_OUTPUT"
fi
# ── Phase 3: reusable CI gate ──────────────────────────────────────────────
# Runs for both rc (when guard says go) and stable. No `secrets:` passed —
# ci.yml and its entire reusable-workflow chain (ci-quality, ci-tests,
# ci-e2e, ci-scope-parity, ci-report) reference zero `secrets.*` values;
# passing any would be unused surface. GITHUB_TOKEN is implicit.
ci:
needs: [route, rc-guard]
if: ${{ always() && (needs.route.outputs.mode == 'stable' || needs.rc-guard.outputs.should_run == 'true') }}
uses: ./.github/workflows/ci.yml
permissions:
contents: read
actions: read
# No pull-requests:write — `ci.yml`'s save-pr-meta job is gated on
# `github.event_name == 'pull_request'`, so it never runs during a
# tag-triggered publish. Least-privilege for release-critical paths.
# ── Phase 4: publish to npm + push refs (RC path) ──────────────────────────
# INVARIANT: `timeout-minutes` MUST stay below the App-token TTL (~60 min
# for actions/create-github-app-token installation tokens). The atomic
# tag-push step relies on the token minted at job start; if the job ever
# runs longer than the TTL, the push fails with an opaque 401. If you
# need to raise the timeout, re-mint the token immediately before the
# `Create and push rc tags` step instead.
publish:
needs: ci
name: Publish to npm
needs: [route, rc-guard, ci]
if: ${{ always() && needs.ci.result == 'success' && (needs.route.outputs.mode == 'stable' || needs.rc-guard.outputs.should_run == 'true') }}
runs-on: ubuntu-latest
timeout-minutes: 15
timeout-minutes: 20
permissions:
# contents: write — RC path needs it for `git push --atomic` (v-tag +
# marker). Stable path runs in the same job and inherits the grant; it
# never invokes `git push`, so the elevated scope is unused there.
# id-token: write — npm provenance attestation.
contents: write
id-token: write
outputs:
# Two distinct step IDs feed this output; exactly one fires per run.
vtag: ${{ steps.rc-tags.outputs.vtag || steps.stable-vtag.outputs.vtag }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# ── Mint short-lived GitHub App token (RC only) ──────────────────────
# Industry direction (2025-2026): GitHub Apps with
# `actions/create-github-app-token` over long-lived PATs for
# workflow-touching tag pushes. Same fine-grained permission surface,
# ~1h expiry, not tied to a user seat, organizationally auditable.
# Replaces a prior fine-grained PAT.
#
# Required secrets (set in repo Settings → Secrets and variables → Actions):
# secrets.RELEASE_APP_ID — the App's numeric ID
# secrets.RELEASE_APP_PRIVATE_KEY — the App's PEM private key
# (The App ID is technically not sensitive — it's visible on the App's
# settings page — but storing it as a secret is harmless and avoids
# mixing storage classes for the same App.)
# The App must be installed on this repository with:
# - Contents: write (push the v-tag and rc marker)
# - Workflows: write (because the v-tag's tree may touch
# .github/workflows/**, which the default
# GITHUB_TOKEN cannot author)
# - Metadata: read (required for the `gh api /users/<slug>[bot]`
# bot-identity lookup in the tag-push step)
- name: Mint GitHub App token (RC)
if: needs.route.outputs.mode == 'rc'
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
# `client-id` is the renamed input that supersedes the deprecated
# `app-id` in v3.x. The action accepts the App's numeric ID or
# its Client ID under this name. We pass the numeric App ID,
# which the action resolves correctly.
client-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
# ── Separate checkout steps per mode ─────────────────────────────────
# Conditional `token:` expressions are footguns: empty string passed to
# actions/checkout fails opaquely, and `|| github.token` silently
# degrades a missing token to GITHUB_TOKEN, masking auth failures until
# the eventual `git push`. Two distinct steps make the auth contract
# explicit and fail loudly at checkout when the App token mint failed
# on the RC path.
- name: Checkout (RC)
if: needs.route.outputs.mode == 'rc'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
fetch-tags: true
# Short-lived GitHub App installation token. Required because the
# v-tag push lands at a SHA whose tree may touch
# `.github/workflows/**`, which the default GITHUB_TOKEN cannot
# author.
token: ${{ steps.app-token.outputs.token }}
# Do not persist the token in .git/config (artipacked audit). The
# RC tag push uses an inline `http.extraheader` at push time only;
# the credential never lands on disk. See the
# `Create and push rc tags` step below.
persist-credentials: false
- name: Checkout (stable)
if: needs.route.outputs.mode == 'stable'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# No `token:` — actions/checkout uses GITHUB_TOKEN by default. Stable
# path performs no git pushes; the default scope is sufficient.
with:
# No git pushes from the stable path either. Skip credential
# persistence (artipacked audit).
persist-credentials: false
- name: Working-tree sanity
# Defense in depth (mirrors the vtag integrity gate, but on the input side):
# if a route-mode regression skipped both checkout `if:` gates, all
# downstream steps would run on a bare runner and produce confusing
# ENOENT errors. Fail loudly and early here instead.
shell: bash
run: |
if [ ! -f gitnexus/package.json ]; then
echo "::error::no working tree at gitnexus/package.json — route classification likely failed silently."
exit 1
fi
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
registry-url: https://registry.npmjs.org
# Hermetic install for the published artifact — no cache carry-over
# from non-tag contexts. setup-node v5+ caches by default when a
# packageManager field is present in package.json, so the explicit
# opt-out is required to clear the zizmor cache-poisoning audit.
# ~30s slower per release; runs rarely.
# Node 24 ships with npm >= 11.5.x, which is the minimum that
# supports npm Trusted Publishing OIDC. Node 22 ships with npm
# 10.9.x (no OIDC) and `npm install -g npm@latest` to self-upgrade
# is fragile — it can crash the in-flight reify with
# `MODULE_NOT_FOUND` on `promise-retry` etc. Bumping the Node
# version is the clean fix; the package's `engines` field is
# `>=22.0.0` so consumer-side compatibility is unaffected (this
# Node version is only used during publish, not by package users).
node-version: 24
# `registry-url:` is intentionally OMITTED. Under npm Trusted
# Publishing, OIDC only engages when no credential is configured.
# Setting `registry-url:` would make setup-node write
# `//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}` into the
# runner's .npmrc AND export NODE_AUTH_TOKEN from its `token:`
# input (default github.token). `npm publish` would then attempt
# GITHUB_TOKEN as the npm token, get rejected with 404, and OIDC
# would never be tried. See actions/setup-node#1440 and the GitHub
# Community discussion #176761 for the upstream bug and consensus
# workaround.
#
# Hermetic install for published artifacts — opt out of the v5+
# default packageManager-based caching (clears the zizmor
# cache-poisoning audit). ~30s slower per release; runs rarely.
package-manager-cache: false
- name: Build gitnexus-shared
run: npm install && npm run build
working-directory: gitnexus-shared
- run: npm ci
- name: Install gitnexus dependencies
run: npm ci
working-directory: gitnexus
- name: Verify version consistency
# ── Stable-only: verify the tag and package.json agree ───────────────
- name: Verify version consistency (stable)
if: needs.route.outputs.mode == 'stable'
shell: bash
working-directory: gitnexus
run: |
set -euo pipefail
TAG_VERSION="${GITHUB_REF#refs/tags/v}"
if ! [[ "$TAG_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
echo "::error::Tag does not follow semver: v$TAG_VERSION"
# Stable mode REJECTS prerelease suffixes — those are filtered at
# trigger by the negative-glob filter, but defend at the bash layer too.
if ! [[ "$TAG_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Stable tag must be ^v[0-9]+.[0-9]+.[0-9]+$ — got v$TAG_VERSION"
exit 1
fi
PKG_VERSION=$(node -p "require('./package.json').version")
@@ -65,24 +424,376 @@ jobs:
exit 1
fi
echo "Version verified: $PKG_VERSION"
working-directory: gitnexus
- name: Build
# ── RC-only: compute the next rc version against the live registry ──
- name: Resolve rc version (rc)
id: rc-version
if: needs.route.outputs.mode == 'rc'
shell: bash
working-directory: gitnexus
env:
BUMP_INPUT: ${{ inputs.bump }}
EVENT_NAME: ${{ github.event_name }}
PKG_NAME: gitnexus
run: |
set -euo pipefail
# 1. Current published `latest` — the floor for any new rc base.
# Only E404 ("never published") falls back to package.json; any
# other error (network, auth, malformed response) fails fast
# (retry-loud policy: never silently substitute on transient errors).
NPM_STDERR_LATEST="$(mktemp)"
if CURRENT_LATEST="$(npm view "$PKG_NAME" version 2>"$NPM_STDERR_LATEST")"; then
:
else
if grep -qiE 'E404|not found' "$NPM_STDERR_LATEST"; then
CURRENT_LATEST="$(node -p "require('./package.json').version")"
echo "Package not on registry (E404) — seeding from package.json: $CURRENT_LATEST"
else
echo "::error::npm registry unreachable for 'view version':" >&2
cat "$NPM_STDERR_LATEST" >&2
rm -f "$NPM_STDERR_LATEST"
exit 1
fi
fi
rm -f "$NPM_STDERR_LATEST"
CURRENT_LATEST_CLEAN="${CURRENT_LATEST%%-*}"
# 2. Full version list — needed for the counter and active-cycle
# inference. Same E404-only fallback.
NPM_STDERR_VERSIONS="$(mktemp)"
if VERSIONS_JSON="$(npm view "$PKG_NAME" versions --json 2>"$NPM_STDERR_VERSIONS")"; then
:
else
if grep -qiE 'E404|not found' "$NPM_STDERR_VERSIONS"; then
VERSIONS_JSON='[]'
echo "No published versions for $PKG_NAME yet (E404)."
else
echo "::error::npm registry unreachable for 'view versions':" >&2
cat "$NPM_STDERR_VERSIONS" >&2
rm -f "$NPM_STDERR_VERSIONS"
exit 1
fi
fi
rm -f "$NPM_STDERR_VERSIONS"
# 3. Base selection.
# - workflow_dispatch + bump != auto → explicit cycle reset.
# - Otherwise (push, or dispatch with bump=auto) → continue the
# highest active rc base > latest if any; else patch from latest.
# Curated wrapper around `npx semver` — bare npx errors are noisy
# and don't distinguish registry-unreachable from invalid-bump-spec.
semver_bump() {
local kind="$1" current="$2" stderr_file out
stderr_file="$(mktemp)"
if out="$(npx --yes -p semver@7 semver -i "$kind" "$current" 2>"$stderr_file")"; then
rm -f "$stderr_file"
printf '%s' "$out"
return 0
fi
echo "::error::semver bump failed (kind=${kind}, current=${current}):" >&2
cat "$stderr_file" >&2
rm -f "$stderr_file"
return 1
}
if [ "$EVENT_NAME" = "workflow_dispatch" ] \
&& [ -n "${BUMP_INPUT:-}" ] \
&& [ "${BUMP_INPUT:-auto}" != "auto" ]; then
BASE="$(semver_bump "$BUMP_INPUT" "$CURRENT_LATEST_CLEAN")"
echo "Explicit bump=$BUMP_INPUT → BASE=$BASE"
else
cat > /tmp/active_base.mjs <<'NODESCRIPT'
const latest = process.env.LATEST;
let v;
try { v = JSON.parse(process.env.VERSIONS_JSON); } catch { v = []; }
if (!Array.isArray(v)) v = [v];
const parse = s => s.split(".").map(n => parseInt(n, 10));
const gt = (a, b) => {
const [A, B] = [parse(a), parse(b)];
for (let i = 0; i < 3; i++) if (A[i] !== B[i]) return A[i] > B[i];
return false;
};
const bases = new Set();
for (const s of v) {
const m = /^(\d+\.\d+\.\d+)-rc\.\d+$/.exec(s);
if (m && gt(m[1], latest)) bases.add(m[1]);
}
if (!bases.size) { process.stdout.write(""); process.exit(0); }
const sorted = [...bases].sort((a, b) => gt(a, b) ? 1 : -1);
process.stdout.write(sorted[sorted.length - 1]);
NODESCRIPT
ACTIVE_BASE="$(LATEST="$CURRENT_LATEST_CLEAN" VERSIONS_JSON="$VERSIONS_JSON" node /tmp/active_base.mjs)"
if [ -n "$ACTIVE_BASE" ]; then
BASE="$ACTIVE_BASE"
echo "Continuing active rc cycle → BASE=$BASE"
else
BASE="$(semver_bump patch "$CURRENT_LATEST_CLEAN")"
echo "No active rc cycle → patch bump from latest → BASE=$BASE"
fi
fi
# 4. Counter: 1 + max existing N for `${BASE}-rc.*`, else 1.
cat > /tmp/next_rc.mjs <<'NODESCRIPT'
const base = process.env.BASE;
const prefix = base + "-rc.";
let v;
try { v = JSON.parse(process.env.VERSIONS_JSON); } catch { v = []; }
if (!Array.isArray(v)) v = [v];
const ns = v
.filter(s => typeof s === "string" && s.startsWith(prefix))
.map(s => parseInt(s.slice(prefix.length), 10))
.filter(n => Number.isInteger(n) && n >= 0);
process.stdout.write(String(ns.length ? Math.max(...ns) + 1 : 1));
NODESCRIPT
NEXT_N="$(BASE="$BASE" VERSIONS_JSON="$VERSIONS_JSON" node /tmp/next_rc.mjs)"
RC_VERSION="${BASE}-rc.${NEXT_N}"
echo "Computed rc: $RC_VERSION"
# 5. Defensive: if the exact version already exists on the registry
# (race with another run), abort before re-publishing.
NPM_STDERR_EXISTS="$(mktemp)"
if npm view "$PKG_NAME@$RC_VERSION" version 2>"$NPM_STDERR_EXISTS" >/dev/null; then
rm -f "$NPM_STDERR_EXISTS"
echo "::error::Version $RC_VERSION already exists on npm — aborting."
exit 1
else
if grep -qiE 'E404|not found' "$NPM_STDERR_EXISTS"; then
rm -f "$NPM_STDERR_EXISTS"
# Version doesn't exist — safe to proceed.
else
echo "::error::npm registry unreachable for existence check:" >&2
cat "$NPM_STDERR_EXISTS" >&2
rm -f "$NPM_STDERR_EXISTS"
exit 1
fi
fi
{
echo "base=$BASE"
echo "rc_n=$NEXT_N"
echo "rc_version=$RC_VERSION"
} >> "$GITHUB_OUTPUT"
- name: Apply rc version in-CI
if: needs.route.outputs.mode == 'rc'
shell: bash
working-directory: gitnexus
run: |
set -euo pipefail
npm version "${{ steps.rc-version.outputs.rc_version }}" \
--no-git-tag-version --allow-same-version
- name: Build gitnexus
run: npm run build
working-directory: gitnexus
- name: Dry-run publish
run: npm publish --dry-run
working-directory: gitnexus
- name: Publish to npm
run: npm publish --provenance --access public
# Cheap verification that the tarball assembles before the real publish.
shell: bash
working-directory: gitnexus
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_TAG: ${{ needs.route.outputs.mode == 'rc' && 'rc' || 'latest' }}
run: npm publish --dry-run --tag "$NPM_TAG"
- name: Extract release notes from CHANGELOG
# ── Acquire the "rc lock" BEFORE publishing (idempotency anchor) ─────
# We create two refs and push atomically:
# v<RC_VERSION> → annotated tag on a detached release commit whose
# tree contains the rewritten package.json, so the
# tag's source matches the npm tarball.
# rc/<HEAD_SHA> → lightweight tag on HEAD; the guard's dedup key.
# Push fails → nothing published. Push succeeds, npm fails → marker
# blocks retries until manual cleanup (see Rollback Runbook in plan).
- name: Create and push rc tags
id: rc-tags
if: needs.route.outputs.mode == 'rc'
shell: bash
working-directory: gitnexus
env:
RC_VERSION: ${{ steps.rc-version.outputs.rc_version }}
HEAD_SHA: ${{ needs.rc-guard.outputs.head_sha }}
# Short-lived GitHub App token. Auth is supplied inline at push
# time via `http.extraheader` (per GitHub's documented
# x-access-token Basic pattern). It is NOT persisted in
# .git/config (artipacked audit) — checkout above ran with
# `persist-credentials: false`.
PUSH_TOKEN: ${{ steps.app-token.outputs.token }}
# App's slug from create-github-app-token (e.g. `gitnexus-release-bot`).
# Used to attribute the release commit to the App identity rather
# than the generic github-actions[bot]. The bot's numeric user-id
# is resolved at runtime via the GitHub API (the action does not
# expose it directly as of v3.2.0).
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
set -euo pipefail
VTAG="v${RC_VERSION}"
MARKER="rc/${HEAD_SHA}"
# Resolve the App's bot user-id and construct the noreply email
# in the GitHub-canonical `<id>+<slug>[bot]@users.noreply.github.com`
# shape. `[bot]` is part of the actual login on GitHub.
#
# The lookup is wrapped in a bounded retry because the first RC
# after App installation may hit propagation delay (404), and
# transient api.github.com 5xx during heavy org activity is a real
# failure class. Without retry, every transient blip aborts the
# entire release after CI has already succeeded.
BOT_LOGIN="${APP_SLUG}[bot]"
BOT_USER_ID=""
api_stderr="$(mktemp)"
for attempt in 1 2 3; do
if BOT_USER_ID="$(gh api "/users/${BOT_LOGIN}" --jq .id 2>"$api_stderr")" \
&& [[ "${BOT_USER_ID}" =~ ^[0-9]+$ ]]; then
break
fi
BOT_USER_ID=""
if [ "$attempt" -lt 3 ]; then
echo "::warning::bot user-id lookup attempt ${attempt} failed; retrying in $((attempt * 5))s"
sleep $((attempt * 5))
fi
done
if ! [[ "${BOT_USER_ID}" =~ ^[0-9]+$ ]]; then
echo "::error::Could not resolve bot user-id for ${BOT_LOGIN} after 3 attempts."
echo "::error::gh api stderr:"
cat "$api_stderr" >&2 || true
echo "::error::Common causes: (a) newly-installed App — user record still propagating to /users/ (wait ~5min, redispatch with force=true); (b) App lacks Metadata: read permission; (c) transient api.github.com 5xx (redispatch)."
rm -f "$api_stderr"
exit 1
fi
rm -f "$api_stderr"
git config user.name "${BOT_LOGIN}"
git config user.email "${BOT_USER_ID}+${BOT_LOGIN}@users.noreply.github.com"
# Detached release commit with the version bump — main stays
# pristine, but the v-tag's tree matches the published package
# exactly (release-integrity).
git add package.json package-lock.json 2>/dev/null || git add package.json
git commit -m "release: ${VTAG}" --allow-empty
RELEASE_SHA="$(git rev-parse HEAD)"
echo "Detached release commit: $RELEASE_SHA"
git tag -a "$VTAG" "$RELEASE_SHA" -m "$VTAG"
git tag "$MARKER" "$HEAD_SHA"
# Inline auth header. The base64-encoded form is masked as well
# as the raw token, because GitHub's secret-masker only masks the
# raw value — any subsequent `set -x` / GIT_TRACE line would
# otherwise expose the encoded credential.
#
# `set +x` wraps the compute+mask pair so that if an operator
# enables ACTIONS_STEP_DEBUG=true for triage (which turns on
# `set -x` globally), the assignment is NOT traced for the one
# line between compute and mask-registration. Without this wrap,
# debug mode would log `+ auth_header='Authorization: Basic <encoded>'`
# exposing a still-valid (~1h) App token.
{ set +x; } 2>/dev/null
auth_header="Authorization: Basic $(printf 'x-access-token:%s' "${PUSH_TOKEN}" | base64 -w0)"
echo "::add-mask::${auth_header}"
# Re-enable tracing only when explicitly requested via step-debug.
if [ "${ACTIONS_STEP_DEBUG:-false}" = "true" ]; then set -x; fi
# Atomic push of both refs. If either would clobber an existing
# remote ref, the push fails and we stop before npm publish.
git -c http.extraheader="${auth_header}" \
push --atomic origin "refs/tags/$VTAG" "refs/tags/$MARKER"
{
echo "vtag=$VTAG"
echo "marker=$MARKER"
echo "release_sha=$RELEASE_SHA"
} >> "$GITHUB_OUTPUT"
- name: Set vtag (stable)
id: stable-vtag
if: needs.route.outputs.mode == 'stable'
shell: bash
# github.ref_name flows in via env to avoid templating into the
# shell source (template-injection audit). Even though refs are
# constrained by git naming rules, the env-passthrough pattern
# makes injection structurally impossible.
env:
REF_NAME: ${{ github.ref_name }}
run: |
echo "vtag=${REF_NAME}" >> "$GITHUB_OUTPUT"
# ── vtag integrity gate ──────────────────────────────────────────────
# Fail closed before any artifact-producing step (npm publish, Release,
# Docker) runs against an empty or mode-mismatched vtag. Prevents the
# silent "Release named main" / "Docker tagged from ref fallback"
# failure modes that the previous draft was vulnerable to.
- name: vtag integrity gate
id: vtag-gate
shell: bash
env:
MODE: ${{ needs.route.outputs.mode }}
VTAG: ${{ steps.rc-tags.outputs.vtag || steps.stable-vtag.outputs.vtag }}
run: |
set -euo pipefail
if [ -z "$VTAG" ]; then
echo "::error::vtag is empty — refusing to create GitHub Release or trigger Docker."
exit 1
fi
case "$MODE" in
rc)
if ! [[ "$VTAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
echo "::error::vtag '${VTAG}' does not match rc shape ^v[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+$"
exit 1
fi
;;
stable)
if ! [[ "$VTAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::vtag '${VTAG}' does not match stable shape ^v[0-9]+.[0-9]+.[0-9]+$"
exit 1
fi
;;
*)
echo "::error::unknown mode '${MODE}' at vtag integrity gate."
exit 1
;;
esac
echo "vtag verified: ${VTAG} (mode=${MODE})"
echo "vtag=${VTAG}" >> "$GITHUB_OUTPUT"
# npm Trusted Publishing (GA'd 2025-07-31). OIDC authentication only
# engages when no npm credential is configured anywhere — the absence
# is the signal. Two upstream behaviors had to be neutralized for
# this to work:
#
# 1. setup-node's `registry-url:` is omitted (see the setup-node
# step above). With it, setup-node writes
# `//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}` into
# .npmrc and exports NODE_AUTH_TOKEN from `token:` (defaulting
# to github.token). npm publish then sends GITHUB_TOKEN as the
# bearer credential and the registry returns 404. OIDC is never
# tried because npm thinks it already has a credential.
# 2. The runner's bundled npm (10.9.x on Node 22) has no OIDC
# support; the upgrade step above pins it to >= 11.5.1.
#
# Provenance is auto-attached by the registry on trusted-publisher
# publishes — no --provenance flag needed.
#
# Prerequisite: register the package as a trusted publisher at
# https://www.npmjs.com/package/gitnexus/access (Publishing access →
# Trusted Publishers → GitHub Actions):
# Owner: abhigyanpatwari
# Repository: GitNexus
# Workflow: publish.yml
# Environment: (none)
- name: Publish to npm
shell: bash
working-directory: gitnexus
env:
NPM_TAG: ${{ needs.route.outputs.mode == 'rc' && 'rc' || 'latest' }}
run: npm publish --access public --tag "$NPM_TAG"
# ── Stable-only: pull CHANGELOG body if present ──────────────────────
- name: Extract release notes from CHANGELOG (stable)
id: changelog
if: needs.route.outputs.mode == 'stable'
shell: bash
run: |
VERSION="${GITHUB_REF#refs/tags/v}"
@@ -98,5 +809,90 @@ jobs:
- name: Create GitHub Release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v2
with:
body_path: ${{ steps.changelog.outputs.fallback == 'false' && '/tmp/release-notes.md' || '' }}
generate_release_notes: ${{ steps.changelog.outputs.fallback == 'true' }}
tag_name: ${{ steps.vtag-gate.outputs.vtag }}
name: >-
${{ needs.route.outputs.mode == 'rc'
&& format('Release Candidate {0}', steps.vtag-gate.outputs.vtag)
|| steps.vtag-gate.outputs.vtag }}
prerelease: ${{ needs.route.outputs.mode == 'rc' }}
make_latest: ${{ needs.route.outputs.mode == 'stable' && 'true' || 'false' }}
# Stable: prefer CHANGELOG body, fall back to auto-generated.
# RC: always auto-generated + the prerelease body block below.
body_path: >-
${{ needs.route.outputs.mode == 'stable' && steps.changelog.outputs.fallback == 'false'
&& '/tmp/release-notes.md' || '' }}
generate_release_notes: >-
${{ needs.route.outputs.mode == 'rc'
|| steps.changelog.outputs.fallback == 'true' }}
body: >-
${{ needs.route.outputs.mode == 'rc' && format(
'Automated release candidate build from `main`.{0}{0}**npm:** `npm install gitnexus@rc`{0}**Version:** `{1}`{0}**Target base:** `{2}` (rc #{3}){0}**Source commit (main):** {4}{0}**Release commit (versioned tree):** {5}{0}{0}Release candidates are pre-stable builds intended for early testing. Stable releases remain on the `latest` dist-tag.',
'\n',
steps.rc-version.outputs.rc_version,
steps.rc-version.outputs.base,
steps.rc-version.outputs.rc_n,
needs.rc-guard.outputs.head_sha,
steps.rc-tags.outputs.release_sha
) || '' }}
# ── RC partial-failure cleanup ───────────────────────────────────────
# If anything after the atomic tag-push step failed (npm publish
# blew up, GitHub Release call timed out, etc.), the v-tag and
# rc/<SHA> marker are already on origin. External consumers
# (Renovate, Dependabot, Releases RSS) can ingest a phantom tag for
# a version that was never published to npm. This step deletes them
# automatically so the operator's recovery is just "redispatch with
# force=true on the next commit", not a manual ref cleanup.
#
# Scoped strictly to RC + real (non-dry-run) + the rc-tags step
# actually produced a vtag (otherwise nothing to clean up). The
# App token is still valid (~1h TTL, job timeout 20min).
- name: Cleanup pushed tags on partial failure
if: ${{ failure() && needs.route.outputs.mode == 'rc' && steps.rc-tags.outputs.vtag != '' }}
shell: bash
working-directory: gitnexus
env:
VTAG: ${{ steps.rc-tags.outputs.vtag }}
MARKER: ${{ steps.rc-tags.outputs.marker }}
PUSH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
set -uo pipefail
echo "::warning::Publish step failed after tag push. Cleaning up remote refs to prevent phantom-version ingestion by downstream consumers."
{ set +x; } 2>/dev/null
auth_header="Authorization: Basic $(printf 'x-access-token:%s' "${PUSH_TOKEN}" | base64 -w0)"
echo "::add-mask::${auth_header}"
if [ "${ACTIONS_STEP_DEBUG:-false}" = "true" ]; then set -x; fi
# Delete v-tag and marker. Each delete is best-effort — if one
# is already absent (atomic push partially rejected, or earlier
# cleanup ran), the other still gets attempted.
for ref in "refs/tags/${VTAG}" "refs/tags/${MARKER}"; do
if git -c http.extraheader="${auth_header}" push origin --delete "${ref}" 2>&1; then
echo "deleted origin ${ref}"
else
echo "::warning::could not delete origin ${ref} — may already be absent or protected. Manual cleanup may be required."
fi
done
echo "::notice::Cleanup complete. To retry the release, redispatch the workflow with force=true on the same SHA, or push a new commit to main."
# ── Phase 5 (RC only): Docker images ───────────────────────────────────────
# R6: Docker remains RC-only. Stable Docker builds are explicitly deferred.
# Secrets are passed explicitly (not via `secrets: inherit`) so the
# callee's secret surface is auditable from the caller's source.
docker:
name: Build & Push RC Docker images
needs: [route, publish]
if: ${{ needs.route.outputs.mode == 'rc' && needs.publish.outputs.vtag != '' }}
uses: ./.github/workflows/docker.yml
secrets:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
permissions:
contents: read
packages: write
id-token: write
attestations: write
with:
tag: ${{ needs.publish.outputs.vtag }}
-459
View File
@@ -1,459 +0,0 @@
name: Release Candidate
on:
# Publish a release-candidate build whenever a merge/commit lands on main.
# Docs/README-only changes are filtered out so prose updates don't
# cut a release.
push:
branches: [main]
paths-ignore:
- '**.md'
- 'docs/**'
- 'LICENSE'
workflow_dispatch:
inputs:
bump:
description: >-
Cycle policy. 'auto' (default) continues the active rc cycle on
this branch if there is one, otherwise bumps patch from latest.
Choose 'patch' / 'minor' / 'major' to explicitly start or reset
an rc cycle.
required: false
default: 'auto'
type: choice
options:
- auto
- patch
- minor
- major
force:
description: 'Publish even when HEAD already has an rc marker'
required: false
default: 'false'
type: choice
options:
- 'false'
- 'true'
# No workflow-level permissions — scoped per job below.
permissions: {}
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
# Serialize all runs on the same ref (push + workflow_dispatch) to prevent two publishes
# racing on the rc counter. cancel-in-progress: false — the earlier merge publishes first.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
# ── Skip when HEAD already has an rc marker (retry / duplicate dispatch) ──
# The marker is a lightweight tag `rc/<HEAD_SHA>` pushed *before* `npm
# publish`, so a failed publish leaves the marker in place and the guard
# refuses to re-publish. Recovery path after a partial failure:
# git push --delete origin rc/<HEAD_SHA> v<RC_VERSION>
# then redispatch with force=true.
guard:
name: Check if release candidate should run
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
pull-requests: read # read PR labels on the merge commit
outputs:
should_run: ${{ steps.decide.outputs.should_run }}
head_sha: ${{ steps.decide.outputs.head_sha }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
fetch-tags: true
- name: Decide
id: decide
shell: bash
env:
FORCE: ${{ inputs.force }}
BUMP_INPUT: ${{ inputs.bump }}
EVENT_NAME: ${{ github.event_name }}
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
HEAD_SHA=$(git rev-parse HEAD)
echo "head_sha=$HEAD_SHA" >> "$GITHUB_OUTPUT"
if [ "$FORCE" = "true" ]; then
echo "Force flag set — running regardless of marker tag."
echo "should_run=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# An explicit cycle reset on dispatch (bump != auto) also bypasses
# the dedup guard — the maintainer is deliberately asking for a
# new rc from the same commit.
if [ "$EVENT_NAME" = "workflow_dispatch" ] \
&& [ -n "${BUMP_INPUT:-}" ] \
&& [ "${BUMP_INPUT:-auto}" != "auto" ]; then
echo "Explicit bump=$BUMP_INPUT — bypassing marker dedup."
echo "should_run=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# ── Skip when the merge commit corresponds to a release ─────────
# Two complementary checks (belt-and-suspenders):
# 1. The HEAD commit subject matches `chore: release vX.Y.Z`
# (the canonical release-PR title in this repo). Anchored
# at both ends to require the bare title or the squash-merge
# `(#NNNN)` suffix exactly — rejects noisy variants like
# `chore: release v1.0.0 (something unrelated)`.
# 2. The squash-merged PR carries the `release` label.
# Either match suppresses the rc build — stable releases publish
# via publish.yml on the v-tag, so the rc cycle should pause for
# them rather than racing the npm publish.
HEAD_SUBJECT="$(git log -1 --pretty=%s HEAD)"
# Sanitise GitHub-Actions annotation prefixes before logging the
# raw subject — defence-in-depth so a hypothetical commit subject
# containing `::error::` or `::set-output::` cannot forge log
# annotations even though %s strips newlines.
HEAD_SUBJECT_SAFE="${HEAD_SUBJECT//::/__}"
RELEASE_SUBJECT_RE='^chore:[[:space:]]*release[[:space:]]+v[0-9]+\.[0-9]+\.[0-9]+([[:space:]]+\(#[0-9]+\))?$'
if [[ "$HEAD_SUBJECT" =~ $RELEASE_SUBJECT_RE ]]; then
echo "HEAD commit subject matches a release commit — skipping rc."
echo " subject (sanitised): $HEAD_SUBJECT_SAFE"
echo "should_run=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Squash-merge commits include `(#NNNN)` at the end of the subject.
if [[ "$HEAD_SUBJECT" =~ \(#([0-9]+)\)[[:space:]]*$ ]]; then
PR_NUM="${BASH_REMATCH[1]}"
echo "Detected squash-merge of PR #$PR_NUM — checking labels."
if LABELS_JSON="$(gh pr view "$PR_NUM" --repo "$REPO" --json labels 2>/dev/null)"; then
if printf '%s' "$LABELS_JSON" | jq -e '.labels[] | select(.name == "release")' >/dev/null; then
echo "PR #$PR_NUM has the 'release' label — skipping rc."
echo "should_run=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "PR #$PR_NUM has no 'release' label — proceeding."
else
# Lookup failure is not fatal — fall through to the dedup check
# so a transient GH API hiccup doesn't silently suppress rc builds.
echo "::warning::Could not read labels for PR #${PR_NUM} — falling through."
fi
fi
# Dedup: is there already an rc/<HEAD_SHA> marker pointing at HEAD?
MARKER="rc/${HEAD_SHA}"
if git rev-parse "refs/tags/$MARKER" >/dev/null 2>&1; then
echo "HEAD already has marker $MARKER — skipping."
echo "should_run=false" >> "$GITHUB_OUTPUT"
else
echo "No marker on HEAD — proceeding."
echo "should_run=true" >> "$GITHUB_OUTPUT"
fi
# ── Reuse the stable CI workflow ─────────────────────────────────────
ci:
needs: guard
if: needs.guard.outputs.should_run == 'true'
uses: ./.github/workflows/ci.yml
permissions:
contents: read
secrets: inherit
# ── Publish the rc build to npm + create GitHub prerelease ───────────
publish:
name: Publish release candidate to npm
needs: [guard, ci]
if: needs.guard.outputs.should_run == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
# The default GITHUB_TOKEN cannot be granted `workflows: write`, so
# tag pushes that reach a commit which modified `.github/workflows/**`
# are rejected with: "refusing to allow a GitHub App to create or
# update workflow ... without `workflows` permission". We pass a
# fine-grained PAT (RELEASE_PUSH_TOKEN, scoped to this repo with
# Contents: write + Workflows: write) to `actions/checkout` so that
# the subsequent `git push --atomic` of the v-tag and rc marker
# carries the PAT's identity. Job-level GITHUB_TOKEN keeps its
# scoped permissions for everything else (npm provenance, etc.).
contents: write # push rc tag + marker (via PAT)
id-token: write # npm provenance
outputs:
vtag: ${{ steps.reltag.outputs.vtag }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
fetch-tags: true
# Use the PAT so `origin` is preauthed for `git push`. Without
# this the default GITHUB_TOKEN is wired into the remote, and a
# workflows-touching tag push is rejected — see the permissions
# block above.
token: ${{ secrets.RELEASE_PUSH_TOKEN }}
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 22
registry-url: https://registry.npmjs.org
# Hermetic install — release-candidate produces shipped artifacts.
# setup-node v5+ caches by default when a packageManager field is
# present in package.json; explicit opt-out is required to clear
# the zizmor cache-poisoning audit. See cache-poisoning audit.
package-manager-cache: false
- name: Build gitnexus-shared
run: npm install && npm run build
working-directory: gitnexus-shared
- name: Install gitnexus dependencies
run: npm ci
working-directory: gitnexus
- name: Resolve rc version
id: version
shell: bash
working-directory: gitnexus
env:
BUMP_INPUT: ${{ inputs.bump }}
EVENT_NAME: ${{ github.event_name }}
PKG_NAME: gitnexus
run: |
set -euo pipefail
# 1. Current published `latest` — the floor for any new rc base.
# Only E404 ("never published") falls back to package.json; any
# other error (network, auth, malformed response) fails fast.
NPM_STDERR_LATEST="$(mktemp)"
if CURRENT_LATEST="$(npm view "$PKG_NAME" version 2>"$NPM_STDERR_LATEST")"; then
:
else
if grep -q 'E404' "$NPM_STDERR_LATEST"; then
CURRENT_LATEST="$(node -p "require('./package.json').version")"
echo "Package not on registry (E404) — seeding from package.json: $CURRENT_LATEST"
else
echo "::error::npm registry unreachable for 'view version':" >&2
cat "$NPM_STDERR_LATEST" >&2
rm -f "$NPM_STDERR_LATEST"
exit 1
fi
fi
rm -f "$NPM_STDERR_LATEST"
CURRENT_LATEST_CLEAN="${CURRENT_LATEST%%-*}"
# 2. Full version list — needed for the counter and for active-cycle
# inference. Same E404-only fallback.
NPM_STDERR_VERSIONS="$(mktemp)"
if VERSIONS_JSON="$(npm view "$PKG_NAME" versions --json 2>"$NPM_STDERR_VERSIONS")"; then
:
else
if grep -q 'E404' "$NPM_STDERR_VERSIONS"; then
VERSIONS_JSON='[]'
echo "No published versions for $PKG_NAME yet (E404)."
else
echo "::error::npm registry unreachable for 'view versions':" >&2
cat "$NPM_STDERR_VERSIONS" >&2
rm -f "$NPM_STDERR_VERSIONS"
exit 1
fi
fi
rm -f "$NPM_STDERR_VERSIONS"
# 3. Base selection.
# - workflow_dispatch + bump ∈ {patch,minor,major} → explicit cycle
# reset from latest.
# - Everything else (push, or dispatch with bump=auto) → continue
# the highest active rc base > latest if one exists; else
# default to patch from latest.
if [ "$EVENT_NAME" = "workflow_dispatch" ] \
&& [ -n "${BUMP_INPUT:-}" ] \
&& [ "${BUMP_INPUT:-auto}" != "auto" ]; then
BASE="$(npx --yes -p semver@7 semver -i "$BUMP_INPUT" "$CURRENT_LATEST_CLEAN")"
echo "Explicit bump=$BUMP_INPUT → BASE=$BASE"
else
cat > /tmp/active_base.mjs <<'NODESCRIPT'
const latest = process.env.LATEST;
let v;
try { v = JSON.parse(process.env.VERSIONS_JSON); } catch { v = []; }
if (!Array.isArray(v)) v = [v];
const parse = s => s.split(".").map(n => parseInt(n, 10));
const gt = (a, b) => {
const [A, B] = [parse(a), parse(b)];
for (let i = 0; i < 3; i++) if (A[i] !== B[i]) return A[i] > B[i];
return false;
};
const bases = new Set();
for (const s of v) {
const m = /^(\d+\.\d+\.\d+)-rc\.\d+$/.exec(s);
if (m && gt(m[1], latest)) bases.add(m[1]);
}
if (!bases.size) { process.stdout.write(""); process.exit(0); }
const sorted = [...bases].sort((a, b) => gt(a, b) ? 1 : -1);
process.stdout.write(sorted[sorted.length - 1]);
NODESCRIPT
ACTIVE_BASE="$(LATEST="$CURRENT_LATEST_CLEAN" VERSIONS_JSON="$VERSIONS_JSON" node /tmp/active_base.mjs)"
if [ -n "$ACTIVE_BASE" ]; then
BASE="$ACTIVE_BASE"
echo "Continuing active rc cycle → BASE=$BASE"
else
BASE="$(npx --yes -p semver@7 semver -i patch "$CURRENT_LATEST_CLEAN")"
echo "No active rc cycle → patch bump from latest → BASE=$BASE"
fi
fi
# 4. Counter: 1 + max existing N for `${BASE}-rc.*`, else 1.
cat > /tmp/next_rc.mjs <<'NODESCRIPT'
const base = process.env.BASE;
const prefix = base + "-rc.";
let v;
try { v = JSON.parse(process.env.VERSIONS_JSON); } catch { v = []; }
if (!Array.isArray(v)) v = [v];
const ns = v
.filter(s => typeof s === "string" && s.startsWith(prefix))
.map(s => parseInt(s.slice(prefix.length), 10))
.filter(n => Number.isInteger(n) && n >= 0);
process.stdout.write(String(ns.length ? Math.max(...ns) + 1 : 1));
NODESCRIPT
NEXT_N="$(BASE="$BASE" VERSIONS_JSON="$VERSIONS_JSON" node /tmp/next_rc.mjs)"
RC_VERSION="${BASE}-rc.${NEXT_N}"
echo "Computed rc: $RC_VERSION"
# 5. Defensive: if the exact version already exists on the registry
# (e.g., race with another run), abort before re-publishing.
# Same E404-only pattern used above — a transient network
# failure must fail loudly, not pretend the version is missing.
NPM_STDERR_EXISTS="$(mktemp)"
if npm view "$PKG_NAME@$RC_VERSION" version 2>"$NPM_STDERR_EXISTS" >/dev/null; then
rm -f "$NPM_STDERR_EXISTS"
echo "::error::Version $RC_VERSION already exists on npm — aborting."
exit 1
else
if grep -qiE 'E404|not found' "$NPM_STDERR_EXISTS"; then
rm -f "$NPM_STDERR_EXISTS"
# Version doesn't exist — safe to proceed.
else
echo "::error::npm registry unreachable for existence check:" >&2
cat "$NPM_STDERR_EXISTS" >&2
rm -f "$NPM_STDERR_EXISTS"
exit 1
fi
fi
{
echo "base=$BASE"
echo "rc_n=$NEXT_N"
echo "rc_version=$RC_VERSION"
} >> "$GITHUB_OUTPUT"
- name: Apply rc version in-CI
shell: bash
working-directory: gitnexus
run: |
set -euo pipefail
npm version "${{ steps.version.outputs.rc_version }}" \
--no-git-tag-version --allow-same-version
- name: Build gitnexus
run: npm run build
working-directory: gitnexus
- name: Dry-run publish
run: npm publish --dry-run --tag rc
working-directory: gitnexus
# ── Acquire the "rc lock" BEFORE publishing (fixes idempotency) ─────
# We create two tags and push them atomically:
# v<RC_VERSION> → annotated tag on a detached release commit
# whose tree contains the rewritten package.json
# (so the tag's source matches the npm tarball)
# rc/<HEAD_SHA> → lightweight tag on HEAD; the guard's dedup key
# If this push fails, nothing is published — safe.
# If this push succeeds but npm publish fails, the marker stays on
# the remote and blocks retries until an operator manually cleans up.
- name: Create and push rc tags
id: reltag
shell: bash
working-directory: gitnexus
env:
RC_VERSION: ${{ steps.version.outputs.rc_version }}
HEAD_SHA: ${{ needs.guard.outputs.head_sha }}
run: |
set -euo pipefail
VTAG="v${RC_VERSION}"
MARKER="rc/${HEAD_SHA}"
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
# Detached release commit with the version bump — keeps `main`
# pristine but gives the v-tag a tree that matches the published
# package contents exactly (fixes release-integrity gap).
git add package.json package-lock.json 2>/dev/null || git add package.json
git commit -m "release: ${VTAG}" --allow-empty
RELEASE_SHA="$(git rev-parse HEAD)"
echo "Detached release commit: $RELEASE_SHA"
# Annotated release tag on the release commit.
git tag -a "$VTAG" "$RELEASE_SHA" -m "$VTAG"
# Lightweight marker on the user-visible HEAD for the guard.
git tag "$MARKER" "$HEAD_SHA"
# Atomic push of both refs. If either would clobber an existing
# remote ref, the push fails and we stop before npm publish.
git push --atomic origin "refs/tags/$VTAG" "refs/tags/$MARKER"
{
echo "vtag=$VTAG"
echo "marker=$MARKER"
echo "release_sha=$RELEASE_SHA"
} >> "$GITHUB_OUTPUT"
- name: Publish to npm (rc dist-tag)
run: npm publish --provenance --access public --tag rc
working-directory: gitnexus
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Create GitHub prerelease
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v2
with:
tag_name: ${{ steps.reltag.outputs.vtag }}
name: Release Candidate ${{ steps.reltag.outputs.vtag }}
prerelease: true
make_latest: 'false'
generate_release_notes: true
body: |
Automated release candidate build from `main`.
**npm:** `npm install gitnexus@rc`
**Version:** `${{ steps.version.outputs.rc_version }}`
**Target base:** `${{ steps.version.outputs.base }}` (rc #${{ steps.version.outputs.rc_n }})
**Source commit (main):** ${{ needs.guard.outputs.head_sha }}
**Release commit (versioned tree):** ${{ steps.reltag.outputs.release_sha }}
Release candidates are pre-stable builds intended for early testing.
Stable releases remain on the `latest` dist-tag.
# ── Build & push RC Docker images ────────────────────────────────────
# Calls docker.yml as a reusable workflow so that the build, signing, and
# attestation logic stays in one place. The publish job exposes `vtag`
# (e.g. `v1.2.3-rc.1`) as an output so we can pass it as the tag input.
# RC images are signed with Cosign keyless signing; the OIDC identity
# will be `docker.yml@refs/heads/main` (the caller's ref) rather than a
# tag ref — see README.md § Docker for the correct verify command for RCs.
docker:
name: Build & Push RC Docker images
needs: [guard, publish]
if: needs.guard.outputs.should_run == 'true' && needs.publish.outputs.vtag != ''
uses: ./.github/workflows/docker.yml
# Reusable workflows do not receive caller secrets unless inherited; without
# this, DOCKERHUB_* / GITHUB_TOKEN are empty in docker.yml → "Username and
# password required" on Docker Hub login (see same pattern on `ci:` above).
secrets: inherit
permissions:
contents: read
packages: write
id-token: write
attestations: write
with:
tag: ${{ needs.publish.outputs.vtag }}
+5 -4
View File
@@ -37,7 +37,8 @@ rules:
- pr-labeler.yml
# Note: cache-poisoning is NOT exempted. The two prior findings in
# publish.yml and release-candidate.yml were fixed structurally by
# dropping `cache: npm` from those workflows (matches the pattern used
# by PyO3/maturin for the same audit). See the commit that added this
# file for the rationale.
# publish.yml and the former release-candidate.yml were fixed structurally
# by dropping `cache: npm` from those workflows (matches the pattern used
# by PyO3/maturin for the same audit). After the publish-workflow
# unification (issue #1609), only publish.yml remains; the same
# cache-poisoning hardening applies there.
+2 -2
View File
@@ -155,9 +155,9 @@ npx gitnexus analyze --embeddings # also generate embeddings for new/changed
npx gitnexus analyze --drop-embeddings # explicit opt-in to wipe existing embeddings
```
`analyze` runs **incrementally by default**. The pipeline still parses every file every run (cross-file resolution requires it), but tree-sitter parsing is **served from a content-addressed cache** at `.gitnexus/parse-cache.json` for chunks whose file contents haven't changed since the last run. Only changed-file rows (and their importers) are rewritten in LadybugDB; unchanged-file rows are preserved. Output is byte-equivalent to a full rebuild. Pass `--force` to wipe and re-index from scratch (e.g., to recover from a corrupt index, or after upgrading GitNexus).
`analyze` runs **incrementally by default**. The pipeline still parses every file every run (cross-file resolution requires it), but tree-sitter parsing is **served from a content-addressed cache** under `.gitnexus/parse-cache/` (per-chunk JSON shards plus `index.json`) for chunks whose file contents haven't changed since the last run. Older installs may still have a legacy single file `.gitnexus/parse-cache.json`, which is read for backward compatibility but no longer written. Only changed-file rows (and their importers) are rewritten in LadybugDB; unchanged-file rows are preserved. Output is byte-equivalent to a full rebuild. Pass `--force` to wipe and re-index from scratch (e.g., to recover from a corrupt index, or after upgrading GitNexus).
The parse cache key is **content-addressed and version-tagged**: it survives `--force` runs, and is automatically invalidated by a `gitnexus` package upgrade (so a new tree-sitter grammar doesn't silently replay stale parse output). Safe to delete `.gitnexus/parse-cache.json` at any time — it'll be rebuilt on the next analyze.
The parse cache key is **content-addressed and version-tagged**: it survives `--force` runs, and is automatically invalidated by a `gitnexus` package upgrade (so a new tree-sitter grammar doesn't silently replay stale parse output). Safe to delete the whole `.gitnexus/parse-cache/` directory (and remove any legacy `.gitnexus/parse-cache.json` if present) at any time — it'll be rebuilt on the next analyze.
Check `.gitnexus/meta.json` `stats.embeddings` (0 = none). A plain `analyze` no longer drops existing vectors — pass `--drop-embeddings` to wipe.
+57 -27
View File
@@ -144,16 +144,18 @@ If you use coding agents, follow project context files (e.g. `AGENTS.md`, `CLAUD
## Releases
Two publish workflows ship `gitnexus` to npm:
One workflow ships `gitnexus` to npm — `.github/workflows/publish.yml`. It
routes between two modes based on the triggering event:
- **Stable** (`.github/workflows/publish.yml`) — triggered by pushing any `v*`
tag. Publishes to the `latest` dist-tag with a changelog-backed GitHub
release. Maintainers are expected to tag from `main` as a convention; the
workflow itself does not enforce branch reachability.
- **Release Candidate** (`.github/workflows/release-candidate.yml`) — runs on
every push to `main` (typically a merged PR) plus manual dispatch. Docs-only
changes are skipped via `paths-ignore`. Publishes to the `rc` dist-tag with
version `X.Y.Z-rc.N` and a GitHub prerelease, where:
- **Stable mode** — triggered by pushing any `v<X.Y.Z>` tag (no `-rc.*`
suffix; RC tags are excluded at trigger via a negative glob). Publishes to
the `latest` dist-tag with a changelog-backed GitHub release. Maintainers
are expected to tag from `main` as a convention; the workflow itself does
not enforce branch reachability. No Docker build (RC-only).
- **Release-candidate mode** — runs on every push to `main` (typically a
merged PR) plus manual `workflow_dispatch`. Docs-only changes are skipped
via `paths-ignore`. Publishes to the `rc` dist-tag with version
`X.Y.Z-rc.N` and a GitHub prerelease, where:
- `X.Y.Z` is selected automatically. On push (and on dispatch with
`bump: auto`, the default) the workflow **continues the active rc cycle**:
if the registry already has `X.Y.Z-rc.*` versions with `X.Y.Z` > current
@@ -170,36 +172,64 @@ Two publish workflows ship `gitnexus` to npm:
caller's ref — see README.md § Docker for the verify command).
Idempotency: the workflow pushes an `rc/<HEAD_SHA>` marker tag and a
`v<RC>` release tag **atomically, before** calling `npm publish`. The guard
refuses to re-run once the marker exists, so a post-publish failure will
not mint a duplicate rc for the same commit. The `v<RC>` tag points at a
detached release commit whose `package.json` matches the npm tarball
exactly (traceable releases). Recovery after a partial failure:
`v<RC>` release tag **atomically, before** calling `npm publish`. The
RC guard refuses to re-run once the marker exists, so a post-publish
failure will not mint a duplicate rc for the same commit. The `v<RC>`
tag points at a detached release commit whose `package.json` matches
the npm tarball exactly (traceable releases). The RC tag is excluded
from this workflow's `push: tags:` filter, so it does **not** re-trigger
publishing — preventing the double-publish failure mode tracked in #1609.
Recovery after a partial failure: the workflow's `if: failure()` cleanup
step in the `publish` job auto-deletes the v-tag and marker on most
post-publish failures, so the typical retry is just:
```bash
gh workflow run publish.yml --ref main -f force=true
# or push a new commit to main, which will cut a fresh RC
```
If auto-cleanup didn't run (e.g. the cleanup step itself failed, or the
failure happened in the route/rc-guard phase before the marker was
pushed), manual cleanup is:
```bash
git push --delete origin rc/<HEAD_SHA> v<RC>
# then redispatch the workflow with force: true
# then redispatch with force: true
```
**Release-PR-skip subject pattern.** The rc-guard job recognizes a
squash-merged release commit by matching the commit subject against
`^chore: release vX.Y.Z` (optionally followed by ` (#NNNN)` for the
squash-merge PR-number suffix). Match is case-insensitive — `Chore: Release v1.2.3`
works too. PRs that should suppress the RC build must either use this
subject shape, or carry the `release` label so the label-based fallback
fires. Other release-style subjects (`chore(release): v1.2.3`,
`release: v1.2.3`) will NOT trigger the skip — please name the release
PR exactly `chore: release vX.Y.Z` to keep the dedup deterministic.
**Docker-only partial failure:** if `publish` succeeds (npm tarball + tags
are live) but the `docker` job subsequently fails (e.g. GHCR flakiness),
the npm RC is already published and the `rc/<HEAD_SHA>` marker is in place.
Re-running `release-candidate.yml` with `force: true` will abort at the
"Version already exists on npm" guard. To recover without cutting a new RC:
Recovery without cutting a new RC:
```bash
# 1. Manually trigger only the docker workflow, passing the existing RC tag:
gh workflow run docker.yml --ref main -f tag=v<RC_VERSION>
# (requires a workflow_dispatch trigger on docker.yml — see note below)
# Re-run only the failed docker job from the original workflow run:
gh run rerun <run-id> --failed
```
Because `docker.yml` intentionally has no `workflow_dispatch` (images are
tag-driven by design), the practical recovery options are:
- Wait for the next commit on `main`, which will cut a new RC that includes
the Docker build.
- Manually run `docker build` + `docker push` locally and sign with Cosign
against the same digest.
- Delete `rc/<HEAD_SHA>` and `v<RC>` tags, then redispatch with `force: true` to re-run the full RC pipeline (cuts a new RC number).
Find the run ID via `gh run list --workflow=publish.yml --branch main`.
`docker.yml` intentionally has no `workflow_dispatch` trigger (images are
tag-driven by design), so the gh-run-rerun path is the supported recovery.
**GitHub Release transient failure** (npm publish succeeded, Release step
failed): the npm artifact is live but no GitHub Release page exists.
Recover by either re-running the failed job (`gh run rerun <run-id> --failed`),
or creating the Release manually:
```bash
gh release create v<RC> --prerelease --generate-notes # RC
gh release create v<X.Y.Z> --notes-file gitnexus/CHANGELOG.md # stable
```
The rc workflow never moves `latest`. To verify after a change, inspect dist-tags:
+1 -1
View File
@@ -36,7 +36,7 @@ Format: **Trigger → Instruction → Reason**. Append new Signs when the same m
### Index seems corrupt or "incremental" is misbehaving
- **Trigger:** `analyze` produces unexpected results, or `meta.json.incrementalInProgress` is set, or the index is in a half-state after a crash.
- **Do:** `npx gitnexus analyze --force` to rebuild from scratch. The dirty-flag check forces this automatically when a previous incremental run didn't complete cleanly, but `--force` is the manual escape hatch. Safe to delete `.gitnexus/parse-cache.json` at any time — content-addressed, will be regenerated.
- **Do:** `npx gitnexus analyze --force` to rebuild from scratch. The dirty-flag check forces this automatically when a previous incremental run didn't complete cleanly, but `--force` is the manual escape hatch. Safe to delete the `.gitnexus/parse-cache/` directory (and any legacy `.gitnexus/parse-cache.json`) at any time — content-addressed, will be regenerated.
- **Why:** Incremental writeback is selective DB row replacement; if the on-disk state is inconsistent for any reason, a full rebuild is the cheapest path back to a known-good index.
### Embeddings vanished after analyze
+5 -3
View File
@@ -429,7 +429,7 @@ The Docker images are version-locked to the npm package:
Both registries receive the same digest from a single build step, so you can
pull from either and the signature verifies identically.
- Release-candidate images (e.g. `:1.7.0-rc.1`) are published alongside each
RC npm release. They are built by `release-candidate.yml` calling `docker.yml`
RC npm release. They are built by `publish.yml` calling `docker.yml`
as a reusable workflow after the RC tag is created and pushed.
- `:latest` is auto-promoted only from non-prerelease tags by the Docker
metadata action, so it always points at a real, npm-published version.
@@ -462,7 +462,7 @@ registries because both sets of tags were signed at the same digest in one
workflow run.
**Release candidates** — signed from `refs/heads/main` (the caller's ref when
`release-candidate.yml` invokes `docker.yml` as a reusable workflow):
`publish.yml` invokes `docker.yml` as a reusable workflow):
```bash
cosign verify ghcr.io/abhigyanpatwari/gitnexus:1.7.0-rc.1 \
@@ -725,9 +725,11 @@ gitnexus wiki --force
# Increase the timeout or retries for large codebase or slow LLM providers
gitnexus wiki --timeout <seconds> # Per-attempt LLM request timeout in seconds (default: 60)
gitnexus wiki --timeout <seconds> # LLM request timeout in seconds (default: disabled)
gitnexus wiki --retries <n> # Max LLM retry attempts per request (default: 3)
# Change the language generation for wiki
gitnexus wiki --lang <lang> # Output language for generated documentation (e.g. english, chinese, spanish, japanese)
```
The wiki generator reads the indexed graph structure, groups files into modules via LLM, generates per-module documentation pages, and creates an overview page — all with cross-references to the knowledge graph.
@@ -56,15 +56,15 @@ Generates repository documentation from the knowledge graph using an LLM. Requir
| Flag | Effect |
|------|--------|
| `--force` | Force full regeneration |
| `--force` | Force full regeneration, also required to re-gerenate an existing wiki in a different language |
| `--model <model>` | LLM model (default: minimax/minimax-m2.5) |
| `--base-url <url>` | LLM API base URL |
| `--api-key <key>` | LLM API key |
| `--concurrency <n>` | Parallel LLM calls (default: 3) |
| `--gist` | Publish wiki as a public GitHub Gist |
| `--timeout <seconds>` | Per-attempt LLM request timeout in seconds (default: 60) |
| `--timeout <seconds>` | LLM request timeout in seconds (default: disabled) |
| `--retries <n>` | Max LLM retry attempts per request (default: 3) |
| `--lang <lang>` | Output language for generated documentation (e.g. english, chinese, spanish, japanese)|
### list — Show all indexed repos
```bash
+2 -1
View File
@@ -26,7 +26,7 @@ export type { PipelinePhase, PipelineProgress } from './pipeline.js';
// ─── Scope-based resolution — RFC #909 (Ring 1 #910) ────────────────────────
// Data model (RFC §2)
export type { SymbolDefinition } from './scope-resolution/symbol-definition.js';
export type { ParameterTypeClass, SymbolDefinition } from './scope-resolution/symbol-definition.js';
export type {
ScopeId,
DefId,
@@ -129,6 +129,7 @@ export type {
RegistryProviders,
OwnerScopedContributor,
ArityVerdict,
ConstraintContext,
} from './scope-resolution/registries/context.js';
// Scope tree spine + position lookup (RFC §2.2 + §3.1; Ring 2 SHARED #912)
@@ -30,10 +30,43 @@ export interface RegistryProviders {
* when absent, every candidate receives `'unknown'` (neutral signal).
*/
arityCompatibility?(callsite: Callsite, def: SymbolDefinition): ArityVerdict;
/**
* Language-specific constraint compatibility between a callsite and a
* candidate `def`. Mirrors `arityCompatibility` and shares its three-valued
* verdict shape; the third value `'unknown'` MUST keep the candidate
* (monotonicity: adding a predicate can only narrow correctly, never
* produce a wrong edge). Consulted by `narrowOverloadCandidates` after
* arity + type filters when a candidate carries `templateConstraints`.
*
* Optional; when absent the constraint filter is a pass-through. Languages
* with no constrained-overload semantics leave this undefined.
*/
constraintCompatibility?(
callsite: Callsite,
def: SymbolDefinition,
ctx: ConstraintContext,
): ArityVerdict;
}
export type ArityVerdict = 'compatible' | 'unknown' | 'incompatible';
/**
* Context threaded into `constraintCompatibility`. Kept minimal in the
* Tier-A scope (only `argumentTypes`, riding here until a separate
* `Callsite`-widening refactor moves them onto the call site directly).
* Future Tier-B graph-aware predicates (`is_base_of_v`, etc.) will widen
* this interface with `lookupTypeByName` and similar helpers.
*/
export interface ConstraintContext {
/**
* Per-slot argument types at the call site, normalized per the language
* adapter. Empty string means unknown. Same convention as
* `narrowOverloadCandidates`' `argTypes` parameter.
*/
readonly argumentTypes?: readonly string[];
}
// ─── Owner-scoped contributor (concrete shape for `RegistryContributor`) ────
/**
@@ -11,6 +11,17 @@
import type { NodeLabel } from '../graph/types.js';
export interface ParameterTypeClass {
/** Normalized base type, matching the coarse `parameterTypes` vocabulary when known. */
base: string;
/** Top-level cv signal preserved from the original C++ parameter spelling. */
cv: 'none' | 'const' | 'volatile' | 'const volatile' | 'unknown';
/** Coarse value/reference/pointer shape. */
indirection: 'value' | 'lvalue-ref' | 'rvalue-ref' | 'pointer' | 'unknown';
/** Number of pointer markers when indirection is `pointer`; otherwise 0. */
pointerDepth: number;
}
export interface SymbolDefinition {
nodeId: string;
filePath: string;
@@ -26,12 +37,22 @@ export interface SymbolDefinition {
/** Per-parameter type names for overload disambiguation (e.g. ['int', 'String']).
* Populated when parameter types are resolvable from AST (any typed language). */
parameterTypes?: string[];
/** Additive per-parameter type shape sidecar for languages that need cv/ref/pointer distinctions.
* Does not participate in graph node identity unless a resolver explicitly opts in. */
parameterTypeClasses?: ParameterTypeClass[];
/** Raw return type text extracted from AST (e.g. 'User', 'Promise<User>') */
returnType?: string;
/** Declared type for non-callable symbols — fields/properties (e.g. 'Address', 'List<User>') */
declaredType?: string;
/** Generic/template specialization arguments for class-like symbols (e.g. ['User'], ['T*']). */
templateArguments?: string[];
/** Per-language constraint payload for template / generic overloads
* (e.g. C++ `enable_if_t<P, T>` predicate trees, C++20 `requires` clauses).
* Opaque to shared code — the producing language adapter owns the shape
* and is the only consumer. Read via the optional
* `ScopeResolver.constraintCompatibility` hook during overload narrowing.
* Absent for symbols that have no constraints (the common case). */
templateConstraints?: unknown;
/** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */
ownerId?: string;
}
+15 -2
View File
@@ -1,5 +1,18 @@
{
"permissions": {
"allow": ["mcp__plugin_claude-mem_mcp-search__get_observations"]
}
"allow": [
"mcp__plugin_claude-mem_mcp-search__get_observations",
"Skill(gitnexus-exploring)",
"Bash(npx gitnexus *)",
"mcp__obsidian-memory__search_nodes",
"mcp__obsidian-memory__add_observations",
"WebSearch",
"WebFetch(domain:cppreference.net)",
"Bash(xargs grep -l \"templateArguments\\\\|parameterTypes\")",
"Bash(gh issue *)",
"Bash(gh pr *)"
]
},
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": ["gitnexus"]
}
+54
View File
@@ -4,6 +4,60 @@ All notable changes to GitNexus will be documented in this file.
## [Unreleased]
## [1.6.5] - 2026-05-16
### Added
- **C++ ADL V2** — Argument-Dependent Lookup overhaul. Class-typed reference args (incl. rvalue refs) contribute associated namespaces (#1595); class-pointer args and template-specialization args (with nested template args) included (#1592, #1596); base-class associated namespaces walked via MRO (#1597); free-function reference args contribute enclosing namespace (#1598); ordinary and ADL free-call candidates merged before overload selection (#1599)
- **C++ standard-conversion-sequence ranking** for overload resolution (#1606)
- **C++ scope-resolution migration** — C++ now runs on the registry-primary RFC #909 path (#938, #1520); template-body `this->` + `using ns::name` calls resolved in the scope resolver (#1590); template specializations disambiguated in class graph IDs and receiver routing (#1587); EXTENDS edges for template and qualified template bases (#1581)
- **PHP scope-resolution migration** — PHP moved to scope-based resolution (#938, #1497, supersedes #1124)
- **Java scope-resolution migration** — RFC #909 Ring 3 (#1482)
- **C scope-resolution migration** — RFC #909 Ring 3 (#1481)
- **Incremental indexing** — `gitnexus analyze` now reuses a parse cache, writes back to DB, and short-circuits scope resolution when nothing changed (#1479)
- **`gitnexus:keep` marker** — preserves custom context sections (#605, #1508)
- **`gitnexus analyze --skip-skills` and `--index-only`** flags (#742, #1485)
- **`gitnexus wiki --timeout` and `--retries` flags** — mitigate timeout aborts on large module pages (#1543)
- **HTTP embedding `dimensions` parameter** — now forwarded to the embedding endpoint (#1498)
- **Cursor 2.4 `postToolUse` hooks** — upgraded for Read/Grep/Shell coverage (#1467)
### Fixed
- **Cross-file type propagation** — resolved a stall on large repos (#1626)
- **C++ inline-namespace ambiguity** — detect same-name ambiguity across inline namespace children (#1564, #1600); workspace-wide dependent-base name resolution for cross-file templates (#1586)
- **Parse cache persistence** — sharded on large repos to avoid corruption (#1580)
- **TypeScript ESM `.js` extension** — fallback applied to tsconfig path-alias resolution (#1530) and `.js` → `.ts` source resolution (#1525)
- **Markdown CRLF line endings** — section heading parser now handles them (#1469)
- **`gitnexus analyze --no-stats`** — actually omits volatile counts (#1477, #1478)
- **`ensureGitNexusIgnored`** — tolerate read-only workspaces (#1549, #1550)
- **Claude augment hook** — skipped when GitNexus server owns the DB (#1493)
- **Docker runtime image** — symlink `gitnexus` binary onto `$PATH` (#1551); install `ca-certificates` for TLS verification (#1545, #1547); include duckdb installer script (#1502)
- **Windows reliability** — fix 32767-char tree-sitter crash and VECTOR-extension SIGSEGV (#1433); platform-aware `tsc` build command for win32 (#1531)
- **Search / FTS** — guard against undefined `bm25Results` when FTS is unavailable (#1489, #1540); CONTAINS fallback in augment when FTS indexes unavailable (#1476)
- **Wiki** — sanitize generated mermaid diagrams (#1539)
- **Hooks** — cap concurrent augment subprocesses to prevent runaway fan-out (#1486, #1510)
- **LadybugDB** — drain checkpoint result before close (#1506); recover `gitnexus analyze` from orphan sidecars when the main DB file is missing (#1622)
- **Group / contracts** — detect `httpx` async consumers (#1408)
- **Server hardening** — sanitize repo name to prevent argument injection on `/api/analyze` (#1305)
### Changed
- **CI release pipeline unified under `publish.yml`** — single source of truth for npm publish, provenance, and GitHub Release creation (#1610)
- **CI: skip RC build on release PRs** — release/* branches no longer cut redundant RCs (#1474)
- **CI (Claude review): make `/review` reliably post PR comments** (#1522); allow Bash in code-review job without interactive approval (#1523)
- **CI publish (post-merge fixes)** — bump publish job to Node 24 for npm OIDC support (#1628); engage npm Trusted Publishing OIDC properly (#1627)
- **Tests** — remove flaky regression test for resource exhaustion (#1521); de-flake regex linearity assertions in U8 (#1475)
### Chore / Dependencies
- `vitest` 4.1.5 → 4.1.6 in /gitnexus (#1605)
- `@langchain/google-genai` bump in /gitnexus-web (#1554)
- `vite` 8.0.10 → 8.0.11 in /gitnexus-web (#1555)
- `mermaid` bump (#1514)
- `protobufjs` 7.5.5 → 7.5.8 + `@protobufjs/utf8` in /gitnexus (#1535, #1536)
- `urllib3` bump in /eval uv group (#1512)
- GitHub Actions: `sigstore/cosign-installer` 4.1.1 → 4.1.2 (#1557)
## [1.6.4] - 2026-05-10
### Added
+146 -146
View File
@@ -1,12 +1,12 @@
{
"name": "gitnexus",
"version": "1.6.4",
"version": "1.6.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitnexus",
"version": "1.6.4",
"version": "1.6.5",
"hasInstallScript": true,
"license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {
@@ -142,9 +142,9 @@
}
},
"node_modules/@emnapi/core": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -1572,9 +1572,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.126.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.126.0.tgz",
"integrity": "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==",
"version": "0.130.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
"integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==",
"dev": true,
"license": "MIT",
"funding": {
@@ -1652,9 +1652,9 @@
"license": "BSD-3-Clause"
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz",
"integrity": "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
"integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
"cpu": [
"arm64"
],
@@ -1669,9 +1669,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.16.tgz",
"integrity": "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz",
"integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
"cpu": [
"arm64"
],
@@ -1686,9 +1686,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.16.tgz",
"integrity": "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz",
"integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
"cpu": [
"x64"
],
@@ -1703,9 +1703,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.16.tgz",
"integrity": "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz",
"integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
"cpu": [
"x64"
],
@@ -1720,9 +1720,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.16.tgz",
"integrity": "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz",
"integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
"cpu": [
"arm"
],
@@ -1737,9 +1737,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz",
"integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
"cpu": [
"arm64"
],
@@ -1754,9 +1754,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.16.tgz",
"integrity": "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz",
"integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
"cpu": [
"arm64"
],
@@ -1771,9 +1771,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz",
"integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
"cpu": [
"ppc64"
],
@@ -1788,9 +1788,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz",
"integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
"cpu": [
"s390x"
],
@@ -1805,9 +1805,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.16.tgz",
"integrity": "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz",
"integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
"cpu": [
"x64"
],
@@ -1822,9 +1822,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.16.tgz",
"integrity": "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz",
"integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
"cpu": [
"x64"
],
@@ -1839,9 +1839,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.16.tgz",
"integrity": "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz",
"integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
"cpu": [
"arm64"
],
@@ -1856,9 +1856,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.16.tgz",
"integrity": "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz",
"integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
"cpu": [
"wasm32"
],
@@ -1866,8 +1866,8 @@
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.9.2",
"@emnapi/runtime": "1.9.2",
"@emnapi/core": "1.10.0",
"@emnapi/runtime": "1.10.0",
"@napi-rs/wasm-runtime": "^1.1.4"
},
"engines": {
@@ -1875,9 +1875,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -1886,9 +1886,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz",
"integrity": "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz",
"integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
"cpu": [
"arm64"
],
@@ -1903,9 +1903,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.16.tgz",
"integrity": "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz",
"integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
"cpu": [
"x64"
],
@@ -1920,9 +1920,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.16.tgz",
"integrity": "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT"
},
@@ -1941,9 +1941,9 @@
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -2132,14 +2132,14 @@
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz",
"integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz",
"integrity": "sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.5",
"@vitest/utils": "4.1.6",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
@@ -2153,8 +2153,8 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.5",
"vitest": "4.1.5"
"@vitest/browser": "4.1.6",
"vitest": "4.1.6"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@@ -2163,16 +2163,16 @@
}
},
"node_modules/@vitest/expect": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
"integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
"integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.5",
"@vitest/spy": "4.1.6",
"@vitest/utils": "4.1.6",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -2181,13 +2181,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
"integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz",
"integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.5",
"@vitest/spy": "4.1.6",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -2208,9 +2208,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
"integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz",
"integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2221,13 +2221,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
"integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz",
"integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.5",
"@vitest/utils": "4.1.6",
"pathe": "^2.0.3"
},
"funding": {
@@ -2235,14 +2235,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
"integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz",
"integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.5",
"@vitest/utils": "4.1.5",
"@vitest/pretty-format": "4.1.6",
"@vitest/utils": "4.1.6",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -2251,9 +2251,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
"integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz",
"integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==",
"dev": true,
"license": "MIT",
"funding": {
@@ -2261,13 +2261,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
"integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz",
"integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.5",
"@vitest/pretty-format": "4.1.6",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -4151,9 +4151,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true,
"funding": [
{
@@ -4498,9 +4498,9 @@
"license": "MIT"
},
"node_modules/postcss": {
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"dev": true,
"funding": [
{
@@ -4703,14 +4703,14 @@
}
},
"node_modules/rolldown": {
"version": "1.0.0-rc.16",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz",
"integrity": "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
"integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.126.0",
"@rolldown/pluginutils": "1.0.0-rc.16"
"@oxc-project/types": "=0.130.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -4719,21 +4719,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.16",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.16",
"@rolldown/binding-darwin-x64": "1.0.0-rc.16",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.16",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.16",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.16",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.16",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16"
"@rolldown/binding-android-arm64": "1.0.1",
"@rolldown/binding-darwin-arm64": "1.0.1",
"@rolldown/binding-darwin-x64": "1.0.1",
"@rolldown/binding-freebsd-x64": "1.0.1",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.1",
"@rolldown/binding-linux-arm64-gnu": "1.0.1",
"@rolldown/binding-linux-arm64-musl": "1.0.1",
"@rolldown/binding-linux-ppc64-gnu": "1.0.1",
"@rolldown/binding-linux-s390x-gnu": "1.0.1",
"@rolldown/binding-linux-x64-gnu": "1.0.1",
"@rolldown/binding-linux-x64-musl": "1.0.1",
"@rolldown/binding-openharmony-arm64": "1.0.1",
"@rolldown/binding-wasm32-wasi": "1.0.1",
"@rolldown/binding-win32-arm64-msvc": "1.0.1",
"@rolldown/binding-win32-x64-msvc": "1.0.1"
}
},
"node_modules/router": {
@@ -5612,16 +5612,16 @@
}
},
"node_modules/vite": {
"version": "8.0.9",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz",
"integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==",
"version": "8.0.13",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.10",
"rolldown": "1.0.0-rc.16",
"postcss": "^8.5.14",
"rolldown": "1.0.1",
"tinyglobby": "^0.2.16"
},
"bin": {
@@ -5638,7 +5638,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.0",
"@vitejs/devtools": "^0.1.18",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
@@ -5690,19 +5690,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
"integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz",
"integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.5",
"@vitest/mocker": "4.1.5",
"@vitest/pretty-format": "4.1.5",
"@vitest/runner": "4.1.5",
"@vitest/snapshot": "4.1.5",
"@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.5",
"@vitest/expect": "4.1.6",
"@vitest/mocker": "4.1.6",
"@vitest/pretty-format": "4.1.6",
"@vitest/runner": "4.1.6",
"@vitest/snapshot": "4.1.6",
"@vitest/spy": "4.1.6",
"@vitest/utils": "4.1.6",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -5730,12 +5730,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.5",
"@vitest/browser-preview": "4.1.5",
"@vitest/browser-webdriverio": "4.1.5",
"@vitest/coverage-istanbul": "4.1.5",
"@vitest/coverage-v8": "4.1.5",
"@vitest/ui": "4.1.5",
"@vitest/browser-playwright": "4.1.6",
"@vitest/browser-preview": "4.1.6",
"@vitest/browser-webdriverio": "4.1.6",
"@vitest/coverage-istanbul": "4.1.6",
"@vitest/coverage-v8": "4.1.6",
"@vitest/ui": "4.1.6",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gitnexus",
"version": "1.6.4",
"version": "1.6.5",
"description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
"author": "Abhigyan Patwari",
"license": "PolyForm-Noncommercial-1.0.0",
+85 -3
View File
@@ -13,6 +13,7 @@ import { execFileSync } from 'child_process';
import v8 from 'v8';
import cliProgress from 'cli-progress';
import { closeLbug } from '../core/lbug/lbug-adapter.js';
import { isWalCorruptionError, WAL_RECOVERY_SUGGESTION } from '../core/lbug/lbug-config.js';
import {
getStoragePaths,
getGlobalRegistryPath,
@@ -67,13 +68,69 @@ const installFatalHandlers = (): void => {
});
};
const HEAP_MB = 8192;
const HEAP_FLAG = `--max-old-space-size=${HEAP_MB}`;
const HEAP_MB = 16384;
const TEST_RESPAWN_HEAP_MB = Number(process.env.GITNEXUS_TEST_RESPAWN_HEAP_MB);
const RESPAWN_HEAP_MB =
Number.isFinite(TEST_RESPAWN_HEAP_MB) && TEST_RESPAWN_HEAP_MB > 0
? Math.floor(TEST_RESPAWN_HEAP_MB)
: HEAP_MB;
const HEAP_FLAG = `--max-old-space-size=${RESPAWN_HEAP_MB}`;
/** Increase default stack size (KB) to prevent stack overflow on deep class hierarchies. */
const STACK_KB = 4096;
const STACK_FLAG = `--stack-size=${STACK_KB}`;
/** Re-exec the process with an 8GB heap and larger stack if we're currently below that. */
/**
* Heuristic for "child re-exec likely died from V8 OOM".
*
* Platform-independent detection is best-effort: V8/Node usually emit
* stable heap-exhaustion phrases in stderr/message across Linux/macOS/Windows
* (for example "JavaScript heap out of memory" or "Reached heap limit"),
* while some environments only expose status/signal (e.g. 134/SIGABRT).
* We combine both text signatures and process-exit signatures.
*/
const childProcessLikelyOom = (err: unknown): boolean => {
if (!err || typeof err !== 'object') return false;
const e = err as {
status?: unknown;
signal?: unknown;
stderr?: unknown;
stdout?: unknown;
message?: unknown;
};
const hasHeapOomSignature = (v: unknown): boolean => {
const text = (
Buffer.isBuffer(v) ? v.toString('utf8') : typeof v === 'string' ? v : ''
).toLowerCase();
if (!text) return false;
return (
text.includes('javascript heap out of memory') ||
text.includes('reached heap limit') ||
text.includes('allocation failed - javascript heap out of memory') ||
text.includes('fatalprocessoutofmemory')
);
};
const fields = [e.message, e.stderr, e.stdout];
if (fields.some((v) => hasHeapOomSignature(v))) return true;
const hasAnyChildOutput = [e.stderr, e.stdout].some(
(v) => (Buffer.isBuffer(v) && v.length > 0) || (typeof v === 'string' && v.length > 0),
);
if (hasAnyChildOutput) return false;
return e.status === 134 || e.signal === 'SIGABRT';
};
const forceHeapOOMForTestIfEnabled = (): void => {
if (process.env.GITNEXUS_TEST_FORCE_HEAP_OOM !== '1') return;
// Allocate JS strings (not Buffers) so pressure lands on V8 heap itself.
// Buffers can allocate off-heap, which makes OOM triggering less reliable.
const chunks: string[] = [];
for (;;) chunks.push('x'.repeat(1024 * 1024));
};
/** Re-exec the process with a 16GB heap and larger stack if we're currently below that. */
function ensureHeap(): boolean {
const nodeOpts = process.env.NODE_OPTIONS || '';
if (nodeOpts.includes('--max-old-space-size')) return false;
@@ -92,6 +149,16 @@ function ensureHeap(): boolean {
env: { ...process.env, NODE_OPTIONS: `${nodeOpts} ${HEAP_FLAG}`.trim() },
});
} catch (e: any) {
if (childProcessLikelyOom(e)) {
cliError(
` Analysis likely ran out of memory.\n` +
` Retry with a larger heap if your machine allows it:\n` +
` NODE_OPTIONS="--max-old-space-size=24576" gitnexus analyze [your-args]\n` +
` (Windows: set NODE_OPTIONS=--max-old-space-size=24576 && gitnexus analyze [your-args])\n` +
` If this persists, it may be a native crash unrelated to heap size.\n`,
{ recoveryHint: 'heap-oom-respawn' },
);
}
process.exitCode = e.status ?? 1;
}
return true;
@@ -184,6 +251,7 @@ export const shouldGenerateCommunitySkillFiles = (
export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOptions) => {
if (ensureHeap()) return;
forceHeapOOMForTestIfEnabled();
// Install fatal handlers immediately after re-exec resolution so any
// async error that escapes the try/catch below (#1169) surfaces with
@@ -638,6 +706,20 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
return;
}
// WAL corruption — the index file is unreadable. Give a clear recovery
// path without a confusing stack trace (the native error message alone
// is enough signal).
if (isWalCorruptionError(err) || msg.includes('LadybugDB WAL corruption')) {
cliError(
` The GitNexus index has a corrupted WAL file.\n` +
` This usually happens when a previous analysis was interrupted mid-write.\n` +
` ${WAL_RECOVERY_SUGGESTION}\n`,
{ recoveryHint: 'wal-corruption' },
);
process.exitCode = 1;
return;
}
// HF download failure — show clean guidance without the raw stack trace.
// Checked before writeFatalToStderr so the user sees one focused message
// rather than a stack-trace dump followed by a second remediation block.
+6 -1
View File
@@ -81,6 +81,7 @@ program
' GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n' +
' GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n' +
' GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n' +
' GITNEXUS_ALLOW_CPP_WORKERS=1 Force worker threads even when C/C++ files are detected.\n' +
' GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n' +
' GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n' +
'\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n' +
@@ -161,11 +162,15 @@ program
)
.option('--no-reasoning-model', 'Disable reasoning model mode (overrides saved config)')
.option('--concurrency <n>', 'Parallel LLM calls (default: 3)', '3')
.option('--timeout <seconds>', 'Per-attempt LLM request timeout in seconds (default: 60)')
.option('--timeout <seconds>', 'LLM request timeout in seconds (default: disabled)')
.option('--retries <n>', 'Max LLM retry attempts per request (default: 3)')
.option('--gist', 'Publish wiki as a public GitHub Gist after generation')
.option('-v, --verbose', 'Enable verbose output (show LLM commands and responses)')
.option('--review', 'Stop after grouping to review module structure before generating pages')
.option(
'--lang <lang>',
'Output language for generated documentation (e.g. english, chinese, spanish, japanese)',
)
.action(createLazyAction(() => import('./wiki.js'), 'wikiCommand'));
program
+8 -1
View File
@@ -1,6 +1,7 @@
import { createServer } from '../server/api.js';
import { logger, flushLoggerSync } from '../core/logger.js';
import { cliError } from './cli-message.js';
import { isWalCorruptionError, WAL_RECOVERY_SUGGESTION } from '../core/lbug/lbug-config.js';
// Catch anything that would cause a silent exit. Pino v10's default
// destination is `sync: false` (SonicBoom buffered) — call
@@ -34,7 +35,13 @@ export const serveCommand = async (options?: { port?: string; host?: string }) =
try {
await createServer(port, host);
} catch (err: any) {
if (err.code === 'EADDRINUSE') {
if (isWalCorruptionError(err)) {
cliError(
`\nGitNexus server could not start: the index has a corrupted WAL file.\n` +
` ${WAL_RECOVERY_SUGGESTION}\n`,
{ recoveryHint: 'wal-corruption' },
);
} else if (err.code === 'EADDRINUSE') {
cliError(
`\nFailed to start GitNexus server:\n` +
` ${err.message || err}\n\n` +
+36 -6
View File
@@ -35,6 +35,24 @@ export interface WikiCommandOptions {
review?: boolean;
timeout?: string;
retries?: string;
lang?: string;
}
function parsePositiveIntegerOption(
value: string | undefined,
flag: string,
multiplier = 1,
): number | undefined {
if (value === undefined) return undefined;
const trimmed = value.trim();
if (!/^[1-9]\d*$/.test(trimmed)) {
throw new Error(`${flag} must be a positive integer`);
}
const parsed = parseInt(trimmed, 10);
if (parsed > Math.floor(Number.MAX_SAFE_INTEGER / multiplier)) {
throw new Error(`${flag} is too large`);
}
return parsed;
}
/**
@@ -127,6 +145,17 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio
return;
}
let timeoutSeconds: number | undefined;
let retries: number | undefined;
try {
timeoutSeconds = parsePositiveIntegerOption(options?.timeout, '--timeout', 1000);
retries = parsePositiveIntegerOption(options?.retries, '--retries');
} catch (error) {
console.log(` Error: ${(error as Error).message}\n`);
process.exitCode = 1;
return;
}
// ── Resolve LLM config (with interactive fallback) ─────────────────
// Save any CLI overrides immediately
if (
@@ -350,13 +379,11 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio
}
// ── Apply per-run overrides not saved to config ────────────────────
if (options?.timeout) {
const secs = parseInt(options.timeout, 10);
if (!isNaN(secs) && secs > 0) llmConfig.requestTimeoutMs = secs * 1000;
if (timeoutSeconds !== undefined) {
llmConfig.requestTimeoutMs = timeoutSeconds * 1000;
}
if (options?.retries) {
const n = parseInt(options.retries, 10);
if (!isNaN(n) && n > 0) llmConfig.maxAttempts = n;
if (retries !== undefined) {
llmConfig.maxAttempts = retries;
}
// ── Setup progress bar with elapsed timer ──────────────────────────
@@ -395,6 +422,7 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio
force: options?.force,
concurrency: options?.concurrency ? parseInt(options.concurrency, 10) : undefined,
reviewOnly: options?.review,
lang: options?.lang,
};
const generator = new WikiGenerator(
@@ -563,6 +591,8 @@ export const wikiCommand = async (inputPath?: string, options?: WikiCommandOptio
if (err.message?.includes('No source files')) {
console.log(`\n ${err.message}\n`);
} else if (err.message?.includes('LLM request timed out after')) {
console.log(`\n Timeout: ${err.message}\n`);
} else if (err.message?.includes('content filter')) {
// Content filter block — actionable message
console.log(`\n Content Filter: ${err.message}\n`);
+14 -1
View File
@@ -766,6 +766,15 @@ export const processCalls = async (
importedRawReturnTypesMap?: ReadonlyMap<string, ReadonlyMap<string, string>>,
heritageMap?: HeritageMap,
bindingAccumulator?: BindingAccumulator,
/**
* Optional cache for compiled `Parser.Query` objects keyed by language name.
* When provided, compiled queries are reused across calls instead of being
* re-compiled from the query string for every file. Callers that invoke
* `processCalls` many times with single-file batches (e.g. the cross-file
* propagation phase) should pass a long-lived map here to avoid O(N)
* query recompilation overhead.
*/
compiledQueryCache?: Map<SupportedLanguages, Parser.Query>,
): Promise<ExtractedHeritage[]> => {
const parser = await loadParser();
const collectedHeritage: ExtractedHeritage[] = [];
@@ -843,7 +852,11 @@ export const processCalls = async (
let matches;
try {
const lang = parser.getLanguage();
const query = new Parser.Query(lang, queryStr);
let query = compiledQueryCache?.get(language);
if (!query) {
query = new Parser.Query(lang, queryStr);
compiledQueryCache?.set(language, query);
}
matches = query.matches(tree.rootNode);
} catch (queryError) {
logger.warn({ queryError }, `Query error for ${file.path}:`);
@@ -210,6 +210,37 @@ interface LanguageProviderConfig {
ancestorNode: SyntaxNode,
) => { funcName: string; label: NodeLabel } | null;
// ── Template constraint extraction (SFINAE / `requires`) ────────────
/**
* Extract a per-language template-constraint payload for a templated
* function / method definition. Used by `parsing-processor` to
* disambiguate same-name same-arity overloads whose distinguishing
* signal is their template constraints rather than their parameter
* types — the canonical C++ SFINAE case (issue #1579):
*
* template<class T, std::enable_if_t<is_integral_v<T>, int> = 0>
* void process(T); // overload A
*
* template<class T, std::enable_if_t<is_floating_point_v<T>, int> = 0>
* void process(T); // overload B
*
* Both overloads' `parameterTypes` collapse to `['T']`, so without a
* constraint fingerprint in the graph node ID they merge into one
* Function node and the resolver only ever sees one candidate to
* narrow. The hook's return value is stamped onto the node's ID via
* `templateConstraintsIdTag()` AND stored on the node's
* `templateConstraints` property so `resolveDefGraphId` can look up
* the right overload by re-hashing the def's constraints at resolve
* time.
*
* Returns the opaque payload (any JSON-serializable shape — the
* producing adapter owns it; shared code MUST NOT inspect) or
* `undefined` when no constraints exist / the node isn't a templated
* function. Languages without SFINAE / concept semantics leave this
* undefined and the disambiguation is a pass-through.
*/
readonly extractTemplateConstraints?: (definitionNode: SyntaxNode) => unknown;
// ── Labels ────────────────────────────────────────────────────────
/** Override the default node label for definition.function captures.
* Return null to skip (C/C++ duplicate), a different label to reclassify
+54 -2
View File
@@ -64,6 +64,7 @@ import {
cppImportOwningScope,
cppReceiverBinding,
} from './cpp/index.js';
import { extractCppTemplateConstraints } from './cpp/constraint-extractor.js';
const C_BUILT_INS: ReadonlySet<string> = new Set([
'printf',
@@ -312,12 +313,19 @@ const cCppExtractFunctionName = (
return { funcName, label };
};
/** Check if a C/C++ function_definition is inside a class or struct body.
/** Check if a C/C++ function_definition is inside a class or struct body
* (and NOT a friend declaration).
* Used by cppLabelOverride to skip duplicate function captures
* that are already covered by definition.method queries. */
* that are already covered by definition.method queries.
* Friend functions are free functions defined inside class bodies —
* they must NOT be skipped (ISO C++ hidden-friend idiom). */
function isCppInsideClassOrStruct(functionNode: SyntaxNode): boolean {
let ancestor: SyntaxNode | null = functionNode?.parent ?? null;
while (ancestor) {
// Friend declarations: the function_definition is wrapped in
// `friend_declaration` → `field_declaration_list` → class_specifier.
// These are free functions, not methods — don't skip them.
if (ancestor.type === 'friend_declaration') return false;
if (ancestor.type === 'class_specifier' || ancestor.type === 'struct_specifier') return true;
ancestor = ancestor.parent;
}
@@ -456,6 +464,7 @@ export const cppProvider = defineLanguage({
heritageExtractor: createHeritageExtractor(SupportedLanguages.CPlusPlus),
labelOverride: cppLabelOverride,
builtInNames: C_BUILT_INS,
extractTemplateConstraints: extractCppTemplateConstraintsForProvider,
// ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ──────────
emitScopeCaptures: emitCppScopeCaptures,
@@ -467,3 +476,46 @@ export const cppProvider = defineLanguage({
arityCompatibility: cppArityCompatibility,
// mergeBindings + resolveImportTarget live on ScopeResolver (see cpp/scope-resolver.ts).
});
/**
* LanguageProvider hook: walk from a function definition node up to its
* enclosing `template_declaration` and extract the SFINAE / `requires`-
* clause constraint payload. Used by `parsing-processor` to fingerprint
* the graph node ID so two SFINAE overloads with identical
* `parameterTypes` get distinct nodes (issue #1579).
*
* Returns `undefined` for non-templated functions and for templated
* functions whose constraints the extractor can't model — both cases
* result in no constraint suffix on the node ID.
*/
function extractCppTemplateConstraintsForProvider(definitionNode: SyntaxNode): unknown {
// Walk up to the enclosing template_declaration. Bound the walk so we
// can't accidentally land on a far-ancestor template_declaration that
// wraps an unrelated function.
let cur: SyntaxNode | null = definitionNode.parent;
let hops = 8;
let templateDecl: SyntaxNode | null = null;
while (cur !== null && hops-- > 0) {
if (cur.type === 'template_declaration') {
templateDecl = cur;
break;
}
if (cur.type === 'translation_unit') break;
cur = cur.parent;
}
if (templateDecl === null) return undefined;
// Find the function_declarator inside the function definition so the
// extractor can map template params to function-argument indices.
let declarator: SyntaxNode | null = definitionNode.childForFieldName('declarator');
let walk = 8;
while (declarator !== null && walk-- > 0) {
if (declarator.type === 'function_declarator') break;
if (declarator.type === 'pointer_declarator' || declarator.type === 'reference_declarator') {
declarator = declarator.childForFieldName('declarator');
continue;
}
break;
}
return extractCppTemplateConstraints(templateDecl, declarator);
}
+272 -62
View File
@@ -15,19 +15,34 @@
*
* ## Current boundary
*
* The current implementation covers ONE associated-entity rule: an argument that's a directly-named
* class type (`audit::Event e`) contributes its **direct enclosing
* namespace** to the candidate set. V2 extends that one step to
* pointer-typed and reference-typed class args (`audit::Event* p`,
* `audit::Event& r`, `audit::Event&& rr`): they contribute the pointee /
* referred class's enclosing namespace too. Function-pointer arguments,
* template specializations, base-class associated namespaces, and the
* rest of the full closure are still deliberately excluded.
* The current implementation covers class-typed arguments (value, pointer,
* and reference) and template specializations with explicit type arguments:
* - `audit::Event e`, `audit::Event* p`, `audit::Event** pp`
* - `audit::Event& r`, `audit::Event&& rr`
* - `std::vector<audit::Event>` (template namespace + template-arg namespaces)
*
* The current implementation also short-circuits to ADL only when ordinary lookup is empty
* (`findCallableBindingInScope` returned undefined). ISO C++ would
* normally merge ADL candidates with ordinary-lookup candidates and
* run overload resolution over the union; V1 defers that merge to V2.
* V2 additionally walks class ancestors (via MRO), so base-class enclosing
* namespaces also contribute associated namespaces.
*
* **GitNexus approximation (not strict ISO C++ ADL):** passing a qualified
* function reference like `utils::worker` contributes `utils` to the associated
* set, enabling resolution of unqualified calls like `with_callback(utils::worker)`
* to `utils::with_callback`. Under ISO C++ `[basic.lookup.argdep]`, associated
* entities for function-type arguments come from the **parameter types and return
* type** of each function in the overload set — NOT the function's enclosing
* namespace. For `void worker()`, the standard-compliant associated set is empty.
* GitNexus instead contributes the enclosing namespace of any Function/Method
* def whose simple name matches, because it enables the dominant real-world ADL
* pattern at reasonable precision cost.
*
* For qualified refs (e.g. `utils::worker`) the namespace is confirmed via a
* workspace lookup (only contributed when a Function/Method named `worker` exists
* in `utils`). For unqualified refs the workspace is searched for any Function
* def with that simple name. Locally-declared function-pointer variables
* (e.g. `void (*g)()`) and function parameters are excluded from this path.
*
* ADL candidates are merged with ordinary unqualified-lookup candidates
* in the free-call fallback before overload narrowing.
*
* ## Parenthesized-name suppression
*
@@ -55,33 +70,45 @@
import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import {
isOverloadAmbiguousAfterNormalization,
narrowOverloadCandidates,
} from '../../scope-resolution/passes/overload-narrowing.js';
import { isCppInlineNamespaceScope } from './inline-namespaces.js';
/**
* Per-argument shape information collected at capture time. ADL fires for
* arguments where `simpleClassName !== ''`, including class pointers and
* references whose declarator chain resolves to a named class type.
* Free-function reference arguments use `functionRefText`.
*/
export interface CppAdlArgInfo {
/** Simple class-like type name (last segment of qualified name); empty
* for primitives, literals, function pointers, template specs, etc. */
* for primitives, literals, function pointers, etc. */
readonly simpleClassName: string;
/** Template's own simple class-like name (e.g. `vector` for
* `std::vector<N::T>`), empty when arg type is not a template spec. */
readonly templateSimpleClassName: string;
/** Template's own enclosing namespace (dot-qualified, e.g. `std`), empty
* when unavailable / unqualified. */
readonly templateNamespace: string;
/** Class-like names extracted from explicit type template arguments,
* recursively bounded. */
readonly templateArgClassNames: readonly string[];
/** Enclosing namespaces extracted from explicit type template arguments,
* recursively bounded. */
readonly templateArgNamespaces: readonly string[];
/** When set, the arg is a potential free-function reference (not a locally-
* declared function-pointer variable or function parameter). Contains the
* identifier text as written in source (e.g. `"utils::worker"` or
* `"worker"`). GitNexus approximation: the function's enclosing namespace
* is contributed to the ADL associated set. For qualified refs a workspace
* lookup confirms a Function/Method with that simple name exists in the
* namespace before contributing; for unqualified refs every namespace
* containing a matching Function/Method def is contributed. */
readonly functionRefText?: string;
}
const argInfoBySite = new Map<string, readonly CppAdlArgInfo[]>();
const noAdlSites = new Set<string>();
const classToNamespaceQualifiedName = new Map<string, string>();
/** Sentinel returned by `pickCppAdlCandidates` when ADL surfaces multiple
* candidates that share normalized parameter types — the caller MUST
* suppress (zero edges) rather than pick arbitrarily. Mirrors the
* OVERLOAD_AMBIGUOUS contract from the receiver-bound path. */
export const ADL_AMBIGUOUS = Symbol('ADL_AMBIGUOUS');
export type AdlResult = SymbolDefinition | typeof ADL_AMBIGUOUS | undefined;
function siteKey(filePath: string, line: number, col: number): string {
return `${filePath}:${line}:${col}`;
}
@@ -134,16 +161,26 @@ export function populateCppAssociatedNamespaces(parsed: ParsedFile): void {
classToNamespaceQualifiedName.set(def.nodeId, nsQName);
}
}
// Enum defs live in Namespace scopes directly (not inside Class scopes).
// Map each Enum def to its enclosing namespace so ADL on enum-typed
// arguments contributes the correct associated namespace.
for (const scope of parsed.scopes) {
if (scope.kind !== 'Namespace') continue;
const nsQName = computeNamespaceQName(scope, scopesById);
if (nsQName === '') continue;
for (const def of scope.ownedDefs) {
if (def.type !== 'Enum') continue;
classToNamespaceQualifiedName.set(def.nodeId, nsQName);
}
}
}
/**
* V1 ADL candidate picker. Returns:
* - `SymbolDefinition` — exactly one ADL candidate (or unique survivor
* after narrowing); caller emits the CALLS edge.
* - `ADL_AMBIGUOUS` — multiple candidates with no disambiguator;
* caller MUST suppress (zero edges).
* - `undefined` — no ADL candidates; caller falls through to ordinary
* `pickUniqueGlobalCallable` fallback.
* ADL candidate collector. Returns:
* - `readonly SymbolDefinition[]` — ADL candidates to merge with
* ordinary unqualified lookup candidates.
* - `undefined` — no ADL candidates.
*
* Fires only when:
* - the call site is not in `noAdlSites` (parenthesized form), AND
@@ -153,33 +190,33 @@ export function populateCppAssociatedNamespaces(parsed: ParsedFile): void {
export function pickCppAdlCandidates(
site: {
readonly name: string;
readonly arity?: number;
readonly argumentTypes?: readonly string[];
readonly atRange: { startLine: number; startCol: number };
},
callerParsed: ParsedFile,
scopes: ScopeResolutionIndexes,
parsedFiles: readonly ParsedFile[],
): AdlResult {
): readonly SymbolDefinition[] | undefined {
const key = siteKey(callerParsed.filePath, site.atRange.startLine, site.atRange.startCol);
if (noAdlSites.has(key)) return undefined;
const args = argInfoBySite.get(key);
if (args === undefined || args.length === 0) return undefined;
// Collect associated namespace QNames from every participating class-typed arg.
// Collect associated namespace QNames from every participating class-typed arg
// and from function-reference args.
const associatedNamespaces = new Set<string>();
for (const arg of args) {
if (arg.simpleClassName === '') continue;
const classDef = findCppClassDefBySimpleName(arg.simpleClassName, scopes);
if (classDef === undefined) continue;
const nsQName = classToNamespaceQualifiedName.get(classDef.nodeId);
if (nsQName !== undefined) associatedNamespaces.add(nsQName);
collectAssociatedNamespacesForAdlArg(arg, scopes, associatedNamespaces);
if (arg.functionRefText !== undefined) {
collectFunctionRefNamespaces(arg.functionRefText, parsedFiles, associatedNamespaces);
}
}
if (associatedNamespaces.size === 0) return undefined;
// Walk every namespace scope in every parsed file; collect callable
// ownedDefs whose enclosing namespace matches one of the associated
// QNames AND whose simple name matches the call's name.
// ISO C++: inline namespaces are transparent — candidates in inline
// children of an associated namespace are also ADL-reachable.
const candidates: SymbolDefinition[] = [];
const seenKey = new Set<string>();
for (const parsed of parsedFiles) {
@@ -188,7 +225,17 @@ export function pickCppAdlCandidates(
for (const scope of parsed.scopes) {
if (scope.kind !== 'Namespace') continue;
const qName = computeNamespaceQName(scope, scopesById);
if (!associatedNamespaces.has(qName)) continue;
if (!associatedNamespaces.has(qName)) {
// Check if this is an inline-namespace child of an associated NS.
// ISO C++ inline namespaces are transparent for ADL: if the outer
// namespace is in the associated set, candidates in the inline child
// are also reachable.
if (!isCppInlineNamespaceScope(scope.id)) continue;
const parentScope = scope.parent !== null ? scopesById.get(scope.parent) : undefined;
if (parentScope === undefined || parentScope.kind !== 'Namespace') continue;
const parentQName = computeNamespaceQName(parentScope, scopesById);
if (!associatedNamespaces.has(parentQName)) continue;
}
for (const def of scope.ownedDefs) {
if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') {
continue;
@@ -204,22 +251,100 @@ export function pickCppAdlCandidates(
candidates.push(def);
}
}
// ISO C++ `[basic.lookup.argdep]` §2: hidden friend functions declared
// inside a class body are visible via ADL when the class is an associated
// class. Scan Class scopes whose enclosing namespace is in the associated
// set for callable ownedDefs matching the call name. This enables the
// canonical "hidden friend" idiom:
// struct Foo { friend void swap(Foo&, Foo&) {} };
for (const scope of parsed.scopes) {
if (scope.kind !== 'Class') continue;
// Check if ANY class def in this scope has an associated namespace.
let isAssociatedClass = false;
for (const def of scope.ownedDefs) {
if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue;
const nsQName = classToNamespaceQualifiedName.get(def.nodeId);
if (nsQName !== undefined && associatedNamespaces.has(nsQName)) {
isAssociatedClass = true;
break;
}
}
if (!isAssociatedClass) continue;
// Also scan Function scopes that are direct children of this class
// scope — friend function definitions create their own Function scope
// underneath the Class scope.
for (const childScope of parsed.scopes) {
if (childScope.parent !== scope.id) continue;
if (childScope.kind !== 'Function') continue;
for (const def of childScope.ownedDefs) {
if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') {
continue;
}
const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
if (simple !== site.name) continue;
if (seenKey.has(def.nodeId)) continue;
seenKey.add(def.nodeId);
candidates.push(def);
}
}
for (const def of scope.ownedDefs) {
if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') {
continue;
}
const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
if (simple !== site.name) continue;
if (seenKey.has(def.nodeId)) continue;
seenKey.add(def.nodeId);
candidates.push(def);
}
}
}
if (candidates.length === 0) return undefined;
if (candidates.length === 1) return candidates[0];
return candidates;
}
// Multi-candidate: narrow then check ambiguity. Reuses the OVERLOAD_AMBIGUOUS
// sentinel contract from `overload-narrowing.ts` so int/long-collision-style
// ambiguity also suppresses on the ADL path.
const narrowed = narrowOverloadCandidates(candidates, site.arity, site.argumentTypes);
if (narrowed.length === 1) return narrowed[0];
if (narrowed.length === 0) return undefined;
if (isOverloadAmbiguousAfterNormalization(narrowed, site.arity)) return ADL_AMBIGUOUS;
// Multiple surviving candidates that aren't normalization-ambiguous —
// ISO C++ would run overload resolution; V1 lacks conversion ranking so
// suppress rather than pick arbitrarily. Mirrors `pickImplicitThisOverload`'s
// unique-survivor requirement (see `pick-implicit-this-overload.test.ts`).
return ADL_AMBIGUOUS;
function collectAssociatedNamespacesForAdlArg(
arg: CppAdlArgInfo,
scopes: ScopeResolutionIndexes,
associatedNamespaces: Set<string>,
): void {
// For template args this may be the template name itself (e.g. `vector`);
// simple-name lookup can match project classes with the same name (known
// V1/V2 simplification).
addAssociatedNamespaceForClassName(arg.simpleClassName, scopes, associatedNamespaces);
// Includes template-owner namespaces (e.g. `std` in std::vector<T>). If
// that surfaces extra candidates, merged-candidate overload narrowing in
// free-call-fallback suppresses arbitrary edge emission.
if (arg.templateNamespace.length > 0) associatedNamespaces.add(arg.templateNamespace);
for (const ns of arg.templateArgNamespaces) {
if (ns.length > 0) associatedNamespaces.add(ns);
}
for (const className of arg.templateArgClassNames) {
addAssociatedNamespaceForClassName(className, scopes, associatedNamespaces);
}
}
function addAssociatedNamespaceForClassName(
simpleClassName: string,
scopes: ScopeResolutionIndexes,
associatedNamespaces: Set<string>,
): void {
if (simpleClassName.length === 0) return;
const classLookup = findCppClassDefBySimpleName(simpleClassName, scopes);
if (classLookup === undefined) return;
const { classDef, ambiguous } = classLookup;
const nsQName = classToNamespaceQualifiedName.get(classDef.nodeId);
if (nsQName !== undefined) associatedNamespaces.add(nsQName);
// Preserve V1 collision behavior for the direct class namespace, but avoid
// amplifying a same-simple-name collision by walking an arbitrary class's
// full MRO chain.
if (ambiguous) return;
for (const ancestorDefId of scopes.methodDispatch.mroFor(classDef.nodeId)) {
const ancestorNsQName = classToNamespaceQualifiedName.get(ancestorDefId);
if (ancestorNsQName !== undefined) associatedNamespaces.add(ancestorNsQName);
}
}
/** Walk upward from a Class scope, finding the innermost enclosing
@@ -313,18 +438,103 @@ function findNamespaceDefInScope(scope: {
return undefined;
}
/** Find a class-like def by simple name across the workspace. V1
* arbitrary-pick on collisions (multiple classes share the simple name);
* C++ ADL strictness would require full type-driven lookup, but V1
* trades that for simplicity. */
/** Find a class-like or enum def by simple name across the workspace.
* V1 still arbitrary-picks the first match on collisions (multiple defs
* share the simple name), but reports the collision so callers can avoid
* amplifying that uncertainty (for example by skipping MRO expansion).
* C++ ADL strictness would require full type-driven lookup.
*
* ISO C++ `[basic.lookup.argdep]` §2: enumerations contribute their
* enclosing namespace to the associated set, just like class types. */
function findCppClassDefBySimpleName(
simpleName: string,
scopes: ScopeResolutionIndexes,
): SymbolDefinition | undefined {
): { classDef: SymbolDefinition; ambiguous: boolean } | undefined {
let firstMatch: SymbolDefinition | undefined;
for (const def of scopes.defs.byId.values()) {
if (def.type !== 'Class' && def.type !== 'Struct' && def.type !== 'Interface') continue;
if (
def.type !== 'Class' &&
def.type !== 'Struct' &&
def.type !== 'Interface' &&
def.type !== 'Enum'
)
continue;
const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
if (simple === simpleName) return def;
if (simple !== simpleName) continue;
if (firstMatch === undefined) {
firstMatch = def;
continue;
}
return { classDef: firstMatch, ambiguous: true };
}
if (firstMatch === undefined) return undefined;
return { classDef: firstMatch, ambiguous: false };
}
/**
* Contribute associated namespaces for a function-reference argument.
*
* - **Qualified refs** (`utils::worker`, `outer::inner::fn`): the namespace
* is extracted from the qualifier text (converting `::` to `.` for dot-joined
* QName matching). A workspace lookup then **verifies** that a Function or
* Method def named `worker` (the simple name after the last `::`) actually
* exists in the extracted namespace. This prevents false positives from
* namespace-qualified variables, enum values, and static data members, which
* also produce `qualified_identifier` AST nodes in tree-sitter-cpp (the
* AST node type alone does not distinguish functions from non-function names).
* - **Unqualified refs** (`worker`): the workspace is searched for any
* Function/Method def whose simple name matches. Every distinct enclosing
* namespace found is added — overloads across the same namespace produce
* a single entry; GitNexus does not select a specific overload at this stage.
*/
function collectFunctionRefNamespaces(
refText: string,
parsedFiles: readonly ParsedFile[],
out: Set<string>,
): void {
const colonIdx = refText.lastIndexOf('::');
if (colonIdx !== -1) {
// Qualified ref: extract namespace prefix and normalise :: → dot notation.
const nsText = refText.slice(0, colonIdx).replace(/::/g, '.');
if (nsText === '') return;
const simpleName = refText.slice(colonIdx + 2);
// Verify that a Function/Method named `simpleName` exists in `nsText`.
// Without this guard every `a::b` qualified_identifier arg (variable,
// enum value, static member, type alias) would blindly contribute `a`
// to the associated set and risk a false-positive CALLS edge.
for (const parsed of parsedFiles) {
const scopesById = new Map<ScopeId, (typeof parsed.scopes)[number]>();
for (const sc of parsed.scopes) scopesById.set(sc.id, sc);
for (const scope of parsed.scopes) {
if (scope.kind !== 'Namespace') continue;
if (computeNamespaceQName(scope, scopesById) !== nsText) continue;
for (const def of scope.ownedDefs) {
if (def.type !== 'Function' && def.type !== 'Method') continue;
const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
if (simple === simpleName) {
out.add(nsText);
return; // Namespace confirmed; no need to scan further files.
}
}
}
}
return;
}
// Unqualified: search all namespace scopes for a Function def with this
// simple name and contribute its enclosing namespace.
for (const parsed of parsedFiles) {
const scopesById = new Map<ScopeId, (typeof parsed.scopes)[number]>();
for (const sc of parsed.scopes) scopesById.set(sc.id, sc);
for (const scope of parsed.scopes) {
if (scope.kind !== 'Namespace') continue;
for (const def of scope.ownedDefs) {
if (def.type !== 'Function' && def.type !== 'Method') continue;
const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
if (simple !== refText) continue;
const nsQName = computeNamespaceQName(scope, scopesById);
if (nsQName !== '') out.add(nsQName);
}
}
}
return undefined;
}
@@ -1,9 +1,11 @@
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import type { ParameterTypeClass } from 'gitnexus-shared';
export interface CppArityInfo {
parameterCount?: number;
requiredParameterCount?: number;
parameterTypes?: string[];
parameterTypeClasses?: ParameterTypeClass[];
}
/**
@@ -73,26 +75,35 @@ export function computeCppDeclarationArity(node: SyntaxNode): CppArityInfo {
const totalNonVariadic = requiredCount + optionalCount;
const types: string[] = [];
const typeClasses: ParameterTypeClass[] = [];
for (const p of params) {
if (p.type === 'variadic_parameter') {
types.push('...');
typeClasses.push(unknownTypeClass('...'));
} else if (p.type === 'variadic_parameter_declaration') {
// Parameter pack: treated as variadic
types.push('...');
typeClasses.push(unknownTypeClass('...'));
} else {
const typeNode = p.childForFieldName('type');
types.push(normalizeCppParamType(typeNode?.text ?? 'unknown'));
const rawType = typeNode?.text ?? 'unknown';
types.push(normalizeCppParamType(rawType));
typeClasses.push(
classifyCppParameterType(rawType, p.childForFieldName('declarator')?.text, p.text),
);
}
}
// Append '...' for C-style variadic if not already in types
if (hasEllipsis && !types.includes('...')) {
types.push('...');
typeClasses.push(unknownTypeClass('...'));
}
return {
parameterCount: isVariadic ? undefined : totalNonVariadic,
requiredParameterCount: requiredCount,
parameterTypes: types,
parameterTypeClasses: typeClasses,
};
}
@@ -120,8 +131,14 @@ export function computeCppCallArity(node: SyntaxNode): number {
* so that `narrowOverloadCandidates` can match against literal-inferred
* argument types (e.g. `inferCppLiteralType` returns `'string'` for
* string literals, not `'std::string'`).
*
* This intentionally remains coarse and graph-ID-stable: cv-qualifiers,
* reference markers, and pointer markers are stripped here. C++ callers
* that need those distinctions should read `parameterTypeClasses`, which
* is an additive sidecar and does not participate in overload node ID
* hashing.
*/
function normalizeCppParamType(raw: string): string {
export function normalizeCppParamType(raw: string): string {
let t = raw.trim();
// Strip const, volatile, etc.
t = t.replace(/\b(const|volatile|restrict|mutable|constexpr)\b/g, '').trim();
@@ -158,6 +175,52 @@ function normalizeCppParamType(raw: string): string {
return STD_MAP[t] ?? t;
}
export function classifyCppParameterType(
rawType: string,
declaratorText?: string,
fullParameterText?: string,
): ParameterTypeClass {
const source = fullParameterText ?? `${rawType} ${declaratorText ?? ''}`.trim();
if (rawType === 'unknown') return unknownTypeClass('unknown');
const hasConst = /\bconst\b/.test(source);
const hasVolatile = /\bvolatile\b/.test(source);
const cv: ParameterTypeClass['cv'] =
hasConst && hasVolatile
? 'const volatile'
: hasConst
? 'const'
: hasVolatile
? 'volatile'
: 'none';
const pointerDepth = (source.match(/\*/g) ?? []).length;
const indirection: ParameterTypeClass['indirection'] =
pointerDepth > 0
? 'pointer'
: /&&/.test(source)
? 'rvalue-ref'
: /&/.test(source)
? 'lvalue-ref'
: 'value';
return {
base: normalizeCppParamType(rawType),
cv,
indirection,
pointerDepth,
};
}
function unknownTypeClass(base: string): ParameterTypeClass {
return {
base,
cv: 'unknown',
indirection: 'unknown',
pointerDepth: 0,
};
}
function findFuncDeclarator(node: SyntaxNode): SyntaxNode | null {
let decl = node.childForFieldName('declarator');
if (decl === null) {
@@ -8,7 +8,10 @@ import type { Callsite, SymbolDefinition } from 'gitnexus-shared';
* - Default parameters (requiredParameterCount < parameterCount)
* - Variadic functions (C-style `...`)
* - Parameter packs (V1: treated as variadic)
* - Templates (V1: generic-ignored, arity check on non-template params)
* - Templates: arity check on non-template params; SFINAE / `requires`
* constraints are filtered separately via `constraintCompatibility`
* (see `constraint-filter.ts` and issue #1579). Type-argument generic
* substitution (`List<T>` ≡ `List<U>`) remains out of V1 scope.
*
* Verdict:
* - 'compatible': callsite.arity fits within [required, total] range
@@ -10,10 +10,11 @@ import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
import { splitCppInclude, splitCppUsingDecl } from './import-decomposer.js';
import { computeCppDeclarationArity, computeCppCallArity } from './arity-metadata.js';
import { markFileLocal } from './file-local-linkage.js';
import { markCppAnonymousNamespaceRange, markFileLocal } from './file-local-linkage.js';
import { markCppDependentBase } from './two-phase-lookup.js';
import { markCppAdlSiteArgs, markCppAdlSiteNoAdl, type CppAdlArgInfo } from './adl.js';
import { markCppInlineNamespaceRange } from './inline-namespaces.js';
import { extractCppTemplateConstraints } from './constraint-extractor.js';
export function emitCppScopeCaptures(
sourceText: string,
@@ -114,6 +115,13 @@ export function emitCppScopeCaptures(
JSON.stringify(arity.parameterTypes),
);
}
if (arity.parameterTypeClasses !== undefined) {
grouped['@declaration.parameter-type-classes'] = syntheticCapture(
'@declaration.parameter-type-classes',
fnNode,
JSON.stringify(arity.parameterTypeClasses),
);
}
// Detect static storage class (file-local linkage)
if (hasStaticStorageClass(fnNode)) {
@@ -130,6 +138,24 @@ export function emitCppScopeCaptures(
markFileLocal(filePath, nameText);
}
}
// SFINAE / `requires`-clause aware constraints for overload
// narrowing (issue #1579). Walk from the enclosing
// `template_declaration` — not the inner `function_definition` —
// so inline method templates (`template<...> class C { template<...> void f(); }`)
// pick up the correct outer constraint scope.
const templateDecl = findEnclosingTemplateDeclaration(fnNode);
if (templateDecl !== null) {
const funcDeclarator = findFunctionDeclarator(fnNode);
const constraints = extractCppTemplateConstraints(templateDecl, funcDeclarator);
if (constraints !== undefined) {
grouped['@declaration.template-constraints'] = syntheticCapture(
'@declaration.template-constraints',
fnNode,
JSON.stringify(constraints),
);
}
}
}
}
@@ -200,20 +226,37 @@ export function emitCppScopeCaptures(
// namespace's source range so `populateCppInlineNamespaceScopes`
// (during populateOwners) can match it back to the corresponding
// Namespace scope.
if (grouped['@declaration.namespace'] !== undefined) {
const anchor = grouped['@declaration.namespace']!;
const nsNode = findNodeAtRange(tree.rootNode, anchor.range, 'namespace_definition');
if (nsNode !== null && isInlineNamespace(nsNode)) {
// `@declaration.namespace` fires only for NAMED namespaces (the query
// requires a `name: (namespace_identifier)` child). Use the unconditional
// `@scope.namespace` capture so the anonymous-namespace branch also runs.
const namespaceScopeAnchor = grouped['@declaration.namespace'] ?? grouped['@scope.namespace'];
if (namespaceScopeAnchor !== undefined) {
const nsNode = findNodeAtRange(
tree.rootNode,
namespaceScopeAnchor.range,
'namespace_definition',
);
if (nsNode !== null) {
// Range coords stored in the shared Range shape use 1-based
// line numbers (see `ast-helpers.ts` rangeForNode where
// `startPosition.row + 1` is applied). Match that convention so
// `populateCppInlineNamespaceScopes` can join against `Scope.range`.
markCppInlineNamespaceRange(filePath, {
// the populators can join against `Scope.range`.
const nsRange = {
startLine: nsNode.startPosition.row + 1,
startCol: nsNode.startPosition.column,
endLine: nsNode.endPosition.row + 1,
endCol: nsNode.endPosition.column,
});
};
if (isInlineNamespace(nsNode)) {
markCppInlineNamespaceRange(filePath, nsRange);
}
// Anonymous namespace: `namespace_definition` with no `name` field.
// Recorded so `expandCppWildcardNames` can propagate its members
// to including TUs even though their names are also `markFileLocal`'d
// (which blocks the global free-call fallback's cross-file path).
if ((nsNode.childForFieldName?.('name') ?? null) === null) {
markCppAnonymousNamespaceRange(filePath, nsRange);
}
}
}
@@ -535,6 +578,52 @@ function extractBaseLookupName(baseNode: SyntaxNode): string {
return '';
}
/**
* Walk parent chain from a function_definition / declaration / field_declaration
* to find the enclosing `template_declaration`. Returns null when the function
* isn't templated. The walk only ascends through wrapper nodes the C++
* grammar inserts between `template_declaration` and the function — direct
* parent in the common case, two hops for member templates whose outer
* class is also templated (we return the INNERMOST template_declaration,
* which carries this function's own template parameters).
*/
function findEnclosingTemplateDeclaration(fnNode: SyntaxNode): SyntaxNode | null {
let cur: SyntaxNode | null = fnNode.parent;
// Cap the walk — `template_declaration` is typically the immediate parent
// or one wrapper away. Anything deeper is an inline-method-in-template
// shape and we still want the innermost templates_declaration whose body
// wraps `fnNode`.
let hops = 8;
while (cur !== null && hops-- > 0) {
if (cur.type === 'template_declaration') return cur;
// Don't ascend past structural boundaries that should reset template scope.
if (cur.type === 'translation_unit') return null;
cur = cur.parent;
}
return null;
}
/**
* Locate the `function_declarator` AST node within a function definition
* or declaration. Unwraps pointer/reference declarator wrappers. Returns
* null when no function_declarator is found (e.g. variable declaration
* mis-classified upstream).
*/
function findFunctionDeclarator(fnNode: SyntaxNode): SyntaxNode | null {
const direct = fnNode.childForFieldName('declarator');
let cur: SyntaxNode | null = direct;
let hops = 8;
while (cur !== null && hops-- > 0) {
if (cur.type === 'function_declarator') return cur;
if (cur.type === 'pointer_declarator' || cur.type === 'reference_declarator') {
cur = cur.childForFieldName('declarator');
continue;
}
break;
}
return findFirstDescendantOfType(fnNode, 'function_declarator');
}
/** Find the first direct child matching one of the given types. */
function findChildOfType(node: SyntaxNode, types: readonly string[]): SyntaxNode | null {
for (let i = 0; i < node.childCount; i++) {
@@ -638,6 +727,15 @@ function inferCppLiteralType(node: SyntaxNode): string {
* - `int n = ...` → 'int'
* - `const int n = ...` → 'int'
* Returns empty string if no declaration found or type is auto/placeholder.
*
* Limitation: only `declaration` siblings inside the enclosing
* `compound_statement` are inspected. Function parameters live in the
* `function_declarator`'s `parameter_list` and are NOT resolved here, so
* `void run(int n) { process(n); }`
* infers `''` for `n` and the constraint filter falls through to
* `'unknown'` → ambiguity suppression → 0 CALLS edges. This is a
* "degrade not lie" gap (no wrong edges, just missing ones); extending
* the scan to `parameter_list` is tracked under #1579 as a follow-up.
*/
function lookupDeclaredTypeForIdentifier(identNode: SyntaxNode): string {
const varName = identNode.text;
@@ -720,12 +818,14 @@ function isParenthesizedFunctionCall(callNode: SyntaxNode): boolean {
/**
* Per-argument ADL classification: walk each argument of a free call and
* decide whether it resolves to a directly-named class or class-pointer
* type (ADL fires) or to an excluded shape such as a reference, function
* pointer, primitive, literal, or template specialization.
* classify its declared type for associated-namespace lookup.
*
* Class-typed values and class pointers (`N::S`, `N::S*`, `N::S**`) all
* preserve the pointee class name for associated-namespace lookup.
* Value/pointer/reference class-typed args and template specializations
* with explicit type arguments contribute; function pointers, primitives,
* literals, and other unsupported shapes produce an empty result.
*
* Class-typed values/pointers/references (`N::S`, `N::S*`, `N::S&`) all
* preserve the class name for associated-namespace lookup.
* Function pointers remain excluded even when their return type names a
* class, because the associated entity is the pointed-to function type,
* not the return type.
@@ -743,7 +843,14 @@ function inferCppCallAdlArgs(callNode: SyntaxNode): CppAdlArgInfo[] {
return out;
}
const EMPTY_ADL_ARG: CppAdlArgInfo = { simpleClassName: '' };
const ADL_TEMPLATE_RECURSION_MAX_DEPTH = 8;
const EMPTY_ADL_ARG: CppAdlArgInfo = {
simpleClassName: '',
templateSimpleClassName: '',
templateNamespace: '',
templateArgClassNames: [],
templateArgNamespaces: [],
};
function classifyAdlArg(argNode: SyntaxNode): CppAdlArgInfo {
// Literals and primitive-shaped expressions never have associated namespaces.
@@ -759,17 +866,91 @@ function classifyAdlArg(argNode: SyntaxNode): CppAdlArgInfo {
) {
return EMPTY_ADL_ARG;
}
// Qualified expression (a::b) — may be a function, variable, enum value,
// or static member. Record as a potential function reference; resolution
// time verifies via workspace lookup that a Function/Method with this simple
// name exists in the extracted namespace before contributing to the set.
if (argNode.type === 'qualified_identifier') {
return {
simpleClassName: '',
templateSimpleClassName: '',
templateNamespace: '',
templateArgClassNames: [],
templateArgNamespaces: [],
functionRefText: argNode.text,
};
}
// Variable reference — look up its declared type (preserving pointer /
// reference / qualified-name shape; the existing arity-narrowing helper
// strips this info).
if (argNode.type === 'identifier') {
return lookupAdlIdentifierType(argNode);
const result = lookupAdlIdentifierType(argNode);
if (result === null) {
// Not found in the local compound_statement scope — could be a
// free-function reference (unqualified name, namespace scope).
return {
simpleClassName: '',
templateSimpleClassName: '',
templateNamespace: '',
templateArgClassNames: [],
templateArgNamespaces: [],
functionRefText: argNode.text,
};
}
return result;
}
// Other shapes (calls, member access, operators) — V1 unsupported.
return EMPTY_ADL_ARG;
}
function lookupAdlIdentifierType(identNode: SyntaxNode): CppAdlArgInfo {
/**
* Returns `true` when `varName` appears as a parameter name in the nearest
* enclosing `function_definition` or `function_declarator` that contains
* `identNode`. Parameters live in `parameter_list` (a sibling of the
* `compound_statement`), so the `compound_statement`-local declaration scan
* in `lookupAdlIdentifierType` would not find them — causing them to be
* mistakenly classified as potential free-function references.
*
* In tree-sitter-cpp a `function_definition` does NOT expose `parameters`
* as a direct named field; parameters live inside the nested
* `function_declarator`. For `function_declarator` nodes the `parameters`
* field IS direct. Both cases are handled below.
*/
function isIdentifierAFunctionParameter(identNode: SyntaxNode, varName: string): boolean {
let node: SyntaxNode | null = identNode.parent;
let safety = 64;
while (node !== null && safety-- > 0) {
let params: SyntaxNode | null = null;
if (node.type === 'function_declarator') {
// parameters is a direct field on function_declarator.
params = node.childForFieldName('parameters');
} else if (node.type === 'function_definition') {
// function_definition carries parameters inside its `declarator` field
// (which is a function_declarator). Walk through it.
const decl = node.childForFieldName('declarator');
if (decl !== null && decl.type === 'function_declarator') {
params = decl.childForFieldName('parameters');
}
}
if (params !== null) {
for (let i = 0; i < params.namedChildCount; i++) {
const param = params.namedChild(i);
if (param === null) continue;
const declNode = param.childForFieldName('declarator');
if (declNode === null) continue;
const leafName = extractDeclaratorLeafName(declNode);
if (leafName === varName) return true;
}
// Only check the immediately enclosing function — do not climb further.
break;
}
if (node.type === 'translation_unit') break;
node = node.parent;
}
return false;
}
function lookupAdlIdentifierType(identNode: SyntaxNode): CppAdlArgInfo | null {
const varName = identNode.text;
let scope: SyntaxNode | null = identNode.parent;
while (
@@ -779,8 +960,17 @@ function lookupAdlIdentifierType(identNode: SyntaxNode): CppAdlArgInfo {
) {
scope = scope.parent;
}
if (scope === null) return EMPTY_ADL_ARG;
if (scope === null) return null;
// Function parameters live in the enclosing function's `parameter_list`,
// NOT inside the `compound_statement`, so the declaration scan below would
// never find them and would return `null` — incorrectly triggering the
// free-function-reference path. Check the parameter_list first.
if (isIdentifierAFunctionParameter(identNode, varName)) {
return EMPTY_ADL_ARG;
}
let foundAsLocalFunctionPointer = false;
for (let i = 0; i < scope.childCount; i++) {
const stmt = scope.child(i);
if (stmt === null || stmt.type !== 'declaration') continue;
@@ -807,6 +997,9 @@ function lookupAdlIdentifierType(identNode: SyntaxNode): CppAdlArgInfo {
if (inner.type === 'pointer_declarator') {
if (findFirstDescendantOfType(inner, 'function_declarator') !== null) {
isFunctionPointer = true;
// Extract the name from within the function-pointer declarator chain
// so `foundAsLocalFunctionPointer` can detect a matching declaration.
nameText = extractDeclaratorLeafName(inner);
break;
}
const next = inner.childForFieldName('declarator');
@@ -836,38 +1029,231 @@ function lookupAdlIdentifierType(identNode: SyntaxNode): CppAdlArgInfo {
}
if (inner.type === 'function_declarator') {
isFunctionPointer = true;
// Extract the name from the inner declarator (e.g. `(*g)` in `void (*g)()`).
const innerDecl = inner.childForFieldName('declarator');
if (innerDecl !== null) nameText = extractDeclaratorLeafName(innerDecl);
break;
}
// Reached the leaf — usually `identifier`. Take its text.
nameText = inner.text;
break;
}
if (nameText === varName && isFunctionPointer) {
// Explicitly declared as a function-pointer variable — must not be
// treated as a free-function reference by the caller.
foundAsLocalFunctionPointer = true;
continue;
}
if (isFunctionPointer || nameText !== varName) continue;
const simpleClassName = extractAdlSimpleTypeName(typeNode);
return { simpleClassName };
const {
templateSimpleClassName,
templateNamespace,
templateArgClassNames,
templateArgNamespaces,
} = extractAdlTemplateInfo(typeNode);
return {
simpleClassName,
templateSimpleClassName,
templateNamespace,
templateArgClassNames,
templateArgNamespaces,
};
}
return EMPTY_ADL_ARG;
// If the identifier was found in local scope as a function-pointer variable,
// return EMPTY_ADL_ARG so the caller does NOT treat it as a free-function
// reference. Otherwise return null to indicate "not in local scope".
//
// Known limitation (Finding 4): variables whose type is a typedef/using alias
// for a function-pointer type are NOT detected here. For example:
// using Callback = void (*)();
// Callback g;
// foo(g); // `g`'s declarator is `identifier` with type `Callback`
// The declarator has no `pointer_declarator` wrapper, so `isFunctionPointer`
// stays false and `extractAdlSimpleTypeName` returns `"Callback"`. ADL then
// looks for a class named `Callback`; if none exists, this degrades to
// EMPTY_ADL_ARG (class not found → no namespace contributed). If a class
// named `Callback` does exist, a spurious namespace contribution could occur.
// Risk is low in practice; a future fix should resolve the typedef/alias chain.
return foundAsLocalFunctionPointer ? EMPTY_ADL_ARG : null;
}
/** Extract the simple class-like type name from a `type:` field node.
* Returns '' for primitives, template specializations, and any other
* Returns '' for primitives and any other
* unsupported type-only shape. Function pointers are filtered at the
* declarator level in `lookupAdlIdentifierType`. */
function extractAdlSimpleTypeName(typeNode: SyntaxNode): string {
if (typeNode.type === 'type_descriptor') {
const innerType = typeNode.childForFieldName('type');
if (innerType !== null) return extractAdlSimpleTypeName(innerType);
for (let i = 0; i < typeNode.childCount; i++) {
const child = typeNode.child(i);
if (child === null) continue;
if (
child.type === 'type_identifier' ||
child.type === 'qualified_identifier' ||
child.type === 'template_type'
) {
return extractAdlSimpleTypeName(child);
}
}
return '';
}
if (typeNode.type === 'primitive_type') return '';
if (typeNode.type === 'sized_type_specifier') return '';
if (typeNode.type === 'type_identifier') return typeNode.text;
if (typeNode.type === 'template_type') {
const nameNode = typeNode.childForFieldName('name');
if (nameNode !== null) return extractAdlSimpleTypeName(nameNode);
const id = findFirstDescendantOfType(typeNode, 'type_identifier');
return id !== null ? id.text : '';
}
if (typeNode.type === 'qualified_identifier') {
const nameNode = typeNode.childForFieldName('name');
if (nameNode !== null) return extractAdlSimpleTypeName(nameNode);
const id = findFirstDescendantOfType(typeNode, 'type_identifier');
return id !== null ? id.text : '';
}
// template_type (e.g. `vector<int>`), function pointers, decltype — V1 excludes.
// Function pointers, decltype, etc — unsupported for ADL participation.
return '';
}
function extractAdlTypeNamespace(typeNode: SyntaxNode): string {
if (typeNode.type === 'type_descriptor') {
const innerType = typeNode.childForFieldName('type');
if (innerType !== null) return extractAdlTypeNamespace(innerType);
for (let i = 0; i < typeNode.childCount; i++) {
const child = typeNode.child(i);
if (child === null) continue;
if (
child.type === 'qualified_identifier' ||
child.type === 'template_type' ||
child.type === 'type_identifier'
) {
return extractAdlTypeNamespace(child);
}
}
return '';
}
if (typeNode.type === 'template_type') {
const nameNode = typeNode.childForFieldName('name');
return nameNode !== null ? extractAdlTypeNamespace(nameNode) : '';
}
if (typeNode.type === 'qualified_identifier') {
const scope = typeNode.childForFieldName('scope');
if (scope !== null) return normalizeCppNamespaceQName(scope.text);
return extractNamespaceFromQualifiedText(typeNode.text);
}
return '';
}
function extractAdlTemplateInfo(typeNode: SyntaxNode): {
templateSimpleClassName: string;
templateNamespace: string;
templateArgClassNames: string[];
templateArgNamespaces: string[];
} {
const templateTypeNode = findTemplateTypeNode(typeNode);
if (templateTypeNode === null) {
return {
templateSimpleClassName: '',
templateNamespace: '',
templateArgClassNames: [],
templateArgNamespaces: [],
};
}
const templateArgClassNames: string[] = [];
const templateArgNamespaces: string[] = [];
collectAdlTemplateArgs(templateTypeNode, 0, templateArgClassNames, templateArgNamespaces);
return {
templateSimpleClassName: extractAdlSimpleTypeName(templateTypeNode),
templateNamespace: extractAdlTypeNamespace(typeNode),
templateArgClassNames,
templateArgNamespaces,
};
}
function collectAdlTemplateArgs(
templateTypeNode: SyntaxNode,
depth: number,
outClassNames: string[],
outNamespaces: string[],
): void {
if (depth >= ADL_TEMPLATE_RECURSION_MAX_DEPTH) return;
if (templateTypeNode.type !== 'template_type') return;
const argList =
templateTypeNode.childForFieldName('arguments') ??
findChildOfType(templateTypeNode, ['template_argument_list']);
if (argList === null) return;
for (let i = 0; i < argList.namedChildCount; i++) {
const arg = argList.namedChild(i);
if (arg === null || arg.type !== 'type_descriptor') continue;
const simpleClassName = extractAdlSimpleTypeName(arg);
if (simpleClassName.length > 0) outClassNames.push(simpleClassName);
const ns = extractAdlTypeNamespace(arg);
if (ns.length > 0) outNamespaces.push(ns);
const nestedType = arg.childForFieldName('type');
const nestedTemplate = nestedType !== null ? findTemplateTypeNode(nestedType) : null;
if (nestedTemplate !== null) {
collectAdlTemplateArgs(nestedTemplate, depth + 1, outClassNames, outNamespaces);
}
}
}
function findTemplateTypeNode(typeNode: SyntaxNode): SyntaxNode | null {
if (typeNode.type === 'template_type') return typeNode;
if (typeNode.type === 'type_descriptor') {
const innerType = typeNode.childForFieldName('type');
if (innerType !== null) return findTemplateTypeNode(innerType);
return null;
}
if (typeNode.type === 'qualified_identifier') {
const nameNode = typeNode.childForFieldName('name');
if (nameNode !== null) return findTemplateTypeNode(nameNode);
return null;
}
return null;
}
function normalizeCppNamespaceQName(text: string): string {
const normalized = text.replace(/^::/, '').replace(/::$/, '').replace(/::/g, '.');
return normalized;
}
function extractNamespaceFromQualifiedText(text: string): string {
const cleaned = text.replace(/\s+/g, '');
const idx = cleaned.lastIndexOf('::');
if (idx <= 0) return '';
return normalizeCppNamespaceQName(cleaned.slice(0, idx));
}
/**
* Walk a declarator node chain, unwrapping pointer/reference/function/
* parenthesized wrappers, and return the text of the innermost identifier.
* Returns `null` when no identifier is found within `safety` steps.
* Used by `lookupAdlIdentifierType` to extract the variable name from
* function-pointer declarator trees such as `(*g)()` in `void (*g)()`.
*/
function extractDeclaratorLeafName(node: SyntaxNode): string | null {
let cur: SyntaxNode = node;
let safety = 16;
while (safety-- > 0) {
if (cur.type === 'identifier' || cur.type === 'type_identifier') return cur.text;
// Common wrapper nodes — follow the 'declarator' field when present.
const next =
cur.childForFieldName('declarator') ??
// parenthesized_declarator: single named child
(cur.type === 'parenthesized_declarator' ? cur.namedChild(0) : null);
if (next === null) return null;
cur = next;
}
return null;
}
/**
* Check if a C++ function_definition or declaration has `static` storage class.
*/
@@ -0,0 +1,335 @@
/**
* Extract C++ template constraint expressions for SFINAE-aware overload
* narrowing (issue #1579). Recognizes 3 AST shapes:
*
* F1 — unqualified non-type template param default:
* `template<class T, enable_if_t<P, int> = 0> void f(T);`
* F2 — `std::`-qualified variant (canonical ticket form):
* `template<class T, std::enable_if_t<P, int> = 0> void f(T);`
* F4 — C++20 leading requires-clause:
* `template<class T> requires P void f(T);`
*
* Deferred (return `{kind:'unknown'}`):
* F3 — void-default `typename = enable_if_t<P>` (cppref labels this
* `/* WRONG *\/` because adjacent overloads collapse to redeclarations)
* F5 — trailing requires (`void f(T) requires P;`)
* `requires_expression` blocks (`requires { typename T::U; }`)
* `decltype(...)`, fold-expressions, user-defined `_v` aliases.
*
* The output payload is opaque to shared code — only
* `constraint-filter.ts` consumes it. See ISO `[temp.constr.normal]` /
* `<https://en.cppreference.com/w/cpp/language/constraints>` for the
* normalization the Kleene 3-valued evaluator implements.
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
export type ConstraintExpr =
| { readonly kind: 'atomic'; readonly name: string; readonly args: readonly string[] }
| { readonly kind: 'and'; readonly children: readonly ConstraintExpr[] }
| { readonly kind: 'or'; readonly children: readonly ConstraintExpr[] }
| { readonly kind: 'not'; readonly child: ConstraintExpr }
| { readonly kind: 'unknown' };
export interface CppConstraintPayload {
/** Ordered template parameter names (type-params only — non-type defaults
* carrying enable_if predicates are folded into `expr`). */
readonly templateParams: readonly string[];
/**
* Mapping from each template parameter name to the call-site argument
* index where its deduced type lives. Computed by scanning the function's
* parameter list for the first parameter whose type is the bare template
* parameter name (or template-typed by it). Missing entries → 'unknown'
* verdict at evaluation time.
*/
readonly paramArgIndex: { readonly [paramName: string]: number };
/** Root constraint expression. When multiple constraints (multiple
* enable_if defaults, requires clause, etc.) are present they are
* implicitly conjoined under a top-level `and` node. */
readonly expr: ConstraintExpr;
}
/**
* Walk a `template_declaration` AST node and extract its constraint
* payload. Caller is responsible for passing the OUTER `template_declaration`
* — for class-member template functions, that means the enclosing
* template_declaration of the class OR of the method, whichever
* directly precedes the function definition.
*
* Returns `undefined` when the template_declaration declares no
* constraints worth tracking (no enable_if default, no requires clause).
* Returns a payload whose `expr.kind === 'unknown'` when constraints are
* present but the extractor cannot model them — monotonicity guarantees
* the filter keeps the candidate in that case.
*/
export function extractCppTemplateConstraints(
templateDecl: SyntaxNode,
funcDeclarator: SyntaxNode | null,
): CppConstraintPayload | undefined {
const paramList = childOfType(templateDecl, 'template_parameter_list');
if (paramList === null) return undefined;
const templateParams: string[] = [];
const exprs: ConstraintExpr[] = [];
for (let i = 0; i < paramList.namedChildCount; i++) {
const param = paramList.namedChild(i);
if (param === null) continue;
if (
param.type === 'type_parameter_declaration' ||
param.type === 'optional_type_parameter_declaration' ||
param.type === 'variadic_type_parameter_declaration'
) {
const id = firstDescendantOfType(param, 'type_identifier');
if (id !== null) templateParams.push(id.text);
continue;
}
// Non-type parameter — F1 / F2 default-value carries the enable_if
// predicate. Shape: `optional_parameter_declaration` with field
// `default_value`, whose value is a `template_type` named
// `enable_if_t` (F1) or a qualified version (F2).
if (param.type === 'optional_parameter_declaration') {
const defaultVal = param.childForFieldName('default_value');
const typeNode = param.childForFieldName('type');
const candidate = extractEnableIfPredicate(typeNode);
if (candidate !== undefined) {
exprs.push(candidate);
} else if (defaultVal !== null) {
// Default-value-as-predicate not yet supported. Bail conservatively.
exprs.push({ kind: 'unknown' });
}
}
}
// F4 — C++20 leading `requires` clause. Tree-sitter-cpp exposes it as a
// `requires_clause` child of `template_declaration` (sibling of the
// template_parameter_list).
const requiresClause = childOfType(templateDecl, 'requires_clause');
if (requiresClause !== null) {
const parsed = parseRequiresClause(requiresClause);
if (parsed !== undefined) exprs.push(parsed);
}
if (templateParams.length === 0 && exprs.length === 0) return undefined;
const paramArgIndex = buildParamArgIndex(templateParams, funcDeclarator);
const expr: ConstraintExpr =
exprs.length === 0
? { kind: 'unknown' }
: exprs.length === 1
? exprs[0]
: { kind: 'and', children: exprs };
return { templateParams, paramArgIndex, expr };
}
/**
* Inspect a non-type template parameter's declared type to see whether
* it's `enable_if_t<P, T>` (F1) or `std::enable_if_t<P, T>` (F2). When
* matched, extract the predicate `P` and return it as a `ConstraintExpr`.
*
* Returns undefined when the parameter's type is not enable_if (so the
* caller can decide whether to bail or ignore).
*/
function extractEnableIfPredicate(typeNode: SyntaxNode | null): ConstraintExpr | undefined {
if (typeNode === null) return undefined;
// Unwrap a type_descriptor wrapper (when present).
let t: SyntaxNode | null = typeNode;
if (t.type === 'type_descriptor') {
t = t.childForFieldName('type') ?? firstDescendantOfType(t, 'template_type');
}
// F2 shape: tree-sitter-cpp models `std::enable_if_t<...>` as
// `qualified_identifier` whose `name` field is the `template_type`.
// F1 shape (unqualified `enable_if_t<...>`) is `template_type` directly.
if (t !== null && t.type === 'qualified_identifier') {
const inner = t.childForFieldName('name') ?? firstDescendantOfType(t, 'template_type');
if (inner !== null && inner.type === 'template_type') {
t = inner;
}
}
if (t === null || t.type !== 'template_type') return undefined;
const nameNode = t.childForFieldName('name');
if (nameNode === null) return undefined;
const tail = stripQualifiedPrefix(nameNode.text);
if (tail !== 'enable_if_t' && tail !== 'enable_if') return undefined;
// Predicate is the first template argument of enable_if_t.
const argList = t.childForFieldName('arguments') ?? childOfType(t, 'template_argument_list');
if (argList === null) return { kind: 'unknown' };
for (let i = 0; i < argList.namedChildCount; i++) {
const arg = argList.namedChild(i);
if (arg === null) continue;
if (arg.type !== 'type_descriptor') continue;
const inner = arg.childForFieldName('type') ?? arg.namedChild(0);
if (inner === null) continue;
return parseAtomicOrBoolean(inner);
}
return { kind: 'unknown' };
}
/** Parse a requires-clause body. The body is a binary or unary expression
* over atomic predicates (variable templates like `is_integral_v<T>`). */
function parseRequiresClause(requiresClause: SyntaxNode): ConstraintExpr | undefined {
// tree-sitter-cpp exposes the expression as a named child or via a
// `constraint` field. Probe both.
let expr: SyntaxNode | null = requiresClause.childForFieldName('constraint');
if (expr === null) {
for (let i = 0; i < requiresClause.namedChildCount; i++) {
const c = requiresClause.namedChild(i);
if (c === null) continue;
// Skip the `requires` keyword token.
if (c.type === 'requires') continue;
expr = c;
break;
}
}
if (expr === null) return undefined;
return parseAtomicOrBoolean(expr);
}
/**
* Recursively parse a constraint sub-expression. Recognizes:
* - `template_type` / `template_function` named `<predicate>_v` → atomic
* - binary_expression with `&&` / `||` → conjunction / disjunction
* - unary_expression with `!` → negation
* - parenthesized_expression → unwrap
* - anything else → `{kind:'unknown'}` (monotonicity-safe)
*
* `requires_expression` blocks intentionally fall through to 'unknown'
* — they need substitution semantics we don't model in V1.
*/
function parseAtomicOrBoolean(node: SyntaxNode): ConstraintExpr {
// Unwrap parentheses.
if (node.type === 'parenthesized_expression') {
const inner = node.namedChild(0);
return inner === null ? { kind: 'unknown' } : parseAtomicOrBoolean(inner);
}
// Boolean composition.
if (node.type === 'binary_expression') {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
const opNode = node.childForFieldName('operator');
if (left !== null && right !== null && opNode !== null) {
const op = opNode.text;
const l = parseAtomicOrBoolean(left);
const r = parseAtomicOrBoolean(right);
if (op === '&&') return { kind: 'and', children: [l, r] };
if (op === '||') return { kind: 'or', children: [l, r] };
}
return { kind: 'unknown' };
}
if (node.type === 'unary_expression') {
const opNode = node.childForFieldName('operator') ?? node.namedChild(0);
const arg = node.childForFieldName('argument') ?? node.namedChild(1) ?? node.namedChild(0);
if (opNode !== null && opNode.text === '!' && arg !== null && arg !== opNode) {
return { kind: 'not', child: parseAtomicOrBoolean(arg) };
}
return { kind: 'unknown' };
}
// Atomic predicate — `template_type` is the typical shape for variable
// templates like `is_integral_v<T>`. Some grammar variants surface it as
// `template_function` or via a `qualified_identifier` wrapper.
if (node.type === 'template_type' || node.type === 'template_function') {
return parseAtomicTemplate(node);
}
if (node.type === 'qualified_identifier') {
// `std::is_integral_v<T>` shape (without template_type wrapping).
const inner = node.childForFieldName('name');
if (inner !== null && (inner.type === 'template_type' || inner.type === 'template_function')) {
return parseAtomicTemplate(inner);
}
return { kind: 'unknown' };
}
// `requires { typename T::U; }` blocks and decltype: out of V1 scope.
return { kind: 'unknown' };
}
function parseAtomicTemplate(t: SyntaxNode): ConstraintExpr {
const nameNode = t.childForFieldName('name');
if (nameNode === null) return { kind: 'unknown' };
const name = stripQualifiedPrefix(nameNode.text);
const argList = t.childForFieldName('arguments') ?? childOfType(t, 'template_argument_list');
const args: string[] = [];
if (argList !== null) {
for (let i = 0; i < argList.namedChildCount; i++) {
const arg = argList.namedChild(i);
if (arg === null) continue;
if (arg.type !== 'type_descriptor') continue;
const inner = arg.childForFieldName('type') ?? arg.namedChild(0);
if (inner === null) continue;
// For Tier-A predicates the args are bare template-parameter names
// (`T`, `U`). Anything more elaborate is bailed via 'unknown' at the
// top level if needed; here we just record the textual identifier.
const id =
inner.type === 'type_identifier' ? inner : firstDescendantOfType(inner, 'type_identifier');
args.push(id !== null ? id.text : inner.text);
}
}
return { kind: 'atomic', name, args };
}
/** Build a `paramName → call-site argument index` map by scanning the
* function's parameter list for parameters typed by each template param. */
function buildParamArgIndex(
templateParams: readonly string[],
funcDeclarator: SyntaxNode | null,
): { [paramName: string]: number } {
const out: { [paramName: string]: number } = {};
if (funcDeclarator === null || templateParams.length === 0) return out;
const paramList = funcDeclarator.childForFieldName('parameters');
if (paramList === null) return out;
let argIdx = 0;
for (let i = 0; i < paramList.childCount; i++) {
const p = paramList.child(i);
if (p === null) continue;
if (
p.type !== 'parameter_declaration' &&
p.type !== 'optional_parameter_declaration' &&
p.type !== 'variadic_parameter_declaration'
) {
continue;
}
const typeNode = p.childForFieldName('type');
if (typeNode !== null) {
const tname = bareTypeIdentifier(typeNode);
if (tname !== null && templateParams.includes(tname) && !(tname in out)) {
out[tname] = argIdx;
}
}
argIdx++;
}
return out;
}
function bareTypeIdentifier(typeNode: SyntaxNode): string | null {
if (typeNode.type === 'type_identifier') return typeNode.text;
// Allow `T const`, `T&`, `T*` shapes — the inner type_identifier still wins.
const id = firstDescendantOfType(typeNode, 'type_identifier');
return id !== null ? id.text : null;
}
function stripQualifiedPrefix(text: string): string {
const idx = text.lastIndexOf('::');
return idx >= 0 ? text.slice(idx + 2) : text;
}
function childOfType(node: SyntaxNode, type: string): SyntaxNode | null {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c !== null && c.type === type) return c;
}
return null;
}
function firstDescendantOfType(node: SyntaxNode, type: string): SyntaxNode | null {
if (node.type === type) return node;
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c === null) continue;
const hit = firstDescendantOfType(c, type);
if (hit !== null) return hit;
}
return null;
}
@@ -0,0 +1,147 @@
/**
* Kleene 3-valued evaluator + curated 4-predicate registry +
* `cppConstraintCompatibility` hook export for SFINAE / `requires`-clause
* filtering (issue #1579).
*
* Semantics:
* - `'incompatible'` → predicate provably fails for these argumentTypes
* (ISO `[temp.constr.atomic]` "not satisfied")
* - `'compatible'` → predicate provably holds
* - `'unknown'` → cannot decide (missing arg-type info, predicate
* not in registry, AST shape bailed during extraction). The shared
* filter keeps the candidate on `'unknown'` — monotonicity guarantee.
*
* Kleene rules (extension of ISO's 2-valued short-circuit conjunction in
* `<https://en.cppreference.com/w/cpp/language/constraints>`):
* AND: incompatible if any child incompatible; compatible iff all
* children compatible; otherwise unknown.
* OR: compatible if any child compatible; incompatible iff all
* children incompatible; otherwise unknown.
* NOT: flip compatible↔incompatible; pass through unknown.
*/
import type { ArityVerdict, Callsite, ConstraintContext, SymbolDefinition } from 'gitnexus-shared';
import { classifyType, type TypeClass } from './type-classifier.js';
import type { ConstraintExpr, CppConstraintPayload } from './constraint-extractor.js';
type AtomicEvaluator = (argClasses: readonly TypeClass[]) => ArityVerdict;
/**
* Curated Tier-A predicate registry — the four canonical
* `<type_traits>` variable templates whose truth tables are closed-form
* over our coarse `TypeClass` enum.
*
* Deferred predicates that need a cv/ref/pointer sidecar on
* `normalizeCppParamType` (today the normalizer strips those markers
* before storage) live in #1579 as one-line follow-up adds.
*/
// ISO `<type_traits>` treats `bool`, `char`, and the signed/unsigned char
// variants as integral types (§21.3.4 Table 48), so `is_integral_v<bool>`
// and `is_integral_v<char>` must both yield `true`. We keep the `TypeClass`
// enum precise (separate `'bool'` / `'char'` buckets) so that
// `is_same_v<bool, int>` still resolves to `'incompatible'`; the integral-
// family widening lives here in the predicate evaluators instead.
function isIntegralClass(c: TypeClass | undefined): boolean {
return c === 'integral' || c === 'bool' || c === 'char';
}
const REGISTRY = new Map<string, AtomicEvaluator>([
['is_integral_v', (cls) => verdictFromBool(isIntegralClass(cls[0]), cls)],
['is_floating_point_v', (cls) => verdictFromBool(cls[0] === 'floating', cls)],
[
'is_arithmetic_v',
(cls) => verdictFromBool(isIntegralClass(cls[0]) || cls[0] === 'floating', cls),
],
// NOTE: cv-qualifiers are stripped by `normalizeCppParamType` before the
// type token reaches `classifyType`, so `is_same_v<const T, T>` returns
// `'compatible'` instead of the ISO-correct `false`. Tracked under the
// cv-sidecar refactor in #1579's "Out of scope" list; until that lands
// this approximation matches the common `is_same_v<T, ConcreteType>`
// dispatch idiom and silently degrades on cv-distinct compares.
[
'is_same_v',
(cls) => {
if (cls.length < 2 || cls[0] === 'unknown' || cls[1] === 'unknown') return 'unknown';
return cls[0] === cls[1] ? 'compatible' : 'incompatible';
},
],
]);
function verdictFromBool(predicate: boolean, cls: readonly TypeClass[]): ArityVerdict {
if (cls[0] === 'unknown') return 'unknown';
return predicate ? 'compatible' : 'incompatible';
}
/** Public surface — registered as `ScopeResolver.constraintCompatibility`. */
export function cppConstraintCompatibility(
_callsite: Callsite,
def: SymbolDefinition,
ctx: ConstraintContext,
): ArityVerdict {
const payload = def.templateConstraints as CppConstraintPayload | undefined;
if (payload === undefined) return 'unknown';
return evaluate(payload.expr, payload, ctx);
}
function evaluate(
expr: ConstraintExpr,
payload: CppConstraintPayload,
ctx: ConstraintContext,
): ArityVerdict {
switch (expr.kind) {
case 'unknown':
return 'unknown';
case 'atomic': {
const evaluator = REGISTRY.get(expr.name);
if (evaluator === undefined) return 'unknown';
const classes = expr.args.map((paramName) => {
const argIdx = payload.paramArgIndex[paramName];
if (argIdx === undefined) return 'unknown' as TypeClass;
const token = ctx.argumentTypes?.[argIdx];
if (token === undefined || token === '') return 'unknown' as TypeClass;
return classifyType(token);
});
return evaluator(classes);
}
case 'and': {
let result: ArityVerdict = 'compatible';
for (const child of expr.children) {
const v = evaluate(child, payload, ctx);
if (v === 'incompatible') return 'incompatible';
if (v === 'unknown') result = 'unknown';
}
return result;
}
case 'or': {
let result: ArityVerdict = 'incompatible';
for (const child of expr.children) {
const v = evaluate(child, payload, ctx);
if (v === 'compatible') return 'compatible';
if (v === 'unknown') result = 'unknown';
}
return result;
}
case 'not': {
const v = evaluate(expr.child, payload, ctx);
if (v === 'compatible') return 'incompatible';
if (v === 'incompatible') return 'compatible';
return 'unknown';
}
}
}
/** Exposed for unit tests — lets `cpp-constraint.test.ts` assert
* `expect(getRegistrySize()).toBe(4)` without exporting the Map itself. */
export function getRegistrySize(): number {
return REGISTRY.size;
}
/** Exposed for unit tests covering the Kleene 3-valued truth table
* directly, without an AST round-trip. */
export function evaluateForTest(
expr: ConstraintExpr,
payload: CppConstraintPayload,
ctx: ConstraintContext,
): ArityVerdict {
return evaluate(expr, payload, ctx);
}
@@ -0,0 +1,47 @@
/**
* C++ conversion-rank scoring for overload resolution (#1578).
*
* Operates on **normalized** type strings (output of
* `normalizeCppParamType` in `arity-metadata.ts`). After normalization:
* - int/long/short/unsigned → 'int'
* - float/double → 'double'
* - char → 'char', bool → 'bool'
*
* Because the normalizer collapses promotion pairs (int↔long,
* float↔double) to the same string, those promotions are invisible at
* this layer — they appear as exact matches (rank 0).
*
* Post-normalization ranking:
* - rank 0 — exact (same normalized type)
* - rank 1 — integral promotion (char→int, bool→int)
* - rank 2 — standard arithmetic conversion (int↔double, char→double,
* bool→double)
* - Infinity — mismatch (string↔int, user types, pointers, etc.)
*
* This function is intentionally C++-specific (issue #1578 pitfall:
* keep conversion-rank tables out of shared overload-narrowing). Other
* languages may define their own `ConversionRankFn` in the future.
*/
/** Set of normalized arithmetic types that support implicit conversion. */
const ARITHMETIC = new Set(['int', 'double', 'char', 'bool']);
/** Integral promotion targets: char→int and bool→int are rank 1. */
const INTEGRAL_PROMOTION = new Map([
['char', 'int'],
['bool', 'int'],
]);
/**
* Return the conversion rank from `argType` to `paramType`.
*
* @returns 0 for exact match, 1 for integral promotion (char/bool→int),
* 2 for standard arithmetic conversion, Infinity for mismatch.
*/
export function cppConversionRank(argType: string, paramType: string): number {
if (argType === paramType) return 0;
// Integral promotions: char→int, bool→int (ISO C++ [conv.prom])
if (INTEGRAL_PROMOTION.get(argType) === paramType) return 1;
if (ARITHMETIC.has(argType) && ARITHMETIC.has(paramType)) return 2;
return Infinity;
}
@@ -35,6 +35,33 @@ const fileLocalNames = new Map<string, Set<string>>();
*/
const nonGloballyVisibleNodeIds = new Map<string, Set<string>>();
/**
* Per-file set of source-range keys identifying `namespace { ... }` blocks.
* Resolved to `ScopeId`s in `populateCppAnonymousNamespaceScopes` and
* consumed via `isCppAnonymousNamespaceScope`.
*
* Anonymous namespaces have file-local linkage but, unlike `static`, their
* members propagate to any TU that `#include`s the declaring file — each
* including TU gets its own internal-linkage copy. So for wildcard import
* expansion (`expandCppWildcardNames`) we treat anonymous-namespace owned
* defs as if declared at the enclosing scope. Cross-file unqualified
* lookup that does NOT go through `#include` is still blocked by the
* `isFileLocal` mark recorded on the def's name.
*/
const anonymousNamespaceRangesByFile = new Map<string, Set<string>>();
const anonymousNamespaceScopeIds = new Set<ScopeId>();
interface RangeKeyShape {
readonly startLine: number;
readonly startCol: number;
readonly endLine: number;
readonly endCol: number;
}
function rangeKey(r: RangeKeyShape): string {
return `${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}`;
}
/** Record a symbol name as file-local (static or anonymous namespace). */
export function markFileLocal(filePath: string, name: string): void {
let names = fileLocalNames.get(filePath);
@@ -50,10 +77,51 @@ export function isFileLocal(filePath: string, name: string): boolean {
return fileLocalNames.get(filePath)?.has(name) ?? false;
}
/** Capture-time: record an anonymous `namespace_definition` source range. */
export function markCppAnonymousNamespaceRange(filePath: string, range: RangeKeyShape): void {
let set = anonymousNamespaceRangesByFile.get(filePath);
if (set === undefined) {
set = new Set();
anonymousNamespaceRangesByFile.set(filePath, set);
}
set.add(rangeKey(range));
}
/** Predicate consumed by `populateCppNonGloballyVisible` and
* `expandCppWildcardNames` to exempt anonymous-namespace scopes from
* the cross-file unqualified-lookup exclusion that applies to ordinary
* named namespaces. */
export function isCppAnonymousNamespaceScope(scopeId: ScopeId): boolean {
return anonymousNamespaceScopeIds.has(scopeId);
}
/** Clear tracked file-local names (call at start of each resolution pass). */
export function clearFileLocalNames(): void {
fileLocalNames.clear();
nonGloballyVisibleNodeIds.clear();
anonymousNamespaceRangesByFile.clear();
anonymousNamespaceScopeIds.clear();
}
/** Resolve recorded anonymous-namespace source ranges to `ScopeId`s.
* Must run inside `populateOwners` BEFORE `populateCppNonGloballyVisible`
* consults the resolved set. */
export function populateCppAnonymousNamespaceScopes(parsed: {
readonly filePath: string;
readonly scopes: readonly {
readonly id: ScopeId;
readonly kind: string;
readonly range: RangeKeyShape;
}[];
}): void {
const ranges = anonymousNamespaceRangesByFile.get(parsed.filePath);
if (ranges === undefined || ranges.size === 0) return;
for (const scope of parsed.scopes) {
if (scope.kind !== 'Namespace') continue;
if (ranges.has(rangeKey(scope.range))) {
anonymousNamespaceScopeIds.add(scope.id);
}
}
}
/**
@@ -87,6 +155,13 @@ export function populateCppNonGloballyVisible(parsed: {
// ISO C++ `[namespace.def]/p4`. Skip them here so cross-file
// unqualified lookup can still see their callable defs.
if (scope.kind === 'Namespace' && isCppInlineNamespaceScope(scope.id)) continue;
// Anonymous namespaces give internal linkage but their contents are
// visible at the enclosing scope within the same TU and propagate to
// any TU that `#include`s the declaring file. The `isFileLocal` mark
// (recorded on the def's name in this file) still blocks cross-file
// unqualified lookup that does not go through #include, so dropping
// the structural visibility exclusion here is safe.
if (scope.kind === 'Namespace' && anonymousNamespaceScopeIds.has(scope.id)) continue;
for (const def of scope.ownedDefs) {
set.add(def.nodeId);
}
@@ -191,9 +266,18 @@ export function expandCppWildcardNames(
// including TU. When the owning scope is unknown we default to
// include (preserves prior behavior for any def whose structural
// ownership wasn't recorded in `Scope.ownedDefs`).
//
// Anonymous namespaces are exempt: their members propagate to the
// enclosing scope of any TU that #includes the declaring file (each
// including TU gets its own internal-linkage copy per ISO C++).
const ownerScope = ownerScopeByNodeId.get(def.nodeId);
const ownerIsAnonymousNamespace =
ownerScope !== undefined &&
ownerScope.kind === 'Namespace' &&
anonymousNamespaceScopeIds.has(ownerScope.id);
if (
ownerScope !== undefined &&
!ownerIsAnonymousNamespace &&
(ownerScope.kind === 'Namespace' || ownerScope.kind === 'Class')
) {
continue;
@@ -201,7 +285,11 @@ export function expandCppWildcardNames(
const name = simpleName(def);
if (name === '') continue;
if (isFileLocal(target.filePath, name)) continue;
// Same exemption for the `isFileLocal` mark — anonymous-namespace
// names are recorded as file-local to suppress the global free-call
// fallback's cross-file leak, but they MUST still propagate through
// wildcard import expansion to including TUs.
if (!ownerIsAnonymousNamespace && isFileLocal(target.filePath, name)) continue;
if (seen.has(name)) continue;
seen.add(name);
names.push(name);
@@ -29,6 +29,10 @@
import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import {
isOverloadAmbiguousAfterNormalization,
narrowOverloadCandidates,
} from '../../scope-resolution/passes/overload-narrowing.js';
interface RangeKey {
readonly startLine: number;
@@ -95,15 +99,17 @@ export function isCppInlineNamespaceScope(scopeId: ScopeId): boolean {
* Returns the most specific (innermost) match — for `outer::foo()`
* where `inline namespace v1` declares `foo`, returns `v1::foo`. When
* multiple inline-namespace children declare the same name, ISO C++
* leaves the call ambiguous; V1 returns the first match in source
* order (stable across runs).
* leaves the call ambiguous; returns `'ambiguous'` so the caller
* suppresses edge emission rather than picking arbitrarily (#1564).
*/
export function resolveCppQualifiedNamespaceMember(
receiverName: string,
memberName: string,
parsedFiles: readonly ParsedFile[],
_scopes: ScopeResolutionIndexes,
): SymbolDefinition | undefined {
): SymbolDefinition | 'ambiguous' | undefined {
const allHits: SymbolDefinition[] = [];
const seenNodeId = new Set<string>();
for (const parsed of parsedFiles) {
const scopesById = new Map<ScopeId, (typeof parsed.scopes)[number]>();
for (const sc of parsed.scopes) scopesById.set(sc.id, sc);
@@ -113,19 +119,45 @@ export function resolveCppQualifiedNamespaceMember(
if (nsDef === undefined) continue;
const nsName = nsDef.qualifiedName?.split('.').pop() ?? nsDef.qualifiedName ?? '';
if (nsName !== receiverName) continue;
// Found a matching namespace scope in this file. Collect the
// member transitively through any inline-namespace children.
const hit = findMemberInNamespaceTransitive(scope, scopesById, memberName);
if (hit !== undefined) return hit;
// Found a matching namespace scope in this file. Collect ALL
// members transitively through any inline-namespace children.
const hits = findMemberInNamespaceTransitive(scope, scopesById, memberName);
for (const hit of hits) {
if (seenNodeId.has(hit.nodeId)) continue;
seenNodeId.add(hit.nodeId);
allHits.push(hit);
}
}
}
return undefined;
if (allHits.length === 0) return undefined;
if (allHits.length === 1) return allHits[0];
// Multi-candidate: the `resolveQualifiedReceiverMember` hook has no
// access to call-site arity or argument types, so
// `narrowOverloadCandidates` cannot actually narrow here — the call
// with `(allHits, undefined, undefined)` is effectively a pass-through.
// We retain it so that `isOverloadAmbiguousAfterNormalization` can
// still detect int/long-style normalization collisions on this path,
// but for any multi-hit case where candidates have genuinely distinct
// signatures (e.g. `foo(int)` vs `foo(double)` in different inline
// children), we conservatively suppress rather than pick arbitrarily.
// A future enhancement could thread call-site argument info through
// the `resolveQualifiedReceiverMember` contract to enable real
// narrowing here.
const narrowed = narrowOverloadCandidates(allHits, undefined, undefined);
if (narrowed.length === 1) return narrowed[0];
if (narrowed.length === 0) return undefined;
if (isOverloadAmbiguousAfterNormalization(narrowed, undefined)) return 'ambiguous';
// Multiple surviving candidates (distinct signatures) — conservative
// suppress because we lack call-site info to disambiguate.
return 'ambiguous';
}
/** Recursively search a namespace scope and any inline-namespace
* descendants for a callable def with the given simple name. Non-inline
* descendants for callable defs with the given simple name. Non-inline
* nested namespaces are NOT traversed — they require explicit
* qualification (`outer::nested::foo`). */
* qualification (`outer::nested::foo`). Returns ALL matches so the
* caller can detect same-name ambiguity across inline children (#1564). */
function findMemberInNamespaceTransitive(
scope: {
readonly id: ScopeId;
@@ -142,22 +174,23 @@ function findMemberInNamespaceTransitive(
}
>,
memberName: string,
): SymbolDefinition | undefined {
): SymbolDefinition[] {
const results: SymbolDefinition[] = [];
// Check this scope's own ownedDefs first.
for (const def of scope.ownedDefs) {
if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') continue;
const simple = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
if (simple === memberName) return def;
if (simple === memberName) results.push(def);
}
// Descend into inline-namespace children.
for (const childScope of scopesById.values()) {
if (childScope.parent !== scope.id) continue;
if (childScope.kind !== 'Namespace') continue;
if (!inlineNamespaceScopeIds.has(childScope.id)) continue;
const hit = findMemberInNamespaceTransitive(childScope, scopesById, memberName);
if (hit !== undefined) return hit;
const childHits = findMemberInNamespaceTransitive(childScope, scopesById, memberName);
for (const hit of childHits) results.push(hit);
}
return undefined;
return results;
}
function findNamespaceDefInScope(scope: {
@@ -9,6 +9,7 @@ import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import { cppProvider } from '../c-cpp.js';
import { cppArityCompatibility } from './arity.js';
import { cppConversionRank } from './conversion-rank.js';
import { cppMergeBindings } from './merge-bindings.js';
import { resolveCppImportTarget } from './import-target.js';
import { scanCppHeaderFiles } from './header-scan.js';
@@ -16,6 +17,7 @@ import {
expandCppWildcardNames,
isFileLocal,
clearFileLocalNames,
populateCppAnonymousNamespaceScopes,
populateCppNonGloballyVisible,
isCppDefGloballyVisible,
} from './file-local-linkage.js';
@@ -24,22 +26,14 @@ import {
clearCppDependentBases,
isCppDependentBaseMember,
} from './two-phase-lookup.js';
import {
populateCppAssociatedNamespaces,
clearCppAdlState,
pickCppAdlCandidates,
ADL_AMBIGUOUS,
} from './adl.js';
import { populateCppAssociatedNamespaces, clearCppAdlState, pickCppAdlCandidates } from './adl.js';
import {
clearCppInlineNamespaces,
populateCppInlineNamespaceScopes,
resolveCppQualifiedNamespaceMember,
} from './inline-namespaces.js';
import { populateCppRangeBindings } from './range-bindings.js';
import {
isOverloadAmbiguousAfterNormalization,
narrowOverloadCandidates,
} from '../../scope-resolution/passes/overload-narrowing.js';
import { cppConstraintCompatibility } from './constraint-filter.js';
/**
* C++ `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by
@@ -92,15 +86,22 @@ export const cppScopeResolver: ScopeResolver = {
// (def, callsite). ScopeResolver contract is (callsite, def).
arityCompatibility: (callsite, def) => cppArityCompatibility(def, callsite),
// SFINAE / `requires`-clause aware overload filter (issue #1579).
// Drops candidates whose template constraints (`enable_if_t<P, T>`,
// C++20 `requires P`) provably fail at the call site. Three-valued —
// `'unknown'` keeps the candidate, preserving "degrade not lie".
constraintCompatibility: cppConstraintCompatibility,
buildMro: (graph, parsedFiles, nodeLookup) =>
buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
populateOwners: (parsed: ParsedFile) => {
populateClassOwnedMembers(parsed);
// Resolve inline-namespace ranges (recorded at capture time) to
// ScopeIds BEFORE `populateCppNonGloballyVisible` runs, so the
// inline-namespace exemption sees the populated Set.
// Resolve inline- and anonymous-namespace ranges (recorded at capture
// time) to ScopeIds BEFORE `populateCppNonGloballyVisible` runs, so
// both exemptions see the populated Sets.
populateCppInlineNamespaceScopes(parsed);
populateCppAnonymousNamespaceScopes(parsed);
// Track namespace-nested and class-nested defs so the global free-call
// fallback and wildcard expansion can suppress them as unqualified
// cross-file callables.
@@ -176,6 +177,10 @@ export const cppScopeResolver: ScopeResolver = {
propagatesReturnTypesAcrossImports: true,
// C++ #include brings in all symbols — enable global free call fallback
allowGlobalFreeCallFallback: true,
// C++ standard-conversion-sequence ranking for overload resolution (#1578).
// Disambiguates `f(int)` vs `f(double)` called with `f(2.5)` by scoring
// each candidate's conversion cost; exact match wins over standard conversion.
conversionRankFn: cppConversionRank,
// Range-for element type inference: for (auto& user : users) → bind user to User
populateRangeBindings: populateCppRangeBindings,
// C++ method return-type bindings need to be visible from module scope
@@ -220,10 +225,12 @@ export const cppScopeResolver: ScopeResolver = {
},
// C++ argument-dependent / Koenig lookup (U2 of plan 2026-05-13-001).
// Fires after `findCallableBindingInScope` returns undefined; surfaces
// candidates from the associated namespaces of class-typed arguments.
// V1 limitation: only direct enclosing-namespace closure for value
// class-typed args; pointer/reference/template-spec args excluded.
// Contributes candidates from associated namespaces of class-typed
// arguments; caller merges with ordinary unqualified lookup candidates.
// Current boundary: class-typed value/pointer/reference args and template
// specializations with explicit type arguments contribute associated
// namespaces. Function-pointer args and full conversion-ranking remain
// excluded.
resolveAdlCandidates: (site, callerParsed, scopes, parsedFiles) => {
// `using ns::name;` introduces `name` into ordinary unqualified lookup.
// For template-class method bodies, lexical scope walks can miss this
@@ -240,21 +247,26 @@ export const cppScopeResolver: ScopeResolver = {
parsedFiles,
scopes,
);
if (member === undefined) continue;
if (member === undefined || member === 'ambiguous') continue;
if (seenUsing.has(member.nodeId)) continue;
seenUsing.add(member.nodeId);
usingNamedHits.push(member);
}
if (usingNamedHits.length > 0) {
const narrowed = narrowOverloadCandidates(usingNamedHits, site.arity, site.argumentTypes);
if (isOverloadAmbiguousAfterNormalization(narrowed, site.arity)) return 'ambiguous';
if (narrowed.length === 1) return narrowed[0];
if (narrowed.length > 1) return 'ambiguous';
const adlHits = pickCppAdlCandidates(site, callerParsed, scopes, parsedFiles);
if (usingNamedHits.length === 0) return adlHits;
if (adlHits === undefined || adlHits.length === 0) return usingNamedHits;
const merged: SymbolDefinition[] = [];
const seen = new Set<string>();
for (const hit of usingNamedHits) {
seen.add(hit.nodeId);
merged.push(hit);
}
const result = pickCppAdlCandidates(site, callerParsed, scopes, parsedFiles);
if (result === ADL_AMBIGUOUS) return 'ambiguous';
return result;
for (const hit of adlHits) {
if (seen.has(hit.nodeId)) continue;
seen.add(hit.nodeId);
merged.push(hit);
}
return merged;
},
// C++ qualified namespace-member resolution (U5 of plan 2026-05-13-001).
@@ -0,0 +1,59 @@
/**
* Coarse-grained type classifier for C++ constraint evaluation
* (`<https://en.cppreference.com/w/cpp/types/is_integral>`,
* `<https://en.cppreference.com/w/cpp/types/is_floating_point>`).
*
* Maps a normalized type token (as produced by `normalizeCppParamType` /
* the call-site inference in `captures.ts`) to one of the categories
* the `<type_traits>` predicate registry uses for SFINAE filtering.
*
* Intentionally coarse: cv / pointer / reference qualifiers are stripped
* upstream by `normalizeCppParamType`. Tier-A predicates
* (`is_integral_v`, `is_floating_point_v`, `is_arithmetic_v`, `is_same_v`)
* are insensitive to those modifiers per ISO `<type_traits>` semantics
* ("including any cv-qualified variants").
*/
export type TypeClass =
| 'integral'
| 'floating'
| 'bool'
| 'char'
| 'string'
| 'null'
| 'class'
| 'unknown';
/**
* Classify a normalized C++ type token. The mapping mirrors the literal-
* inference table in `captures.ts:inferCppLiteralType` plus the std::
* normalization in `arity-metadata.ts:normalizeCppParamType`.
*
* Caller note: token must already be normalized (no `const`, no `&` / `*`,
* no `std::` prefix). Tokens passed via `ConstraintContext.argumentTypes`
* coming from `inferCppCallArgTypes` satisfy this.
*/
export function classifyType(token: string): TypeClass {
if (token.length === 0) return 'unknown';
switch (token) {
case 'int':
return 'integral';
case 'double':
case 'float':
return 'floating';
case 'bool':
return 'bool';
case 'char':
return 'char';
case 'string':
return 'string';
case 'null':
return 'null';
default:
// After normalization, anything that isn't a recognized primitive
// is assumed to be a class-like type. The Tier-A predicate registry
// doesn't introspect class types — `is_integral_v` etc. simply
// returns `false` for `'class'`, matching ISO behavior.
return 'class';
}
}
@@ -34,7 +34,7 @@
* logic up the dependency chain instead.
*/
import type { NodeLabel, SymbolDefinition } from 'gitnexus-shared';
import type { NodeLabel, ParameterTypeClass, SymbolDefinition } from 'gitnexus-shared';
/**
* Class-like NodeLabels — used for qualifiedName fallback inside
@@ -126,6 +126,7 @@ export interface AddMetadata {
parameterCount?: number;
requiredParameterCount?: number;
parameterTypes?: string[];
parameterTypeClasses?: ParameterTypeClass[];
returnType?: string;
declaredType?: string;
templateArguments?: string[];
@@ -276,6 +277,9 @@ export const createSymbolTable = (): InternalSymbolTable => {
...(metadata?.parameterTypes !== undefined
? { parameterTypes: metadata.parameterTypes }
: {}),
...(metadata?.parameterTypeClasses !== undefined
? { parameterTypeClasses: metadata.parameterTypeClasses }
: {}),
...(metadata?.returnType !== undefined ? { returnType: metadata.returnType } : {}),
...(metadata?.declaredType !== undefined ? { declaredType: metadata.declaredType } : {}),
...(metadata?.templateArguments !== undefined
@@ -1,4 +1,4 @@
import type { GraphNode, GraphRelationship, NodeLabel } from 'gitnexus-shared';
import type { GraphNode, GraphRelationship, NodeLabel, ParameterTypeClass } from 'gitnexus-shared';
import { KnowledgeGraph } from '../graph/types.js';
import Parser from 'tree-sitter';
import { loadParser, loadLanguage, isLanguageAvailable } from '../tree-sitter/parser-loader.js';
@@ -30,7 +30,11 @@ import {
constTagForId,
buildCollisionGroups,
} from './utils/method-props.js';
import { extractTemplateArguments, templateArgumentsIdTag } from './utils/template-arguments.js';
import {
extractTemplateArguments,
templateArgumentsIdTag,
templateConstraintsIdTag,
} from './utils/template-arguments.js';
import type { LanguageProvider } from './language-provider.js';
import type { ParsedFile } from 'gitnexus-shared';
import { WorkerPool } from './workers/worker-pool.js';
@@ -128,6 +132,7 @@ export const mergeChunkResults = (
parameterCount: sym.parameterCount,
requiredParameterCount: sym.requiredParameterCount,
parameterTypes: sym.parameterTypes,
parameterTypeClasses: sym.parameterTypeClasses,
returnType: sym.returnType,
declaredType: sym.declaredType,
templateArguments: sym.templateArguments,
@@ -650,9 +655,38 @@ const processParsingSequential = async (
classTemplateArguments.length > 0
? templateArgumentsIdTag(classTemplateArguments)
: '';
// SFINAE / `requires`-clause aware ID disambiguation (issue #1579).
// Function-template overloads with identical parameterTypes but
// mutually-exclusive constraints (e.g. `enable_if_t<is_integral_v<T>>`
// vs `enable_if_t<is_floating_point_v<T>>`) need distinct graph
// nodes so the constraint-filter step in `narrowOverloadCandidates`
// has two candidates to narrow between. Without this tag they
// collapse to a single Function node and the SFINAE call resolves
// to only one edge regardless of which overload's constraint holds.
// The provider hook is the right invocation point — parsing-processor
// sees raw tree-sitter matches without the `@`-prefixed synthetic
// captures `scope-extractor` consumes, so we delegate extraction to
// the language adapter (C++ implements this; other languages opt out).
let parsedTemplateConstraints: unknown = undefined;
let constraintsTag = '';
if (
(nodeLabel === 'Function' || nodeLabel === 'Method') &&
provider.extractTemplateConstraints !== undefined &&
definitionNode !== null
) {
try {
parsedTemplateConstraints = provider.extractTemplateConstraints(definitionNode);
if (parsedTemplateConstraints !== undefined) {
constraintsTag = templateConstraintsIdTag(parsedTemplateConstraints);
}
} catch {
parsedTemplateConstraints = undefined;
constraintsTag = '';
}
}
const nodeId = generateId(
nodeLabel,
`${file.path}:${qualifiedName}${classTemplateTag}${arityTag}`,
`${file.path}:${qualifiedName}${classTemplateTag}${arityTag}${constraintsTag}`,
);
const classNodeForSymbol = definitionNodeForRange || definitionNode || nameNode;
const qualifiedTypeName =
@@ -689,6 +723,9 @@ const processParsingSequential = async (
...(classTemplateArguments !== undefined && classTemplateArguments.length > 0
? { templateArguments: classTemplateArguments }
: {}),
...(parsedTemplateConstraints !== undefined
? { templateConstraints: parsedTemplateConstraints }
: {}),
...(frameworkHint
? {
astFrameworkMultiplier: frameworkHint.entryPointMultiplier,
@@ -744,6 +781,7 @@ const processParsingSequential = async (
parameterCount: methodProps.parameterCount as number | undefined,
requiredParameterCount: methodProps.requiredParameterCount as number | undefined,
parameterTypes: methodProps.parameterTypes as string[] | undefined,
parameterTypeClasses: methodProps.parameterTypeClasses as ParameterTypeClass[] | undefined,
returnType: methodProps.returnType as string | undefined,
declaredType,
templateArguments: classTemplateArguments,
@@ -16,12 +16,18 @@ import {
} from '../call-processor.js';
import type { createResolutionContext } from '../model/resolution-context.js';
import { createASTCache } from '../ast-cache.js';
import { type PipelineProgress, getLanguageFromFilename } from 'gitnexus-shared';
import {
type PipelineProgress,
getLanguageFromFilename,
type SupportedLanguages,
} from 'gitnexus-shared';
import { readFileContents } from '../filesystem-walker.js';
import { isLanguageAvailable } from '../../tree-sitter/parser-loader.js';
import { isRegistryPrimary } from '../registry-primary-flag.js';
import { topologicalLevelSort } from '../utils/graph-sort.js';
import type { KnowledgeGraph } from '../../graph/types.js';
import { isDev } from '../utils/env.js';
import type Parser from 'tree-sitter';
import { logger } from '../../logger.js';
/** Max AST trees to keep in LRU cache for cross-file binding propagation. */
@@ -114,6 +120,36 @@ export async function runCrossFileBindingPropagation(
let crossFileResolved = 0;
const crossFileStart = Date.now();
const astCache = createASTCache(AST_CACHE_CAP);
// Compiled query objects keyed by language name. Shared across all processCalls
// invocations in this phase so the same tree-sitter query string is only
// compiled once per language instead of once per file (O(1) vs O(N)).
const compiledQueryCache = new Map<SupportedLanguages, Parser.Query>();
// Snapshot total topological candidates for progress math. We walk the
// levels once more here (fast — no I/O) so we can report meaningful
// percentages rather than a frozen display.
let totalCandidates = 0;
for (const level of levels) {
for (const filePath of level) {
if (totalCandidates >= MAX_CROSS_FILE_REPROCESS) break;
const imports = ctx.namedImportMap.get(filePath);
if (!imports) continue;
if (!allPathSet.has(filePath)) continue;
const lang = getLanguageFromFilename(filePath);
if (!lang || !isLanguageAvailable(lang)) continue;
// Registry-primary languages have their call resolution handled by the
// scope-resolution pipeline — processCalls skips them immediately. Skip
// here too so we avoid the I/O cost (readFileContents) and map-building
// overhead for files that would be no-ops anyway.
if (isRegistryPrimary(lang)) continue;
totalCandidates++;
}
if (totalCandidates >= MAX_CROSS_FILE_REPROCESS) break;
}
const cappedTotal = Math.min(totalCandidates, MAX_CROSS_FILE_REPROCESS);
/** Emit a progress event every PROGRESS_INTERVAL files so the UI stays alive. */
const PROGRESS_INTERVAL = 25;
for (const level of levels) {
const levelCandidates: {
@@ -151,6 +187,10 @@ export async function runCrossFileBindingPropagation(
const lang = getLanguageFromFilename(filePath);
if (!lang || !isLanguageAvailable(lang)) continue;
// Registry-primary languages have their call resolution handled by the
// scope-resolution pipeline — processCalls skips them immediately. Skip
// here to avoid readFileContents I/O and map-building for no-op files.
if (isRegistryPrimary(lang)) continue;
levelCandidates.push({ filePath, seeded, importedReturns, importedRawReturns });
}
@@ -188,8 +228,24 @@ export async function runCrossFileBindingPropagation(
bindings.size > 0 ? bindings : undefined,
importedReturnTypesMap.size > 0 ? importedReturnTypesMap : undefined,
importedRawReturnTypesMap.size > 0 ? importedRawReturnTypesMap : undefined,
undefined,
undefined,
compiledQueryCache,
);
crossFileResolved++;
// Emit progress every PROGRESS_INTERVAL files so the UI shows real
// movement instead of a frozen display (cross-file can take minutes
// on large repos with many cross-file imports).
if (crossFileResolved % PROGRESS_INTERVAL === 0 || crossFileResolved === cappedTotal) {
const pct = cappedTotal > 0 ? Math.round((crossFileResolved / cappedTotal) * 8) : 0;
onProgress({
phase: 'parsing',
percent: 82 + pct,
message: `Cross-file type propagation (${crossFileResolved}/${cappedTotal} files)...`,
stats: { filesProcessed: crossFileResolved, totalFiles, nodesCreated: graph.nodeCount },
});
}
}
if (crossFileResolved >= MAX_CROSS_FILE_REPROCESS) {
@@ -71,6 +71,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
import { isDev } from '../utils/env.js';
import { synthesizeWildcardImportBindings, needsSynthesis } from './wildcard-synthesis.js';
import { extractORMQueriesInline } from './orm-extraction.js';
import { hasWorkerUnsafeLanguages } from '../workers/worker-language-guard.js';
import { logger } from '../../logger.js';
// ── Constants ──────────────────────────────────────────────────────────────
@@ -315,6 +316,8 @@ export async function runChunkedParseAndResolve(
const parseCache = options?.parseCache;
let chunkCacheHits = 0;
let chunkCacheMisses = 0;
// Suppress duplicate warnings when multiple consecutive chunks contain C/C++ files.
let hasWarnedAboutCppChunks = false;
try {
for (let chunkIdx = 0; chunkIdx < numChunks; chunkIdx++) {
@@ -370,6 +373,25 @@ export async function runChunkedParseAndResolve(
// them under the chunk hash for the next run.
chunkCacheMisses++;
const rawResults: ParseWorkerResult[] = [];
// C/C++ grammar native bindings can crash worker threads with an
// unrecoverable Napi::Error / std::terminate(). Detect C/C++ files
// within this specific chunk and route them through the main-thread
// sequential path to avoid the crash. Other chunks (no C/C++) keep
// using the worker pool. GITNEXUS_ALLOW_CPP_WORKERS=1 opts out of
// this guard when a stable custom build is in use.
const chunkHasWorkerUnsafeFiles =
workerPool &&
process.env.GITNEXUS_ALLOW_CPP_WORKERS !== '1' &&
hasWorkerUnsafeLanguages(chunkFiles);
if (chunkHasWorkerUnsafeFiles && !hasWarnedAboutCppChunks) {
hasWarnedAboutCppChunks = true;
logger.warn(
`C/C++ files detected — parsing chunks containing C/C++ files in sequential mode to avoid known ` +
`worker-thread native binding errors (e.g. \`Napi::Error\`). C/C++ source files ` +
`are still fully indexed; chunks with no C/C++ continue using workers. ` +
`Set GITNEXUS_ALLOW_CPP_WORKERS=1 if you use a custom tree-sitter build without this issue.`,
);
}
chunkWorkerData = await processParsing(
graph,
chunkFiles,
@@ -391,7 +413,11 @@ export async function runChunkedParseAndResolve(
},
});
},
workerPool,
// For chunks containing C/C++ files, bypass the worker pool to avoid
// unrecoverable Napi::Error crashes from native grammar bindings in
// worker threads. processParsing falls through to processParsingSequential
// (main-thread safe). All other chunks keep using the worker pool.
chunkHasWorkerUnsafeFiles ? undefined : workerPool,
// Capture raw results only when we have a cache to write to —
// otherwise we'd retain extra arrays for nothing.
parseCache && chunkHash ? rawResults : undefined,
@@ -63,6 +63,7 @@ import type {
BindingRef,
CaptureMatch,
ImportEdge,
ParameterTypeClass,
ParsedFile,
ParsedImport,
ReferenceSite,
@@ -545,8 +546,12 @@ function buildDefFromDeclarationMatch(
const parameterCount = parseIntCapture(match['@declaration.parameter-count']);
const requiredParameterCount = parseIntCapture(match['@declaration.required-parameter-count']);
const parameterTypes = parseJsonStringArrayCapture(match['@declaration.parameter-types']);
const parameterTypeClasses = parseJsonParameterTypeClassesCapture(
match['@declaration.parameter-type-classes'],
);
const declaredType = match['@declaration.field-type']?.text;
const returnType = match['@declaration.return-type']?.text;
const templateConstraints = parseJsonCapture(match['@declaration.template-constraints']);
return {
nodeId: makeDefId(filePath, anchor.range, type, nameCap.text),
@@ -556,18 +561,79 @@ function buildDefFromDeclarationMatch(
...(parameterCount !== undefined ? { parameterCount } : {}),
...(requiredParameterCount !== undefined ? { requiredParameterCount } : {}),
...(parameterTypes !== undefined ? { parameterTypes } : {}),
...(parameterTypeClasses !== undefined ? { parameterTypeClasses } : {}),
...(declaredType !== undefined ? { declaredType } : {}),
...(returnType !== undefined ? { returnType } : {}),
...(templateArguments !== undefined ? { templateArguments } : {}),
...(templateConstraints !== undefined ? { templateConstraints } : {}),
};
}
/** Parse an opaque JSON payload synthesized by per-language captures
* (e.g. C++ `@declaration.template-constraints`). Producer owns the
* shape; shared code threads it through as `unknown` per the
* `SymbolDefinition.templateConstraints` contract. */
function parseJsonCapture(cap: { readonly text: string } | undefined): unknown {
if (cap === undefined) return undefined;
try {
return JSON.parse(cap.text);
} catch {
return undefined;
}
}
function parseIntCapture(cap: { readonly text: string } | undefined): number | undefined {
if (cap === undefined) return undefined;
const n = Number.parseInt(cap.text, 10);
return Number.isFinite(n) ? n : undefined;
}
function parseJsonParameterTypeClassesCapture(
cap: { readonly text: string } | undefined,
): ParameterTypeClass[] | undefined {
if (cap === undefined) return undefined;
try {
const parsed = JSON.parse(cap.text);
if (!Array.isArray(parsed)) return undefined;
const out: ParameterTypeClass[] = [];
for (const item of parsed) {
if (item === null || typeof item !== 'object') return undefined;
const o = item as Record<string, unknown>;
if (typeof o.base !== 'string') return undefined;
if (
o.cv !== 'none' &&
o.cv !== 'const' &&
o.cv !== 'volatile' &&
o.cv !== 'const volatile' &&
o.cv !== 'unknown'
) {
return undefined;
}
if (
o.indirection !== 'value' &&
o.indirection !== 'lvalue-ref' &&
o.indirection !== 'rvalue-ref' &&
o.indirection !== 'pointer' &&
o.indirection !== 'unknown'
) {
return undefined;
}
if (typeof o.pointerDepth !== 'number' || !Number.isFinite(o.pointerDepth)) {
return undefined;
}
out.push({
base: o.base,
cv: o.cv,
indirection: o.indirection,
pointerDepth: o.pointerDepth,
});
}
return out;
} catch {
return undefined;
}
}
function parseJsonStringArrayCapture(
cap: { readonly text: string } | undefined,
): string[] | undefined {
@@ -977,6 +1043,7 @@ const KNOWN_SUB_TAGS: ReadonlySet<string> = new Set<string>([
'@declaration.parameter-count',
'@declaration.required-parameter-count',
'@declaration.parameter-types',
'@declaration.template-constraints',
]);
/**
@@ -254,6 +254,7 @@
import type {
BindingRef,
Callsite,
ConstraintContext,
ParsedFile,
ScopeId,
SupportedLanguages,
@@ -264,6 +265,7 @@ import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
import { LanguageProvider } from '../../language-provider.js';
import { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import type { SemanticModel } from '../../model/semantic-model.js';
import type { ConversionRankFn } from '../passes/overload-narrowing.js';
/** A LinearizeStrategy receives the full ancestor map so C3-style
* algorithms (which need to merge each parent's MRO) can implement
@@ -278,6 +280,10 @@ export type LinearizeStrategy = (
/** Result of `ScopeResolver.arityCompatibility` — mirrors `RegistryProviders.arityCompatibility`. */
export type ArityVerdict = 'compatible' | 'unknown' | 'incompatible';
/** Re-exported for ScopeResolver consumers — same shape as
* `RegistryProviders.constraintCompatibility`'s third parameter. */
export type { ConstraintContext } from 'gitnexus-shared';
export interface ScopeResolver {
/** Identity for telemetry + per-language flag check. */
readonly language: SupportedLanguages;
@@ -373,6 +379,28 @@ export interface ScopeResolver {
*/
arityCompatibility(callsite: Callsite, def: SymbolDefinition): ArityVerdict;
/**
* Per-language constraint compatibility between a callsite and a
* candidate `def` that carries `templateConstraints` metadata.
* Mirrors `arityCompatibility` semantics: the three-valued verdict
* MUST treat `'unknown'` as keep-candidate (monotonicity — adding
* a predicate can only narrow correctly, never produce a wrong
* edge). Consulted by `narrowOverloadCandidates` after the arity
* and parameter-type filters.
*
* Optional. Languages without constrained-overload semantics
* (SFINAE, `requires` clauses, trait bounds, conditional types)
* leave this undefined and the constraint filter is a pass-through.
*
* C++ is the first consumer; see `languages/cpp/constraint-filter.ts`
* for the Tier-A predicate registry and Kleene 3-valued evaluator.
*/
readonly constraintCompatibility?: (
callsite: Callsite,
def: SymbolDefinition,
ctx: ConstraintContext,
) => ArityVerdict;
// ─── Per-language strategies ───────────────────────────────────────────────
/**
@@ -533,6 +561,20 @@ export interface ScopeResolver {
*/
readonly allowGlobalFreeCallFallback?: boolean;
/**
* Optional per-slot conversion-rank function for overload resolution.
* When provided, `narrowOverloadCandidates` uses ranked scoring as a
* fallback when the exact-type filter produces no match. The function
* returns a numeric cost (0 = exact, 1 = promotion, 2 = standard
* conversion, Infinity = incompatible) for converting an argument
* type to a parameter type.
*
* The conversion-rank table is language-specific (issue #1578 pitfall:
* keep it out of shared overload-narrowing). C++ provides
* `cppConversionRank`; other languages define their own if needed.
*/
readonly conversionRankFn?: ConversionRankFn;
/**
* Optional predicate to identify definitions with file-local linkage
* (e.g. C `static` functions). When provided, `pickUniqueGlobalCallable`
@@ -576,16 +618,15 @@ export interface ScopeResolver {
* Optional argument-dependent-lookup (ADL / Koenig lookup) hook for
* languages with C++-style associated-namespace candidate addition.
*
* Runs in the free-call fallback AFTER `findCallableBindingInScope`
* returns `undefined` and BEFORE `pickUniqueGlobalCallable`. The hook
* inspects the call site's argument types, computes the associated
* namespace set, and returns either:
* - a unique `SymbolDefinition` — emit the CALLS edge to it.
* - `'ambiguous'` — multiple candidates share normalized parameter
* types; the caller MUST suppress (zero edges). Mirrors the
* OVERLOAD_AMBIGUOUS sentinel from `overload-narrowing.ts`.
* - `undefined` — no ADL candidates; caller falls through to the
* global free-call fallback (`pickUniqueGlobalCallable`).
* Runs in the free-call fallback alongside ordinary unqualified lookup.
* The fallback merges ordinary candidates with ADL candidates and applies
* overload narrowing over the union.
*
* The hook inspects the call site's argument types, computes the
* associated namespace set, and returns either:
* - an array of candidate `SymbolDefinition`s to add to the
* ordinary-lookup candidate pool.
* - `undefined` when ADL contributes no candidates.
*
* Languages without C++-style ADL leave this undefined. The
* cross-language contract is "additive tier" — defining the hook never
@@ -601,7 +642,7 @@ export interface ScopeResolver {
callerParsed: ParsedFile,
scopes: ScopeResolutionIndexes,
parsedFiles: readonly ParsedFile[],
) => SymbolDefinition | 'ambiguous' | undefined;
) => readonly SymbolDefinition[] | undefined;
/**
* Optional resolver for qualified-receiver member calls where the
@@ -616,8 +657,9 @@ export interface ScopeResolver {
*
* Receiver-bound-calls invokes this hook AFTER Case 1 (namespace
* imports) and AFTER Case 2 (class-name receiver) fail to resolve.
* Returns the target def, or `undefined` to fall through to the
* remaining cases.
* Returns the target def, `'ambiguous'` when multiple inline-namespace
* children declare the same name (suppresses edge emission), or
* `undefined` to fall through to the remaining cases.
*/
readonly resolveQualifiedReceiverMember?: (
receiverName: string,
@@ -625,7 +667,7 @@ export interface ScopeResolver {
callerScope: ScopeId,
scopes: ScopeResolutionIndexes,
parsedFiles: readonly ParsedFile[],
) => SymbolDefinition | undefined;
) => SymbolDefinition | 'ambiguous' | undefined;
/**
* Enable the receiver-bound Case 0.5 fallback for explicit `this`
@@ -21,6 +21,7 @@ import type { NodeLabel, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { generateId } from '../../../../lib/utils.js';
import { qualifiedKey, simpleKey, type GraphNodeLookup } from '../graph-bridge/node-lookup.js';
import { templateConstraintsIdTag } from '../../utils/template-arguments.js';
/**
* Labels that may legitimately ANCHOR a CALLS/ACCESSES edge as the
* source ("caller"). A Variable / Property can be the TARGET of an
@@ -76,12 +77,31 @@ export function resolveDefGraphId(
type?: NodeLabel;
parameterTypes?: readonly string[];
templateArguments?: readonly string[];
templateConstraints?: unknown;
},
nodeLookup: GraphNodeLookup,
): string | undefined {
const qn = def.qualifiedName;
if (qn === undefined || qn.length === 0) return undefined;
if (def.type !== undefined) {
// SFINAE / `requires`-clause disambiguation (issue #1579) — try the
// constraint-fingerprinted key FIRST. Two function-template overloads
// with identical `parameterTypes` but mutually-exclusive SFINAE
// constraints route to their distinct graph nodes via this key.
// Must run before the parameter-types key because both overloads
// share the latter.
if (
(def.type === 'Function' || def.type === 'Method') &&
def.templateConstraints !== undefined
) {
const cKey = qualifiedKey(
filePath,
def.type,
`${qn}${templateConstraintsIdTag(def.templateConstraints)}`,
);
const cHit = nodeLookup.get(cKey);
if (cHit !== undefined) return cHit;
}
// Overload disambiguation: when the def carries parameter types,
// try the parameter-typed key first so same-name same-arity
// overloads route to their distinct graph nodes.
@@ -20,6 +20,7 @@
import type { NodeLabel } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../../graph/types.js';
import { templateConstraintsIdTag } from '../../utils/template-arguments.js';
export type GraphNodeLookup = ReadonlyMap<string, string>;
@@ -97,6 +98,21 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup {
// Each overload is unique — set unconditionally.
lookup.set(pKey, node.id);
}
// SFINAE / `requires`-clause disambiguation (issue #1579) — register
// a constraint-fingerprinted key so resolveDefGraphId can locate the
// correct overload by hashing the def's `templateConstraints`. Mirrors
// the parameter-types key but keys on the opaque constraint payload
// instead, separating two `process<T>` overloads whose
// `parameterTypes=['T']` would otherwise collide.
const tConstraints = (props as { templateConstraints?: unknown }).templateConstraints;
if (tConstraints !== undefined && (node.label === 'Function' || node.label === 'Method')) {
const cKey = qualifiedKey(
props.filePath,
node.label,
`${qualified}${templateConstraintsIdTag(tConstraints)}`,
);
lookup.set(cKey, node.id);
}
if (
(node.label === 'Class' ||
node.label === 'Struct' ||
@@ -23,9 +23,19 @@ import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexe
import type { SemanticModel } from '../../model/semantic-model.js';
import type { WorkspaceResolutionIndex } from '../workspace-index.js';
import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
import type { ScopeResolver } from '../contract/scope-resolver.js';
import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js';
import { findCallableBindingInScope, findClassBindingInScope } from '../scope/walkers.js';
import { narrowOverloadCandidates } from './overload-narrowing.js';
import {
findAllCallableBindingsInScope,
findCallableBindingInScope,
findCallableBindingsAndAdlBlocker,
findClassBindingInScope,
} from '../scope/walkers.js';
import {
isOverloadAmbiguousAfterNormalization,
narrowOverloadCandidates,
type ConversionRankFn,
} from './overload-narrowing.js';
export function emitFreeCallFallback(
graph: KnowledgeGraph,
@@ -55,7 +65,14 @@ export function emitFreeCallFallback(
callerParsed: ParsedFile,
scopes: ScopeResolutionIndexes,
parsedFiles: readonly ParsedFile[],
) => SymbolDefinition | 'ambiguous' | undefined;
) => readonly SymbolDefinition[] | undefined;
readonly conversionRankFn?: ConversionRankFn;
/** Optional per-language constraint hook threaded into
* `narrowOverloadCandidates`. Drops candidates whose template
* constraints (e.g. C++ `enable_if_t`, C++20 `requires`) provably
* fail at the call site. Three-valued; `'unknown'` keeps the
* candidate (monotonicity). */
readonly constraintCompatibility?: ScopeResolver['constraintCompatibility'];
} = {},
): number {
let emitted = 0;
@@ -83,44 +100,156 @@ export function emitFreeCallFallback(
// the same name in a single class, choose the best match by
// arity + argument types.
if (fnDef === undefined) {
fnDef = pickImplicitThisOverload(site, scopes, workspaceIndex, model);
fnDef = pickImplicitThisOverload(site, scopes, workspaceIndex, model, {
conversionRankFn: options.conversionRankFn,
constraintCompatibility: options.constraintCompatibility,
});
}
// Scope-chain callable lookup. First-match preserves scope-chain
// precedence (local shadows import). When a conversion-rank function
// is available AND the binding scope contains multiple overloads,
// refine with `narrowOverloadCandidates` to pick the best overload
// by argument types (#1578). The first-match result is kept as a
// fallback when narrowing is indeterminate.
if (fnDef === undefined) {
fnDef = findCallableBindingInScope(site.inScope, site.name, scopes);
}
// V1 ADL tier (C++ Koenig lookup, opt-in via provider.resolveAdlCandidates).
// Fires only when ordinary lookup is empty — V1 limitation per
// plan 2026-05-13-001 U2; ISO C++ would merge ADL with ordinary lookup
// and run overload resolution over the union.
//
// Sentinel 'ambiguous': ADL surfaced multiple candidates with
// identical normalized parameter types (mirrors OVERLOAD_AMBIGUOUS).
// We mark the site handled so `emit-references` does not retry, and
// continue to the next site without emitting an edge.
if (fnDef === undefined && options.resolveAdlCandidates !== undefined) {
const adlResult = options.resolveAdlCandidates(
{
name: site.name,
arity: site.arity,
argumentTypes: site.argumentTypes,
atRange: { startLine: site.atRange.startLine, startCol: site.atRange.startCol },
},
parsed,
scopes,
parsedFiles,
);
if (adlResult === 'ambiguous') {
handledSites.add(`${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`);
continue;
}
if (adlResult !== undefined) {
fnDef = adlResult;
if (options.resolveAdlCandidates === undefined) {
// Non-ADL path: first-match preserves scope-chain precedence
// (local shadows import). When a conversion-rank function is
// available AND the binding scope contains multiple overloads,
// refine with narrowOverloadCandidates (#1578).
fnDef = findCallableBindingInScope(site.inScope, site.name, scopes);
if (fnDef !== undefined && options.conversionRankFn !== undefined) {
const allCallables = findAllCallableBindingsInScope(site.inScope, site.name, scopes);
if (allCallables.length > 1) {
const narrowed = narrowOverloadCandidates(
allCallables,
site.arity,
site.argumentTypes,
{
conversionRankFn: options.conversionRankFn,
constraintCompatibility: options.constraintCompatibility,
},
);
if (narrowed.length === 1) {
fnDef = narrowed[0];
} else if (narrowed.length > 1) {
// Multiple survivors after conversion-rank scoring.
// Suppress when all candidates share the same file (true
// overloads) — mirrors ADL merged-candidate path behavior.
// Cross-file candidates are shadowing; keep first-match.
const sameFile = narrowed.every((d) => d.filePath === narrowed[0]!.filePath);
if (sameFile) {
handledSites.add(
`${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`,
);
continue;
}
}
// narrowed.length === 0: keep the first-match fnDef —
// preserves local-shadows-import.
}
}
} else {
// ADL path: ISO C++ `[basic.lookup.unqual]` §7 — ADL is suppressed
// when ordinary lookup finds a non-function name or a block-scope
// function declaration.
const {
callables: ordinary,
nonCallableFound,
blockScopeDeclFound,
} = findCallableBindingsAndAdlBlocker(site.inScope, site.name, scopes);
const adlSuppressed = nonCallableFound || blockScopeDeclFound;
const adl = adlSuppressed
? undefined
: options.resolveAdlCandidates(
{
name: site.name,
arity: site.arity,
argumentTypes: site.argumentTypes,
atRange: { startLine: site.atRange.startLine, startCol: site.atRange.startCol },
},
parsed,
scopes,
parsedFiles,
);
const siteKey = `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`;
if (adl === undefined || adl.length === 0) {
// No ADL contribution. Default behavior: `ordinary[0]` —
// scope-chain walk preserves local-shadows-import precedence.
//
// Narrowing kicks in when either disambiguation signal is
// present: any candidate carries `templateConstraints`
// (SFINAE / `requires`-clause guarded templates, #1579), OR
// a conversion-rank function is provided (#1606 / #1578).
// Both hooks are threaded into `narrowOverloadCandidates`
// via the unified `OverloadNarrowingHookCtx`.
const hasConstraints = ordinary.some((d) => d.templateConstraints !== undefined);
const canNarrow = hasConstraints || options.conversionRankFn !== undefined;
if (ordinary.length <= 1 || !canNarrow) {
fnDef = ordinary[0];
} else {
const narrowed = narrowOverloadCandidates(ordinary, site.arity, site.argumentTypes, {
conversionRankFn: options.conversionRankFn,
constraintCompatibility: options.constraintCompatibility,
});
if (narrowed.length === 1) {
fnDef = narrowed[0];
} else if (narrowed.length === 0) {
handledSites.add(siteKey);
continue;
} else {
// >1 survivors: same-file → suppress (true overloads,
// "degrade not lie" — no edge beats a wrong one, and
// SFINAE-ambiguous calls land here). Cross-file →
// first-match (shadowing semantics).
const sameFile = narrowed.every((d) => d.filePath === narrowed[0]!.filePath);
if (sameFile) {
handledSites.add(siteKey);
continue;
}
fnDef = ordinary[0];
}
}
} else {
const merged: SymbolDefinition[] = [];
const seenMerge = new Set<string>();
const push = (defs: readonly SymbolDefinition[]): void => {
for (const d of defs) {
if (seenMerge.has(d.nodeId)) continue;
seenMerge.add(d.nodeId);
merged.push(d);
}
};
push(ordinary);
push(adl);
const narrowed = narrowOverloadCandidates(merged, site.arity, site.argumentTypes, {
conversionRankFn: options.conversionRankFn,
constraintCompatibility: options.constraintCompatibility,
});
if (narrowed.length === 1) {
fnDef = narrowed[0];
} else if (narrowed.length === 0) {
handledSites.add(siteKey);
continue;
} else if (narrowed.length > 1) {
if (isOverloadAmbiguousAfterNormalization(narrowed, site.arity)) {
handledSites.add(siteKey);
continue;
}
// Multiple survivors remain after conversion-rank scoring;
// suppress instead of picking arbitrarily.
handledSites.add(siteKey);
continue;
}
}
}
}
// V1: pickUniqueGlobalCallable ignores import context — resolves to any
// globally-unique callable. False cross-package edges are possible when
// the caller does not import the target package. Same-package calls are
// caught by findCallableBindingInScope above before reaching here.
// usually caught by nearest-scope lookup before reaching here.
if (fnDef === undefined && options.allowGlobalFallback === true) {
fnDef = pickUniqueGlobalCallable(
site.name,
@@ -138,6 +267,8 @@ export function emitFreeCallFallback(
scopes,
})
: undefined,
site.argumentTypes,
options.conversionRankFn,
);
}
if (fnDef === undefined) continue;
@@ -176,6 +307,8 @@ function pickUniqueGlobalCallable(
isFileLocalDef?: (def: SymbolDefinition) => boolean,
callArity?: number,
isCallerVisible?: (candidate: SymbolDefinition) => boolean,
callArgTypes?: readonly string[],
conversionRankFn?: ConversionRankFn,
): SymbolDefinition | undefined {
const scopeDefs: SymbolDefinition[] = [];
const scopeSeen = new Set<string>();
@@ -210,6 +343,16 @@ function pickUniqueGlobalCallable(
const arityMatch = narrowByArity(scopeDefs, callArity);
if (arityMatch !== undefined) return arityMatch;
}
// When arity narrowing left >1 candidate, try overload narrowing with
// argument types + conversion ranking (#1578). This picks the unique
// best-rank candidate when exact-type or conversion-rank scoring can
// disambiguate (e.g., `f(int)` vs `f(double)` called with `f(2.5)`).
if (scopeDefs.length > 1) {
const narrowed = narrowOverloadCandidates(scopeDefs, callArity, callArgTypes, {
conversionRankFn,
});
if (narrowed.length === 1) return narrowed[0];
}
const defs: SymbolDefinition[] = [];
const seen = new Set<string>();
@@ -243,6 +386,13 @@ function pickUniqueGlobalCallable(
const arityMatch = narrowByArity(defs, callArity);
if (arityMatch !== undefined) return arityMatch;
}
// Same argument-type + conversion-rank narrowing for the model pool.
if (defs.length > 1) {
const narrowed = narrowOverloadCandidates(defs, callArity, callArgTypes, {
conversionRankFn,
});
if (narrowed.length === 1) return narrowed[0];
}
return undefined;
}
@@ -316,6 +466,10 @@ export function pickImplicitThisOverload(
scopes: ScopeResolutionIndexes,
workspaceIndex: WorkspaceResolutionIndex,
model: SemanticModel,
hookCtx?: {
readonly conversionRankFn?: ConversionRankFn;
readonly constraintCompatibility?: ScopeResolver['constraintCompatibility'];
},
): SymbolDefinition | undefined {
// Find the enclosing Class scope by walking parents.
let curId: ScopeId | null = site.inScope;
@@ -343,7 +497,10 @@ export function pickImplicitThisOverload(
// ambiguous narrowing (multiple compatible candidates with no
// disambiguating signal) leaves the call unresolved rather than
// routing to an arbitrary first overload by registration order.
const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes);
const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes, {
conversionRankFn: hookCtx?.conversionRankFn,
constraintCompatibility: hookCtx?.constraintCompatibility,
});
if (candidates.length !== 1) return undefined;
return candidates[0];
}
@@ -24,15 +24,63 @@
* equality. An empty string in `argTypes[i]` means "unknown" and
* counts as a match. Mismatches disqualify. A non-empty typed
* result wins; otherwise return the arity-filtered candidates.
* 4b. When the exact-type filter from step 4 returns empty AND a
* `conversionRankFn` is provided (via `hookCtx`), rank candidates
* via pairwise dominance comparison (ISO C++ [over.ics.rank]):
* F1 beats F2 only when F1 is not worse for every arg and better
* for at least one. Non-dominated candidates are returned;
* multiple survivors are genuinely ambiguous.
* 4c. Final per-candidate constraint filter (SFINAE / `requires`).
* When `constraintCompatibility` is provided via `hookCtx`, drop
* candidates whose template constraints provably fail at the
* call site. Three-valued; `'unknown'` keeps the candidate
* (monotonicity).
* 5. Empty input returns empty output.
*/
import type { SymbolDefinition } from 'gitnexus-shared';
import type { ArityVerdict, Callsite, ConstraintContext, SymbolDefinition } from 'gitnexus-shared';
/**
* Per-slot conversion-rank function. Returns a numeric cost for
* converting `argType` to `paramType`:
* - 0 = exact match (no conversion)
* - 1 = promotion (e.g. char→int, bool→int in C++)
* - 2 = standard conversion (e.g. int→double)
* - Infinity = incompatible types
*
* Each language provides its own implementation. The function operates
* on normalized type strings (output of the language's type normalizer).
*/
export type ConversionRankFn = (argType: string, paramType: string) => number;
/**
* Optional hook bundle for narrowing extension points. Threaded in
* from `pickOverload` / `pickImplicitThisOverload` so per-language
* narrowing can layer in conversion-rank scoring (#1606) and
* constraint filtering (#1579) without changing the call signature
* at every site. Each hook is independently optional — leaving both
* undefined preserves the legacy arity + exact-type behavior.
*/
export interface OverloadNarrowingHookCtx {
/** Conversion-rank scoring fallback (step 4b). Engages when the
* exact-type filter rejects every candidate. */
readonly conversionRankFn?: ConversionRankFn;
/** Constraint filter (step 4c). Drops candidates whose template
* guards (SFINAE `enable_if_t`, C++20 `requires`, future Rust
* trait bounds, etc.) provably fail at the call site. Three-valued
* — `'unknown'` keeps the candidate (monotonicity). */
readonly constraintCompatibility?: (
callsite: Callsite,
def: SymbolDefinition,
ctx: ConstraintContext,
) => ArityVerdict;
}
export function narrowOverloadCandidates(
overloads: readonly SymbolDefinition[],
argCount: number | undefined,
argTypes: readonly string[] | undefined,
hookCtx?: OverloadNarrowingHookCtx,
): readonly SymbolDefinition[] {
if (overloads.length === 0) return [];
@@ -73,6 +121,7 @@ export function narrowOverloadCandidates(
const candidates: readonly SymbolDefinition[] =
arityMatches.length > 0 ? arityMatches : anyUnknownBounds ? overloads : [];
let result: readonly SymbolDefinition[] = candidates;
if (argTypes !== undefined && argTypes.length > 0) {
const typed = candidates.filter((d) => {
const params = d.parameterTypes;
@@ -83,10 +132,121 @@ export function narrowOverloadCandidates(
}
return true;
});
if (typed.length > 0) return typed;
if (typed.length > 0) {
result = typed;
} else if (hookCtx?.conversionRankFn !== undefined) {
// ── Conversion-rank scoring (step 4b) ──────────────────────────
// The exact-type filter rejected every candidate. Rank via
// pairwise dominance: F1 beats F2 only when F1 is not worse for
// every arg and better for at least one. Non-dominated candidates
// are returned; multiple survivors are genuinely ambiguous. When
// ranking also yields empty, fall through to the arity-filtered
// `candidates` set — matches pre-#1606 behavior.
const ranked = rankByConversion(candidates, argTypes, hookCtx.conversionRankFn);
if (ranked.length > 0) result = ranked;
}
}
return candidates;
// Constraint filter (step 4c; Tier-A — SFINAE / `requires` clauses).
// Runs after arity, exact-type, and conversion-rank filters so the
// hook only sees candidates already viable on the other axes.
// Three-valued: `'compatible'` and `'unknown'` keep the candidate
// (monotonicity — adding a predicate must never cause a wrong edge);
// only `'incompatible'` drops it. Candidates without
// `templateConstraints` are always kept.
//
// No fallback to the unconstrained set when this filter empties the
// candidate list: a fully-`'incompatible'` verdict is authoritative.
// The downstream `OVERLOAD_AMBIGUOUS` sentinel still guards the empty
// case, so a buggy hook that wrongly returns `'incompatible'` for
// every candidate degrades to today's "suppress edge" behavior rather
// than emitting a wrong edge.
if (hookCtx?.constraintCompatibility !== undefined && argCount !== undefined) {
const callsite: Callsite = { arity: argCount };
const ctx: ConstraintContext = argTypes !== undefined ? { argumentTypes: argTypes } : {};
result = result.filter((def) => {
if (def.templateConstraints === undefined) return true;
return hookCtx.constraintCompatibility!(callsite, def, ctx) !== 'incompatible';
});
}
return result;
}
/**
* Pairwise dominance comparison (ISO C++ [over.ics.rank]).
*
* F1 is a better match than F2 when F1's conversion rank is **not
* worse** for every argument AND **strictly better** for at least one.
* Candidates dominated by any other viable candidate are removed.
* If more than one non-dominated candidate remains, they are genuinely
* ambiguous — callers suppress the edge rather than picking arbitrarily.
*
* Candidates with at least one `Infinity`-ranked slot (incompatible
* type) are excluded before pairwise comparison begins.
*/
function rankByConversion(
candidates: readonly SymbolDefinition[],
argTypes: readonly string[],
rankFn: ConversionRankFn,
): readonly SymbolDefinition[] {
// Step 1: compute per-slot ranks and exclude non-viable candidates.
const viable: Array<{ def: SymbolDefinition; ranks: number[] }> = [];
for (const d of candidates) {
const params = d.parameterTypes;
if (params === undefined) continue;
const ranks: number[] = [];
let ok = true;
for (let i = 0; i < argTypes.length && i < params.length; i++) {
if (argTypes[i] === '') {
ranks.push(0); // unknown arg → any-match (rank 0)
continue;
}
const r = rankFn(argTypes[i], params[i]);
if (!isFinite(r)) {
ok = false;
break;
}
ranks.push(r);
}
if (!ok) continue;
viable.push({ def: d, ranks });
}
if (viable.length <= 1) return viable.map((v) => v.def);
// Step 2: pairwise dominance — remove candidates dominated by any other.
const dominated = new Set<number>();
for (let i = 0; i < viable.length; i++) {
if (dominated.has(i)) continue;
for (let j = i + 1; j < viable.length; j++) {
if (dominated.has(j)) continue;
const cmp = pairwiseCompare(viable[i].ranks, viable[j].ranks);
if (cmp < 0)
dominated.add(j); // i dominates j
else if (cmp > 0) dominated.add(i); // j dominates i
}
}
return viable.filter((_, idx) => !dominated.has(idx)).map((v) => v.def);
}
/**
* Compare two per-slot rank vectors.
* Returns -1 if `a` dominates `b` (not worse everywhere, better somewhere),
* +1 if `b` dominates `a`,
* 0 if neither dominates (incomparable or equal).
*/
function pairwiseCompare(a: readonly number[], b: readonly number[]): -1 | 0 | 1 {
let aBetter = false;
let bBetter = false;
const len = Math.min(a.length, b.length);
for (let i = 0; i < len; i++) {
if (a[i] < b[i]) aBetter = true;
else if (b[i] < a[i]) bBetter = true;
if (aBetter && bBetter) return 0; // incomparable — early exit
}
if (aBetter && !bBetter) return -1;
if (bBetter && !aBetter) return 1;
return 0;
}
/**
@@ -73,6 +73,8 @@ type ReceiverBoundProviderSubset = Pick<
| 'hoistTypeBindingsToModule'
| 'resolveQualifiedReceiverMember'
| 'resolveThisViaEnclosingClass'
| 'conversionRankFn'
| 'constraintCompatibility'
>;
function normalizeTemplateArgToken(value: string): string {
@@ -343,6 +345,10 @@ export function emitReceiverBoundCalls(
methodOverloads,
site.arity,
site.argumentTypes,
{
conversionRankFn: provider.conversionRankFn,
constraintCompatibility: provider.constraintCompatibility,
},
);
if (isOverloadAmbiguousAfterNormalization(narrowed, site.arity)) {
ambiguous = true;
@@ -356,6 +362,12 @@ export function emitReceiverBoundCalls(
hiddenByName = true;
break;
}
// Multiple tied survivors with distinct param types (e.g.
// h(int,double) vs h(double,int) both scoring 2) → ambiguous.
if (narrowed.length > 1) {
ambiguous = true;
break;
}
memberDef = narrowed[0] ?? methodOverloads[0];
break;
}
@@ -445,6 +457,12 @@ export function emitReceiverBoundCalls(
scopes,
parsedFiles,
);
if (memberDef === 'ambiguous') {
// Same-name ambiguity across inline-namespace children (#1564):
// suppress edge emission, mark site handled.
handledSites.add(siteKey);
continue;
}
if (memberDef !== undefined) {
const ok = tryEmitEdge(
graph,
@@ -634,7 +652,7 @@ export function emitReceiverBoundCalls(
let memberDef: SymbolDefinition | undefined;
let ambiguous = false;
for (const ownerId of chain) {
const picked = pickOverload(ownerId, memberName, site, model);
const picked = pickOverload(ownerId, memberName, site, model, provider);
if (picked === OVERLOAD_AMBIGUOUS) {
ambiguous = true;
break;
@@ -702,6 +720,7 @@ function pickOverload(
memberName: string,
site: ParsedFile['referenceSites'][number],
model: SemanticModel,
provider: ReceiverBoundProviderSubset,
): SymbolDefinition | typeof OVERLOAD_AMBIGUOUS | undefined {
const overloads = model.methods.lookupAllByOwner(ownerId, memberName);
if (overloads.length === 0) {
@@ -712,7 +731,10 @@ function pickOverload(
}
if (overloads.length === 1) return overloads[0];
const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes);
const candidates = narrowOverloadCandidates(overloads, site.arity, site.argumentTypes, {
conversionRankFn: provider.conversionRankFn,
constraintCompatibility: provider.constraintCompatibility,
});
// When narrowing leaves >1 candidate that share identical normalized
// parameter-types (e.g., C++ `f(int)` vs `f(long)` both collapsed to
// `['int']` by `normalizeCppParamType`), suppress the edge entirely.
@@ -720,6 +742,11 @@ function pickOverload(
// would arbitrarily pick a candidate and lie about the call's target.
// PR #1520 review follow-up plan U2 / Claude review Finding 5.
if (isOverloadAmbiguousAfterNormalization(candidates, site.arity)) return OVERLOAD_AMBIGUOUS;
// When conversion-rank scoring leaves >1 tied candidate with distinct
// parameter types (e.g. h(int,double) vs h(double,int) both scoring 2),
// suppress rather than picking arbitrarily — C++ would call this
// ambiguous. Mirrors ADL merged-candidate suppression behavior.
if (candidates.length > 1) return OVERLOAD_AMBIGUOUS;
return candidates[0] ?? overloads[0];
}
@@ -382,6 +382,8 @@ export function runScopeResolution(
isFileLocalDef: provider.isFileLocalDef,
isCallableVisibleFromCaller: provider.isCallableVisibleFromCaller,
resolveAdlCandidates: provider.resolveAdlCandidates,
conversionRankFn: provider.conversionRankFn,
constraintCompatibility: provider.constraintCompatibility,
},
);
const { emitted, skipped } = emitReferencesViaLookup(
@@ -226,33 +226,132 @@ export function findCallableBindingInScope(
callableName: string,
scopes: ScopeResolutionIndexes,
): SymbolDefinition | undefined {
return findAllCallableBindingsInScope(startScope, callableName, scopes)[0];
}
/**
* Look up all callable bindings (Function/Method/Constructor) by name
* from the nearest scope in the chain that binds `callableName`.
*
* Preserves the original scope-walk boundary used by
* `findCallableBindingInScope`: once any callable binding is found in a
* scope, outer scopes are not consulted.
*/
export function findAllCallableBindingsInScope(
startScope: ScopeId,
callableName: string,
scopes: ScopeResolutionIndexes,
): readonly SymbolDefinition[] {
let currentId: ScopeId | null = startScope;
const visited = new Set<ScopeId>();
while (currentId !== null) {
if (visited.has(currentId)) return undefined;
if (visited.has(currentId)) return [];
visited.add(currentId);
const scope = scopes.scopeTree.getScope(currentId);
if (scope === undefined) return undefined;
if (scope === undefined) return [];
const out: SymbolDefinition[] = [];
const seen = new Set<string>();
const pushCallable = (def: SymbolDefinition): void => {
if (def.type !== 'Function' && def.type !== 'Method' && def.type !== 'Constructor') return;
if (seen.has(def.nodeId)) return;
seen.add(def.nodeId);
out.push(def);
};
const localBindings = scope.bindings.get(callableName);
if (localBindings !== undefined) {
for (const b of localBindings) {
if (b.def.type === 'Function' || b.def.type === 'Method' || b.def.type === 'Constructor') {
return b.def;
}
pushCallable(b.def);
}
}
const importedBindings = lookupBindingsAt(currentId, callableName, scopes);
for (const b of importedBindings) {
if (b.def.type === 'Function' || b.def.type === 'Method' || b.def.type === 'Constructor') {
return b.def;
pushCallable(b.def);
}
if (out.length > 0) return out;
currentId = scope.parent;
}
return [];
}
/**
* ISO C++ `[basic.lookup.unqual]` §7: ADL is suppressed when ordinary
* unqualified lookup finds:
* - a name that is NOT a function or function template, OR
* - a block-scope function declaration that is NOT a using-declaration.
*
* Combined walker that stops at the **nearest scope** where `name` has any
* binding (callable or non-callable) and returns:
* - `callables`: Function/Method/Constructor defs found at that scope
* - `nonCallableFound`: a non-function binding was present (variable, class, etc.)
* - `blockScopeDeclFound`: a callable was found at a Function or Block scope
* (block-scope function declaration that blocks ADL)
*
* One pass, one stop — no divergence between callable collection and blocker
* detection.
*/
export function findCallableBindingsAndAdlBlocker(
startScope: ScopeId,
name: string,
scopes: ScopeResolutionIndexes,
): {
callables: readonly SymbolDefinition[];
nonCallableFound: boolean;
blockScopeDeclFound: boolean;
} {
let currentId: ScopeId | null = startScope;
const visited = new Set<ScopeId>();
while (currentId !== null) {
if (visited.has(currentId))
return { callables: [], nonCallableFound: false, blockScopeDeclFound: false };
visited.add(currentId);
const scope = scopes.scopeTree.getScope(currentId);
if (scope === undefined)
return { callables: [], nonCallableFound: false, blockScopeDeclFound: false };
const callables: SymbolDefinition[] = [];
const seen = new Set<string>();
let nonCallableFound = false;
let anyBinding = false;
const process = (def: SymbolDefinition): void => {
anyBinding = true;
if (def.type === 'Function' || def.type === 'Method' || def.type === 'Constructor') {
if (!seen.has(def.nodeId)) {
seen.add(def.nodeId);
callables.push(def);
}
} else {
nonCallableFound = true;
}
};
const localBindings = scope.bindings.get(name);
if (localBindings !== undefined) {
for (const b of localBindings) {
process(b.def);
}
}
const importedBindings = lookupBindingsAt(currentId, name, scopes);
for (const b of importedBindings) {
process(b.def);
}
if (anyBinding) {
// ISO C++: a block-scope function declaration (Function or Block scope)
// that is NOT a using-declaration blocks ADL. If we found callables at
// a function/block scope, ADL must be suppressed.
const blockScopeDeclFound =
callables.length > 0 && (scope.kind === 'Function' || scope.kind === 'Block');
return { callables, nonCallableFound, blockScopeDeclFound };
}
currentId = scope.parent;
}
return undefined;
return { callables: [], nonCallableFound: false, blockScopeDeclFound: false };
}
/**
@@ -55,3 +55,34 @@ export function templateArgumentsIdTag(templateArguments?: readonly string[]): s
if (templateArguments === undefined || templateArguments.length === 0) return '';
return `~${templateArguments.join(',')}`;
}
/**
* Stable short hash for the opaque `SymbolDefinition.templateConstraints`
* payload (issue #1579). Two function-template overloads with identical
* `parameterTypes` but mutually-exclusive SFINAE constraints
* (`enable_if_t<is_integral_v<T>>` vs `enable_if_t<is_floating_point_v<T>>`)
* must produce distinct graph node IDs so the constraint-filter step
* has two candidates to narrow between. Without this they collapse to
* a single Function node and the SFINAE golden case can only emit one
* edge regardless of resolver fixes.
*
* FNV-1a 32-bit, base36 encoded. Deterministic; non-cryptographic — the
* tag's job is collision-avoidance among same-name overloads in one
* file, not security.
*/
export function constraintsHash(jsonText: string): string {
let h = 0x811c9dc5;
for (let i = 0; i < jsonText.length; i++) {
h ^= jsonText.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return (h >>> 0).toString(36);
}
/** Build the `~c:<hash>` ID suffix from an opaque constraint payload.
* Returns empty string when the payload is absent so callers can
* string-concatenate unconditionally. */
export function templateConstraintsIdTag(payload: unknown): string {
if (payload === undefined || payload === null) return '';
return `~c:${constraintsHash(JSON.stringify(payload))}`;
}
@@ -71,7 +71,7 @@ import {
isVueSetupTopLevel,
} from '../vue-sfc-extractor.js';
import type { NamedBinding } from '../named-bindings/types.js';
import type { NodeLabel } from 'gitnexus-shared';
import type { NodeLabel, ParameterTypeClass } from 'gitnexus-shared';
import type { FieldInfo, FieldExtractorContext } from '../field-types.js';
import type { MethodInfo, MethodExtractorContext } from '../method-types.js';
import type { VariableExtractorContext } from '../variable-types.js';
@@ -128,6 +128,7 @@ interface ParsedSymbol {
parameterCount?: number;
requiredParameterCount?: number;
parameterTypes?: string[];
parameterTypeClasses?: ParameterTypeClass[];
returnType?: string;
declaredType?: string;
templateArguments?: string[];
@@ -2306,6 +2307,7 @@ const processFileGroup = (
parameterCount: methodProps.parameterCount as number | undefined,
requiredParameterCount: methodProps.requiredParameterCount as number | undefined,
parameterTypes: methodProps.parameterTypes as string[] | undefined,
parameterTypeClasses: methodProps.parameterTypeClasses as ParameterTypeClass[] | undefined,
returnType: methodProps.returnType as string | undefined,
...(declaredType !== undefined ? { declaredType } : {}),
...(classTemplateArguments !== undefined && classTemplateArguments.length > 0
@@ -0,0 +1,12 @@
import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared';
const WORKER_UNSAFE_LANGUAGES = new Set<SupportedLanguages>([
SupportedLanguages.C,
SupportedLanguages.CPlusPlus,
]);
export const hasWorkerUnsafeLanguages = (files: ReadonlyArray<{ path: string }>): boolean =>
files.some((f) => {
const language = getLanguageFromFilename(f.path);
return language !== null && WORKER_UNSAFE_LANGUAGES.has(language);
});
+251 -27
View File
@@ -1,5 +1,5 @@
import fs from 'fs/promises';
import { createReadStream, createWriteStream } from 'fs';
import { createReadStream, createWriteStream, constants as fsConstants } from 'fs';
import { createInterface } from 'readline';
import { once } from 'events';
import { finished } from 'stream/promises';
@@ -21,7 +21,9 @@ import {
closeLbugConnection,
isDbBusyError,
isOpenRetryExhausted,
isWalCorruptionError,
openLbugConnection,
WAL_RECOVERY_SUGGESTION,
waitForWindowsHandleRelease,
type LbugConnectionHandle,
} from './lbug-config.js';
@@ -152,6 +154,7 @@ export const splitRelCsvByLabelPair = async (
let db: lbug.Database | null = null;
let conn: lbug.Connection | null = null;
let currentDbPath: string | null = null;
let currentDbReadOnly = false;
let ftsLoaded = false;
let vectorExtensionLoaded = false;
@@ -201,6 +204,163 @@ export const isReadOnlyDbError = (err: unknown): boolean => {
return /read-only database/i.test(msg);
};
const isMissingFileError = (err: unknown): boolean => {
const errno = err as NodeJS.ErrnoException;
return errno?.code === 'ENOENT';
};
const extractErrnoCode = (err: unknown): string | undefined => {
const errno = err as NodeJS.ErrnoException;
return errno?.code;
};
const MAX_LOGGED_ERROR_MESSAGE_LENGTH = 160;
const summarizeError = (err: unknown): string =>
(err instanceof Error ? err.message : String(err)).slice(0, MAX_LOGGED_ERROR_MESSAGE_LENGTH);
// ---------------------------------------------------------------------------
// Cross-process init lock
//
// Prevents a TOCTOU race in orphan sidecar cleanup: between checking that
// the main DB file is missing and unlinking sidecars, another process could
// create a fresh DB. The lock file (`${dbPath}.init.lock`) is created with
// O_CREAT | O_EXCL (atomic create-or-fail) and contains the owning PID +
// timestamp so stale locks from crashed processes can be reclaimed.
// ---------------------------------------------------------------------------
/** Maximum age (ms) before an init lock is considered stale. */
const INIT_LOCK_STALE_MS = 30_000;
/** Maximum attempts to acquire the init lock before giving up. */
const INIT_LOCK_MAX_ATTEMPTS = 6;
/** Delay between lock-acquisition retries (ms). */
const INIT_LOCK_RETRY_DELAY_MS = 500;
const initLockPath = (dbPath: string): string => `${dbPath}.init.lock`;
/**
* Returns true when the process identified by `pid` is still running.
* Uses `process.kill(pid, 0)` which sends signal 0 (a no-op probe) —
* it throws ESRCH when the process does not exist.
*/
const isProcessAlive = (pid: number): boolean => {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
};
/**
* Try to break a stale lock whose owning process has exited.
* Returns `true` if the stale lock was removed (caller should retry acquire).
* Returns `false` if the lock is still valid (another live process owns it).
*/
const tryBreakStaleLock = async (lockPath: string): Promise<boolean> => {
try {
const content = await fs.readFile(lockPath, 'utf-8');
const parsed = JSON.parse(content) as { pid?: number; ts?: number };
// If the owning process is still alive AND the lock is not stale, don't break.
if (typeof parsed.pid === 'number' && isProcessAlive(parsed.pid)) {
// Even a live process's lock can be stale if it's been held too long
// (e.g. the process is hung). Check the timestamp.
if (typeof parsed.ts === 'number' && Date.now() - parsed.ts < INIT_LOCK_STALE_MS) {
return false;
}
}
// PID is gone or lock exceeded INIT_LOCK_STALE_MS — reclaim it.
await fs.unlink(lockPath);
logger.warn(
`GitNexus: removed stale init lock (pid=${parsed.pid ?? '?'}, age=${typeof parsed.ts === 'number' ? `${Date.now() - parsed.ts}ms` : '?'})`,
);
return true;
} catch (err) {
// Lock file disappeared between our read and unlink, or is unreadable.
// Either way, let the caller retry the acquire.
if (isMissingFileError(err)) return true;
// Permission error or corrupt content — log and let caller retry.
const code = extractErrnoCode(err);
logger.warn(
`GitNexus: unable to inspect init lock (${code ?? 'UNKNOWN'}): ${summarizeError(err)}`,
);
return false;
}
};
/**
* Acquire a cross-process init lock for `dbPath`.
* Uses `O_CREAT | O_EXCL` for atomic create-or-fail semantics.
*
* Returns a release function that removes the lock file. The release
* function is idempotent and safe to call even if the lock was already
* cleaned up externally.
*
* Throws if the lock cannot be acquired after `INIT_LOCK_MAX_ATTEMPTS`.
*/
export const acquireInitLock = async (dbPath: string): Promise<() => Promise<void>> => {
const lockPath = initLockPath(dbPath);
const payload = JSON.stringify({ pid: process.pid, ts: Date.now() });
// Ensure the parent directory exists before creating the lock file.
// On a fresh repo the `.gitnexus/` directory may not exist yet, and
// fs.open with O_CREAT | O_EXCL would fail with ENOENT.
await fs.mkdir(path.dirname(lockPath), { recursive: true });
for (let attempt = 1; attempt <= INIT_LOCK_MAX_ATTEMPTS; attempt++) {
try {
const handle = await fs.open(
lockPath,
fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY,
);
await handle.writeFile(payload);
await handle.close();
// Return the idempotent release function
return async () => {
try {
await fs.unlink(lockPath);
} catch (err) {
if (!isMissingFileError(err)) {
const code = extractErrnoCode(err);
logger.warn(
`GitNexus: failed to release init lock (${code ?? 'UNKNOWN'}): ${summarizeError(err)}`,
);
}
}
};
} catch (err) {
if ((err as NodeJS.ErrnoException)?.code !== 'EEXIST') {
throw err; // Unexpected error — propagate immediately
}
// Lock file exists — check if it's stale
const broken = await tryBreakStaleLock(lockPath);
if (broken && attempt < INIT_LOCK_MAX_ATTEMPTS) {
continue; // Stale lock removed — retry immediately
}
if (attempt === INIT_LOCK_MAX_ATTEMPTS) {
throw new Error(
`GitNexus: unable to acquire init lock after ${INIT_LOCK_MAX_ATTEMPTS} attempts — ` +
`another gitnexus process may be initializing the same database (${lockPath})`,
);
}
// Live process holds the lock — wait and retry
await new Promise((resolve) => setTimeout(resolve, INIT_LOCK_RETRY_DELAY_MS));
}
}
// Unreachable — loop always throws or returns
throw new Error('GitNexus: init lock acquisition failed unexpectedly');
};
/** Exported for testing — returns the lock file path for a given dbPath. */
export const _initLockPathForTest = initLockPath;
const runWithSessionLock = async <T>(operation: () => Promise<T>): Promise<T> => {
const previous = sessionLock;
let release: (() => void) | null = null;
@@ -289,12 +449,17 @@ export const initLbug = async (dbPath: string) => {
* database is busy (e.g. `gitnexus analyze` holds the write lock).
* Each retry waits DB_LOCK_RETRY_DELAY_MS * attempt milliseconds.
*/
export const withLbugDb = async <T>(dbPath: string, operation: () => Promise<T>): Promise<T> => {
export const withLbugDb = async <T>(
dbPath: string,
operation: () => Promise<T>,
options: { readOnly?: boolean } = {},
): Promise<T> => {
let lastError: unknown;
const readOnly = options.readOnly === true;
for (let attempt = 1; attempt <= DB_LOCK_RETRY_ATTEMPTS; attempt++) {
try {
return await runWithSessionLock(async () => {
await ensureLbugInitialized(dbPath);
await ensureLbugInitialized(dbPath, readOnly);
return operation();
});
} catch (err) {
@@ -324,15 +489,15 @@ export const withLbugDb = async <T>(dbPath: string, operation: () => Promise<T>)
throw lastError;
};
const ensureLbugInitialized = async (dbPath: string) => {
if (conn && currentDbPath === dbPath) {
const ensureLbugInitialized = async (dbPath: string, readOnly: boolean = false) => {
if (conn && currentDbPath === dbPath && currentDbReadOnly === readOnly) {
return { db, conn };
}
await doInitLbug(dbPath);
await doInitLbug(dbPath, readOnly);
return { db, conn };
};
const doInitLbug = async (dbPath: string) => {
const doInitLbug = async (dbPath: string, readOnly: boolean = false) => {
// Different database requested — close the old one first
if (conn || db) {
await safeClose();
@@ -364,17 +529,67 @@ const doInitLbug = async (dbPath: string) => {
await fs.rm(dbPath, { recursive: true, force: true });
}
// If it's a file, assume it's an existing LadybugDB database - LadybugDB will open it
} catch {
} catch (err) {
if (!isMissingFileError(err)) {
throw err;
}
// Path doesn't exist, which is what LadybugDB wants for a new database
}
// Ensure parent directory exists
const parentDir = path.dirname(dbPath);
await fs.mkdir(parentDir, { recursive: true });
// ---------------------------------------------------------------------------
// Cross-process critical section: acquire init lock, clean orphan sidecars,
// and open the database. The lock prevents a TOCTOU race where another
// process could create a fresh DB between our access() check and the
// unlink() of stale sidecars.
// ---------------------------------------------------------------------------
const releaseInitLock = await acquireInitLock(dbPath);
try {
// Crash-recovery cleanup: if the main DB file is missing, stale sidecars
// from an interrupted run can block fresh opens indefinitely.
try {
await fs.access(dbPath);
} catch (err) {
if (isMissingFileError(err)) {
// `.shadow` is documented by LadybugDB checkpointing and `.wal.checkpoint`
// was observed in the #1618 crash loop that motivated this recovery path.
const orphanSidecars = [`${dbPath}.shadow`, `${dbPath}.wal.checkpoint`];
for (const sidecar of orphanSidecars) {
try {
await fs.unlink(sidecar);
logger.warn(
`GitNexus: removed orphan sidecar ${path.basename(sidecar)} (no main DB file present)`,
);
} catch (err) {
if (isMissingFileError(err)) {
continue;
}
const code = extractErrnoCode(err);
logger.warn(
`GitNexus: failed to remove orphan sidecar ${path.basename(sidecar)} (${code ?? 'UNKNOWN'}) while main DB file is missing; LadybugDB open may still fail: ${summarizeError(err)}`,
);
}
}
} else {
const code = extractErrnoCode(err);
logger.warn(
`GitNexus: unable to verify main DB file before orphan sidecar cleanup (${code ?? 'UNKNOWN'}); skipping cleanup: ${summarizeError(err)}`,
);
}
}
const opened = await openLbugConnection(lbug, dbPath);
db = opened.db;
conn = opened.conn;
// Ensure parent directory exists
const parentDir = path.dirname(dbPath);
await fs.mkdir(parentDir, { recursive: true });
const opened = readOnly
? await openLbugConnection(lbug, dbPath, { readOnly: true })
: await openLbugConnection(lbug, dbPath);
db = opened.db;
conn = opened.conn;
currentDbReadOnly = readOnly;
} finally {
await releaseInitLock();
}
for (const schemaQuery of SCHEMA_QUERIES) {
try {
@@ -390,7 +605,25 @@ const doInitLbug = async (dbPath: string) => {
// anyway and any genuine cross-process lock contention surfaces
// on the next operation via withLbugDb's retry. Logging it here
// would just be noise in CI.
if (!msg.includes('already exists') && !isDbBusyError(err)) {
//
// WAL corruption: the first DDL write after DB open triggers WAL
// replay — if the WAL file was left in a corrupt state by an
// interrupted previous run, the native engine throws here. Rather
// than logging a WARN and continuing in a broken state, close the
// DB cleanly and surface an actionable error so the caller (serve,
// MCP, analyze) can exit with a clear recovery message.
if (isWalCorruptionError(err)) {
await safeClose();
currentDbPath = null;
ftsLoaded = false;
vectorExtensionLoaded = false;
ensuredFTSIndexes.clear();
throw new Error(
`LadybugDB WAL corruption detected at ${dbPath}. ${WAL_RECOVERY_SUGGESTION}\n` +
` Original error: ${msg.slice(0, 200)}`,
);
}
if (!msg.includes('already exists') && !isDbBusyError(err) && !isReadOnlyDbError(err)) {
logger.warn(`⚠️ Schema creation warning: ${msg.slice(0, 120)}`);
}
}
@@ -834,12 +1067,7 @@ export const batchInsertNodesToLbug = async (
};
export const executeQuery = async (cypher: string): Promise<any[]> => {
if (!conn) {
throw new Error('LadybugDB not initialized. Call initLbug first.');
}
const queryResult = await conn.query(cypher);
return await readQueryRows(queryResult);
return await executePrepared(cypher, {});
};
export const streamQuery = async (
@@ -1502,19 +1730,15 @@ export const queryFTS = async (
throw new Error('LadybugDB not initialized. Call initLbug first.');
}
// Escape backslashes and single quotes to prevent Cypher injection
const escapedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "''");
const cypher = `
CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', '${escapedQuery}', conjunctive := ${conjunctive})
CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', $query, conjunctive := ${conjunctive})
RETURN node, score
ORDER BY score DESC
LIMIT ${limit}
`;
try {
const queryResult = await conn.query(cypher);
const rows = await readQueryRows(queryResult);
const rows = await executePrepared(cypher, { query });
return rows.map((row: any) => {
const node = row.node || row[0] || {};
+1 -1
View File
@@ -49,7 +49,7 @@ export const LBUG_MAX_DB_SIZE: number = (() => {
const WAL_CORRUPTION_RE = /corrupt(ed)?\s+wal|invalid\s+wal\s+record|wal.*corrupt|checksum.*wal/i;
export const WAL_RECOVERY_SUGGESTION =
'WAL corruption detected. Run `gitnexus analyze` to rebuild the index.';
'WAL corruption detected. Run `gitnexus analyze --force` to rebuild the index.';
export function isWalCorruptionError(err: unknown): boolean {
if (!err) return false;
+13 -40
View File
@@ -17,8 +17,12 @@
import fs from 'fs/promises';
import lbug from '@ladybugdb/core';
import { loadFTSExtension } from './lbug-adapter.js';
import { createLbugDatabase, isWalCorruptionError } from './lbug-config.js';
import { isReadOnlyDbError, loadFTSExtension } from './lbug-adapter.js';
import {
createLbugDatabase,
isWalCorruptionError,
WAL_RECOVERY_SUGGESTION,
} from './lbug-config.js';
/** Per-repo pool: one Database, many Connections */
interface PoolEntry {
@@ -375,8 +379,7 @@ async function doInitLbug(repoId: string, dbPath: string): Promise<void> {
break;
} catch (retryErr) {
throw new Error(
`LadybugDB WAL corruption detected for ${repoId}. ` +
`Run \`gitnexus analyze\` to rebuild the index. ` +
`LadybugDB WAL corruption detected for ${repoId}. ${WAL_RECOVERY_SUGGESTION} ` +
`(${retryErr instanceof Error ? retryErr.message : String(retryErr)})`,
);
}
@@ -595,30 +598,7 @@ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise
}
export const executeQuery = async (repoId: string, cypher: string): Promise<any[]> => {
const entry = pool.get(repoId);
if (!entry) {
throw new Error(`LadybugDB not initialized for repo "${repoId}". Call initLbug first.`);
}
if (isWriteQuery(cypher)) {
throw new Error('Write operations are not allowed. The pool adapter is read-only.');
}
entry.lastUsed = Date.now();
const conn = await checkout(entry);
silenceStdout();
activeQueryCount++;
try {
const queryResult = await withTimeout(conn.query(cypher), QUERY_TIMEOUT_MS, 'Query');
const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
const rows = await result.getAll();
return rows;
} finally {
activeQueryCount--;
restoreStdout();
checkin(entry, conn);
}
return await executeParameterized(repoId, cypher, {});
};
/**
@@ -650,6 +630,11 @@ export const executeParameterized = async (
const result = Array.isArray(queryResult) ? queryResult[0] : queryResult;
const rows = await result.getAll();
return rows;
} catch (err) {
if (isReadOnlyDbError(err)) {
throw new Error('Write operations are not allowed. The pool adapter is read-only.');
}
throw err;
} finally {
activeQueryCount--;
restoreStdout();
@@ -682,15 +667,3 @@ export const closeLbug = async (repoId?: string): Promise<void> => {
* Check if a specific repo's pool is active
*/
export const isLbugReady = (repoId: string): boolean => pool.has(repoId);
/** Regex to detect write operations in user-supplied Cypher queries.
* Note: CALL is NOT blocked — it's used for read-only FTS (CALL QUERY_FTS_INDEX)
* and vector search (CALL QUERY_VECTOR_INDEX). The database is opened in
* read-only mode as defense-in-depth against write procedures. */
export const CYPHER_WRITE_RE =
/(?<!:)\b(CREATE|DELETE|SET|MERGE|REMOVE|DROP|ALTER|COPY|DETACH|FOREACH|INSTALL|LOAD)\b/i;
/** Check if a Cypher query contains write operations */
export function isWriteQuery(query: string): boolean {
return CYPHER_WRITE_RE.test(query);
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Return true only for plain-object payloads that can be safely used as
* named parameter maps in prepared Cypher execution.
*
* Validation criteria:
* - must be a JavaScript object (`typeof value === 'object'`)
* - must not be `null`
* - must not be an array
* - must have a plain-object prototype
* - values must be scalar bindable values (string | number | boolean | null)
*
* Rationale: prepared-statement params are key/value maps; rejecting null/array
* and non-plain objects keeps binding behavior predictable and avoids passing
* complex host objects to Ladybug parameter binding.
*/
const isBindableScalar = (value: unknown): value is string | number | boolean | null =>
value === null || ['string', 'number', 'boolean'].includes(typeof value);
export const isValidQueryParams = (value: unknown): value is Record<string, unknown> =>
value !== null &&
typeof value === 'object' &&
!Array.isArray(value) &&
(Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null) &&
Object.values(value).every(isBindableScalar);
+6 -7
View File
@@ -27,22 +27,20 @@ export interface FTSSearchResponse {
* caller can distinguish "zero matches" from "index missing".
*/
async function queryFTSViaExecutor(
executor: (cypher: string) => Promise<any[]>,
executor: (cypher: string, params: Record<string, any>) => Promise<any[]>,
tableName: string,
indexName: string,
query: string,
limit: number,
): Promise<Array<{ filePath: string; score: number; nodeId: string }> | null> {
// Escape single quotes and backslashes to prevent Cypher injection
const escapedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "''");
const cypher = `
CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', '${escapedQuery}', conjunctive := false)
CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', $query, conjunctive := false)
RETURN node, score
ORDER BY score DESC
LIMIT ${limit}
`;
try {
const rows = await executor(cypher);
const rows = await executor(cypher, { query });
return rows.map((row: any) => {
const node = row.node || row[0] || {};
const score = row.score ?? row[1] ?? 0;
@@ -81,8 +79,9 @@ export const searchFTSFromLbug = async (
// IMPORTANT: FTS queries run sequentially to avoid connection contention.
// The MCP pool supports multiple connections, but FTS is best run serially.
const poolMod = await import('../lbug/pool-adapter.js');
const { executeQuery } = poolMod;
const executor = (cypher: string) => executeQuery(repoId, cypher);
const { executeParameterized } = poolMod;
const executor = (cypher: string, params: Record<string, any>) =>
executeParameterized(repoId, cypher, params);
for (const { table, indexName } of FTS_INDEXES) {
const result = await queryFTSViaExecutor(executor, table, indexName, query, limit);
+61 -4
View File
@@ -66,12 +66,15 @@ export interface WikiOptions {
concurrency?: number;
/** If true, stop after building module tree for user review */
reviewOnly?: boolean;
/** Output language for generated documentation (e.g. 'english', 'chinese', 'spanish') */
lang?: string;
}
export interface WikiMeta {
fromCommit: string;
generatedAt: string;
model: string;
lang: string;
moduleFiles: Record<string, string[]>;
moduleTree: ModuleTreeNode[];
}
@@ -177,6 +180,28 @@ export class WikiGenerator {
};
}
/**
* Return the effective lang string: strip control characters, trim, cap at 50 chars,
* then validate against a character allowlist. Returns '' if the value is absent or invalid.
* Used for both prompt construction and meta storage/comparison so they are always in sync.
*/
private effectiveLang(): string {
const lang = (this.options.lang ?? '')
.replace(/[\x00-\x1F\x7F]/g, '')
.trim()
.slice(0, 50);
return /^[a-zA-Z -]+$/.test(lang) ? lang : '';
}
/**
* Append an output-language instruction to a system prompt when --lang is set.
*/
private buildSystemPrompt(base: string): string {
const lang = this.effectiveLang();
if (!lang) return base;
return `${base}\n\nIMPORTANT: Write ALL documentation content in ${lang}. This includes prose, code comments in examples, and diagram labels. Note: page titles (H1 headings) are generated separately and will remain in English.`;
}
/**
* Route LLM call to the appropriate provider (OpenAI-compatible or Cursor CLI).
*/
@@ -207,6 +232,15 @@ export class WikiGenerator {
// Up-to-date check (skip if --force)
if (!forceMode && existingMeta && existingMeta.fromCommit === currentCommit) {
const currentLang = this.effectiveLang();
const metaLang = existingMeta.lang ?? '';
if (currentLang !== metaLang) {
const prevDisplay = metaLang || 'english (default)';
const nextDisplay = currentLang || 'english (default)';
throw new Error(
`Wiki was generated in ${prevDisplay}; use --force to regenerate in ${nextDisplay}.`,
);
}
// Still regenerate the HTML viewer in case it's missing
await this.ensureHTMLViewer();
return { pagesGenerated: 0, mode: 'up-to-date', failedModules: [] };
@@ -235,6 +269,15 @@ export class WikiGenerator {
let result: WikiRunResult;
try {
if (!forceMode && existingMeta && existingMeta.fromCommit) {
const currentLang = this.effectiveLang();
const metaLang = existingMeta.lang ?? '';
if (currentLang !== metaLang) {
const prevDisplay = metaLang || 'english (default)';
const nextDisplay = currentLang || 'english (default)';
throw new Error(
`Wiki was generated in ${prevDisplay}; use --force to regenerate in ${nextDisplay}.`,
);
}
result = await this.incrementalUpdate(existingMeta, currentCommit);
} else {
result = await this.fullGeneration(currentCommit);
@@ -368,6 +411,7 @@ export class WikiGenerator {
fromCommit: currentCommit,
generatedAt: new Date().toISOString(),
model: this.llmConfig.model,
lang: this.effectiveLang(),
moduleFiles,
moduleTree,
});
@@ -415,6 +459,9 @@ export class WikiGenerator {
DIRECTORY_TREE: dirTree,
});
// Grouping is a structured-data phase (JSON output), not documentation.
// Do NOT apply buildSystemPrompt here — a language instruction would risk
// translating module-name keys, breaking slug stability and JSON parsing.
const response = await this.invokeLLM(
prompt,
GROUPING_SYSTEM_PROMPT,
@@ -589,9 +636,13 @@ export class WikiGenerator {
PROCESSES: formatProcesses(processes),
});
const response = await this.invokeLLM(prompt, MODULE_SYSTEM_PROMPT, this.streamOpts(node.name));
const response = await this.invokeLLM(
prompt,
this.buildSystemPrompt(MODULE_SYSTEM_PROMPT),
this.streamOpts(node.name),
);
// Write page with front matter
// H1 uses the English module name (stable slug source); body is LLM-translated.
const pageContent = sanitizeMermaidMarkdown(`# ${node.name}\n\n${response.content}`);
await fs.writeFile(path.join(this.wikiDir, `${node.slug}.md`), pageContent, 'utf-8');
}
@@ -630,7 +681,11 @@ export class WikiGenerator {
CROSS_PROCESSES: formatProcesses(processes),
});
const response = await this.invokeLLM(prompt, PARENT_SYSTEM_PROMPT, this.streamOpts(node.name));
const response = await this.invokeLLM(
prompt,
this.buildSystemPrompt(PARENT_SYSTEM_PROMPT),
this.streamOpts(node.name),
);
const pageContent = sanitizeMermaidMarkdown(`# ${node.name}\n\n${response.content}`);
await fs.writeFile(path.join(this.wikiDir, `${node.slug}.md`), pageContent, 'utf-8');
@@ -678,7 +733,7 @@ export class WikiGenerator {
const response = await this.invokeLLM(
prompt,
OVERVIEW_SYSTEM_PROMPT,
this.buildSystemPrompt(OVERVIEW_SYSTEM_PROMPT),
this.streamOpts('Generating overview', 88),
);
@@ -713,6 +768,7 @@ export class WikiGenerator {
...existingMeta,
fromCommit: currentCommit,
generatedAt: new Date().toISOString(),
lang: this.effectiveLang(),
});
return { pagesGenerated: 0, mode: 'incremental', failedModules: [] };
}
@@ -817,6 +873,7 @@ export class WikiGenerator {
fromCommit: currentCommit,
generatedAt: new Date().toISOString(),
model: this.llmConfig.model,
lang: this.effectiveLang(),
});
this.onProgress('done', 100, 'Incremental update complete');
+27 -7
View File
@@ -23,7 +23,7 @@ export interface LLMConfig {
apiVersion?: string;
/** When true, strips sampling params and uses max_completion_tokens instead of max_tokens */
isReasoningModel?: boolean;
/** Per-attempt fetch timeout in ms (default: 60_000). */
/** Per-attempt fetch timeout in ms. Omit to disable request timeouts. */
requestTimeoutMs?: number;
/** Max fetch attempts before giving up (default: 3). */
maxAttempts?: number;
@@ -81,6 +81,19 @@ export function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
function formatTimeoutDuration(timeoutMs: number): string {
if (timeoutMs >= 1000 && timeoutMs % 1000 === 0) {
return `${timeoutMs / 1000}s`;
}
return `${timeoutMs}ms`;
}
function isTimeoutLikeError(err: unknown): boolean {
if (!(err instanceof Error)) return false;
if (err.name === 'TimeoutError' || err.name === 'AbortError') return true;
return /time(d)?\s*out|timeout/i.test(err.message);
}
/**
* Validate that a base URL supplied for LLM API calls is a safe HTTP/HTTPS
* endpoint (CWE-918 / CodeQL js/http-to-file-access).
@@ -237,12 +250,13 @@ export async function callLLM(
...authHeaders,
},
body: JSON.stringify(body),
// Per-attempt timeout. Without this each retry can hang
// indefinitely on a frozen TCP connection — the per-call
// signal is the only timeout `resilientFetch` honors;
// `capDelayMs` only bounds the *backoff* between attempts.
// Default 60s; raise via --timeout for slow models or large pages.
signal: AbortSignal.timeout(config.requestTimeoutMs ?? 60_000),
// Request timeout is opt-in for wiki generation. Large local
// model runs can legitimately take well over a minute, so the
// default runtime path must not impose a hidden 60s ceiling.
signal:
config.requestTimeoutMs !== undefined
? AbortSignal.timeout(config.requestTimeoutMs)
: undefined,
},
{
breakerKey: `wiki-llm-${new URL(url).host}`,
@@ -261,6 +275,12 @@ export async function callLLM(
`LLM API error (${err.response.status} after retries): ${errorText.slice(0, 500)}`,
);
}
if (config.requestTimeoutMs !== undefined && isTimeoutLikeError(err)) {
throw new Error(
`LLM request timed out after ${formatTimeoutDuration(config.requestTimeoutMs)}. ` +
'Increase --timeout or omit it to disable the request timeout.',
);
}
throw err;
}
+121 -13
View File
@@ -14,15 +14,20 @@ import {
executeParameterized,
closeLbug,
isLbugReady,
isWriteQuery,
} from '../../core/lbug/pool-adapter.js';
import { isValidQueryParams } from '../../core/lbug/query-params.js';
import { isWalCorruptionError, WAL_RECOVERY_SUGGESTION } from '../../core/lbug/lbug-config.js';
export { isWriteQuery };
// Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node
// at MCP server startup — crashes on unsupported Node ABI versions (#89)
// git utilities available if needed
// import { isGitRepo, getCurrentCommit, getGitRoot } from '../../storage/git.js';
import { parseDiffHunks, type FileDiff } from '../../storage/git.js';
import {
parseDiffHunks,
getCanonicalRepoRoot,
getGitRoot,
type FileDiff,
} from '../../storage/git.js';
import { realpathSync } from 'fs';
import {
listRegisteredRepos,
cleanupOldKuzuFiles,
@@ -169,6 +174,9 @@ function logQueryError(context: string, err: unknown): void {
logger.error({ context, err: msg }, 'GitNexus query failed');
}
const isReadOnlyDbError = (err: unknown): boolean =>
/read-only database/i.test(err instanceof Error ? err.message : String(err));
/**
* Per-query latency telemetry for production aggregation (#553).
*
@@ -211,6 +219,55 @@ interface RepoHandle {
stats?: RegistryEntry['stats'];
}
/** Resolve symlinks for path comparison; falls back to path.resolve on error.
* Uses `realpathSync.native` (not the pure-JS `realpathSync`) so that Windows
* 8.3 short names (e.g. RUNNER~1 → runneradmin) are expanded to long form,
* matching the output of `git rev-parse --show-toplevel`. */
function tryRealpath(p: string): string {
try {
return realpathSync.native(p);
} catch {
return path.resolve(p);
}
}
/**
* Resolve the git diff cwd for detect_changes, auto-detecting linked worktrees.
*
* When `launchCwd` is a linked worktree of the same canonical repository as
* `repoPath` (i.e. `getGitRoot(launchCwd)` differs from `repoPath` but both
* share the same `getCanonicalRepoRoot`), returns the worktree's git root so
* that `git diff` sees the correct working directory and index.
*
* Returns `repoPath` unchanged in all other cases (non-worktree, git
* unavailable, unrelated repo).
*
* Extracted as a module-level export so tests can pass any `launchCwd` instead
* of relying on `process.cwd()`, which is fixed to the server launch directory
* and cannot be changed mid-process.
*/
export function resolveWorktreeCwd(repoPath: string, launchCwd: string): string {
try {
const launchGitRoot = getGitRoot(launchCwd);
if (launchGitRoot) {
// Normalise via realpathSync before comparing so macOS /var → /private/var
// symlinks (and Windows 8.3 short names) don't create false mismatches.
const realLaunch = tryRealpath(launchGitRoot);
const realRepo = tryRealpath(repoPath);
if (realLaunch !== realRepo) {
const launchCanonical = getCanonicalRepoRoot(launchCwd);
const repoCanonical = getCanonicalRepoRoot(repoPath);
if (launchCanonical && repoCanonical && launchCanonical === repoCanonical) {
return launchGitRoot;
}
}
}
} catch {
// Best-effort; fall through to repoPath.
}
return repoPath;
}
export class LocalBackend {
private repos: Map<string, RepoHandle> = new Map();
private contextCache: Map<string, CodebaseContext> = new Map();
@@ -1218,31 +1275,41 @@ export class LocalBackend {
}
}
async executeCypher(repoName: string, query: string): Promise<any> {
async executeCypher(
repoName: string,
query: string,
params: Record<string, unknown> = {},
): Promise<any> {
const repo = await this.resolveRepo(repoName);
return this.cypher(repo, { query });
return this.cypher(repo, { query, params });
}
private async cypher(repo: RepoHandle, params: { query: string }): Promise<any> {
private async cypher(
repo: RepoHandle,
request: { query: string; params?: Record<string, unknown> },
): Promise<any> {
await this.ensureInitialized(repo.id);
if (!isLbugReady(repo.id)) {
return { error: 'LadybugDB not ready. Index may be corrupted.' };
}
// Block write operations (defense-in-depth — DB is already read-only)
if (isWriteQuery(params.query)) {
if (request.params !== undefined && !isValidQueryParams(request.params)) {
return {
error:
'Write operations (CREATE, DELETE, SET, MERGE, REMOVE, DROP, ALTER, COPY, DETACH) are not allowed. The knowledge graph is read-only.',
error: '"params" must be a plain object with scalar values (string/number/boolean/null).',
};
}
try {
const result = await executeQuery(repo.id, params.query);
const result = await executeParameterized(repo.id, request.query, request.params ?? {});
return result;
} catch (err: any) {
const msg = err.message || 'Query failed';
if (isReadOnlyDbError(err)) {
return {
error:
'Write operations (CREATE, DELETE, SET, MERGE, REMOVE, DROP, ALTER, COPY, DETACH) are not allowed. The knowledge graph is read-only.',
};
}
if (isWalCorruptionError(err)) {
return {
error: msg,
@@ -2133,6 +2200,7 @@ export class LocalBackend {
params: {
scope?: string;
base_ref?: string;
worktree?: string;
},
): Promise<any> {
await this.ensureInitialized(repo.id);
@@ -2161,11 +2229,51 @@ export class LocalBackend {
let diffOutput: string;
try {
// Resolve the cwd for git diff.
//
// In a linked worktree (e.g. /repo/wt-feature/), the user's staged and
// unstaged changes live in that worktree's separate working directory and
// index. Running `git diff` from the canonical repo root sees a different
// working tree and returns empty output.
//
// Resolution order (see resolveWorktreeCwd for details):
// 1. params.worktree — explicit override, validated against the
// registered repo's canonical root.
// 2. Auto-detect — if the server's launch cwd (process.cwd()) is a
// linked worktree of the same canonical repo, use its git root.
// 3. repo.repoPath — fallback (original behaviour, handled inside
// resolveWorktreeCwd when no worktree is detected).
//
// Start with the auto-detected value; override with the validated
// explicit param when provided. This avoids a dead initial assignment.
let diffCwd = resolveWorktreeCwd(repo.repoPath, process.cwd());
if (params.worktree) {
if (!path.isAbsolute(params.worktree)) {
return {
error: `worktree must be an absolute path, got: "${params.worktree}"`,
};
}
const providedResolved = path.resolve(params.worktree);
const repoCanonical = getCanonicalRepoRoot(repo.repoPath);
if (!repoCanonical) {
return {
error: `Could not determine canonical root for repo "${repo.repoPath}". Is git available?`,
};
}
const worktreeCanonical = getCanonicalRepoRoot(providedResolved);
if (!worktreeCanonical || tryRealpath(worktreeCanonical) !== tryRealpath(repoCanonical)) {
return {
error: `worktree "${params.worktree}" is not a worktree of repo "${repo.repoPath}". Ensure the path is inside the same git repository.`,
};
}
diffCwd = providedResolved;
}
// maxBuffer raised from Node's 1MB default to 256MB to avoid ENOBUFS on
// repos with large unstaged/untracked diffs (e.g. unignored build folders).
// See issue: spawnSync git ENOBUFS in detect_changes(scope="unstaged").
diffOutput = execFileSync('git', diffArgs, {
cwd: repo.repoPath,
cwd: diffCwd,
encoding: 'utf-8',
maxBuffer: 256 * 1024 * 1024,
});
+12
View File
@@ -187,6 +187,11 @@ TIPS:
type: 'object',
properties: {
query: { type: 'string', description: 'Cypher query to execute' },
params: {
type: 'object',
description:
'Optional query parameters for placeholders (e.g. $name) to execute via prepared statement binding.',
},
repo: {
type: 'string',
description: 'Repository name or path. Omit if only one repo is indexed.',
@@ -253,6 +258,8 @@ Maps git diff hunks to indexed symbols, then traces which processes are impacted
WHEN TO USE: Before committing — to understand what your changes affect. Pre-commit review, PR preparation.
AFTER THIS: Review affected processes. Use context() on high-risk symbols. READ gitnexus://repo/{name}/process/{name} for full traces.
GIT WORKTREE SUPPORT: GitNexus automatically detects when the MCP server was launched from inside a linked git worktree and runs git diff against that worktree — no extra parameters needed in the common case. Pass "worktree" explicitly only when the server was started from a different directory than the worktree you are editing (e.g., the server runs from the canonical root but your changes are in a linked worktree at a different path).
Returns: changed symbols, affected processes, and a risk summary.`,
annotations: READ_ONLY_TOOL_ANNOTATIONS,
inputSchema: {
@@ -268,6 +275,11 @@ Returns: changed symbols, affected processes, and a risk summary.`,
type: 'string',
description: 'Branch/commit for "compare" scope (e.g., "main")',
},
worktree: {
type: 'string',
description:
'Absolute path to a linked git worktree. Pass this when your changes are in a worktree (the .git entry at that path is a file, not a directory). GitNexus will run git diff from that worktree so staged/unstaged changes are correctly detected.',
},
repo: {
type: 'string',
description: 'Repository name or path. Omit if only one repo is indexed.',
+41 -24
View File
@@ -22,8 +22,9 @@ import {
flushWAL,
closeLbug,
withLbugDb,
isReadOnlyDbError,
} from '../core/lbug/lbug-adapter.js';
import { isWriteQuery } from '../core/lbug/pool-adapter.js';
import { isValidQueryParams } from '../core/lbug/query-params.js';
import { NODE_TABLES, type GraphNode, type GraphRelationship } from 'gitnexus-shared';
import { searchFTSFromLbug } from '../core/search/bm25-index.js';
import { hybridSearch } from '../core/search/hybrid-search.js';
@@ -621,6 +622,44 @@ export const handleFileRequest = async (
}
};
export const handleQueryRequest = async (
req: express.Request,
res: express.Response,
resolveRepo: (repoName?: string) => Promise<{ storagePath: string } | undefined>,
): Promise<void> => {
try {
const cypher = req.body.cypher as string;
if (!cypher) {
res.status(400).json({ error: 'Missing "cypher" in request body' });
return;
}
const queryParams = req.body.params;
if (queryParams !== undefined && !isValidQueryParams(queryParams)) {
res.status(400).json({
error: '"params" must be a plain object with scalar values (string/number/boolean/null)',
});
return;
}
const entry = await resolveRepo(requestedRepo(req));
if (!entry) {
res.status(404).json({ error: 'Repository not found' });
return;
}
const lbugPath = path.join(entry.storagePath, 'lbug');
const result = await withLbugDb(lbugPath, () => executePrepared(cypher, queryParams ?? {}), {
readOnly: true,
});
res.json({ result });
} catch (err: any) {
if (isReadOnlyDbError(err)) {
res.status(403).json({ error: 'Write queries are not allowed via the HTTP API' });
return;
}
res.status(500).json({ error: err.message || 'Query failed' });
}
};
export const createServer = async (port: number, host: string = '127.0.0.1') => {
const app = express();
app.disable('x-powered-by');
@@ -1020,29 +1059,7 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
// Execute Cypher query
app.post('/api/query', async (req, res) => {
try {
const cypher = req.body.cypher as string;
if (!cypher) {
res.status(400).json({ error: 'Missing "cypher" in request body' });
return;
}
if (isWriteQuery(cypher)) {
res.status(403).json({ error: 'Write queries are not allowed via the HTTP API' });
return;
}
const entry = await resolveRepo(requestedRepo(req));
if (!entry) {
res.status(404).json({ error: 'Repository not found' });
return;
}
const lbugPath = path.join(entry.storagePath, 'lbug');
const result = await withLbugDb(lbugPath, () => executeQuery(cypher));
res.json({ result });
} catch (err: any) {
res.status(500).json({ error: err.message || 'Query failed' });
}
await handleQueryRequest(req, res, resolveRepo);
});
// Search (supports mode: 'hybrid' | 'semantic' | 'bm25', and optional enrichment)
+106 -18
View File
@@ -70,15 +70,28 @@ const GITNEXUS_PKG_VERSION = (() => {
})();
export const PARSE_CACHE_VERSION = `${SCHEMA_BUMP}+${GITNEXUS_PKG_VERSION}`;
const CACHE_FILENAME = 'parse-cache.json';
const LEGACY_CACHE_FILENAME = 'parse-cache.json';
const CACHE_DIRNAME = 'parse-cache';
const CACHE_INDEX_FILENAME = 'index.json';
/** On-disk shape. */
/** Keys on disk always come from `computeChunkHash` — 64-char lowercase hex. */
const CHUNK_CACHE_KEY_HEX_RE = /^[a-f0-9]{64}$/;
const isValidChunkCacheKey = (chunkHash: string): boolean => CHUNK_CACHE_KEY_HEX_RE.test(chunkHash);
/** On-disk shape for the legacy single-file format. */
interface ParseCacheFile {
version: string;
/** key = chunk hash (hex) → cached chunk result list. */
entries: Record<string, ParseWorkerResult[]>;
}
/** On-disk shape for the sharded directory format. */
interface ShardedParseCacheIndex {
version: string;
keys: string[];
}
/** Runtime view: keyed Map for fast lookup; mutated in place during a run. */
export interface ParseCache {
version: string;
@@ -144,12 +157,19 @@ const mapReviver = (_key: string, value: unknown): unknown => {
return value;
};
/**
* Load the parse cache. Returns an empty cache on any failure (missing
* file, corrupt JSON, version mismatch). Never throws on a normal load.
*/
export const loadParseCache = async (storagePath: string): Promise<ParseCache> => {
const cachePath = path.join(storagePath, CACHE_FILENAME);
const getLegacyCachePath = (storagePath: string): string =>
path.join(storagePath, LEGACY_CACHE_FILENAME);
const getCacheDirPath = (storagePath: string): string => path.join(storagePath, CACHE_DIRNAME);
const getCacheIndexPath = (storagePath: string): string =>
path.join(getCacheDirPath(storagePath), CACHE_INDEX_FILENAME);
const getCacheChunkPath = (storagePath: string, chunkHash: string): string =>
path.join(getCacheDirPath(storagePath), `${chunkHash}.json`);
const loadLegacyParseCache = async (storagePath: string): Promise<ParseCache> => {
const cachePath = getLegacyCachePath(storagePath);
try {
const raw = await fs.readFile(cachePath, 'utf-8');
const data = JSON.parse(raw, mapReviver) as ParseCacheFile;
@@ -172,22 +192,90 @@ export const loadParseCache = async (storagePath: string): Promise<ParseCache> =
}
};
const loadShardedParseCache = async (storagePath: string): Promise<ParseCache | null> => {
const indexPath = getCacheIndexPath(storagePath);
try {
const raw = await fs.readFile(indexPath, 'utf-8');
const data = JSON.parse(raw) as ShardedParseCacheIndex;
if (
typeof data !== 'object' ||
data === null ||
data.version !== PARSE_CACHE_VERSION ||
!Array.isArray(data.keys)
) {
return emptyCache();
}
const entries = new Map<string, ParseWorkerResult[]>();
for (const chunkHash of data.keys) {
if (typeof chunkHash !== 'string' || !isValidChunkCacheKey(chunkHash)) continue;
try {
const chunkRaw = await fs.readFile(getCacheChunkPath(storagePath, chunkHash), 'utf-8');
const chunkData = JSON.parse(chunkRaw, mapReviver) as ParseWorkerResult[];
if (Array.isArray(chunkData)) entries.set(chunkHash, chunkData);
} catch {
/* skip corrupt or missing shard */
}
}
return { version: PARSE_CACHE_VERSION, entries, usedKeys: new Set<string>() };
} catch {
return null;
}
};
/**
* Persist the cache to disk atomically (write-and-rename) so a crash
* mid-write doesn't leave a corrupt file.
* Load the parse cache. Returns an empty cache on any failure (missing
* file, corrupt JSON, version mismatch). Never throws on a normal load.
*/
export const loadParseCache = async (storagePath: string): Promise<ParseCache> => {
const sharded = await loadShardedParseCache(storagePath);
if (sharded) return sharded;
return loadLegacyParseCache(storagePath);
};
/**
* Persist the cache to disk using a temp directory + rename.
*
* Writes shards under `${cacheDir}.tmp`, then removes the old `cacheDir` and
* renames the temp directory into place. There is a crash window after
* `rm(cacheDir)` and before `rename(tmpDir, cacheDir)` where no cache exists;
* that is acceptable — `loadParseCache` yields empty and the next run
* reparses. This is not a single atomic swap of the whole tree, but avoids
* leaving a half-written shard set visible to readers.
*/
export const saveParseCache = async (storagePath: string, cache: ParseCache): Promise<void> => {
await fs.mkdir(storagePath, { recursive: true });
const cachePath = path.join(storagePath, CACHE_FILENAME);
const tmpPath = `${cachePath}.tmp`;
const out: ParseCacheFile = {
const cacheDir = getCacheDirPath(storagePath);
const tmpDir = `${cacheDir}.tmp`;
await fs.rm(tmpDir, { recursive: true, force: true });
await fs.mkdir(tmpDir, { recursive: true });
const keys: string[] = [];
for (const [chunkHash, chunkResults] of cache.entries) {
if (!isValidChunkCacheKey(chunkHash)) continue;
let payload: string;
try {
payload = JSON.stringify(chunkResults, mapReplacer);
} catch {
// Extremely dense chunks could theoretically exceed string limits; skip
// rather than failing the entire save (orchestrator catches save errors).
continue;
}
keys.push(chunkHash);
const chunkPath = path.join(tmpDir, `${chunkHash}.json`);
await fs.writeFile(chunkPath, payload, 'utf-8');
}
const index: ShardedParseCacheIndex = {
version: cache.version,
entries: Object.fromEntries(cache.entries),
keys,
};
// Compact JSON; this file can be tens of MB on a large repo and pretty-
// printing roughly doubles size for no value.
await fs.writeFile(tmpPath, JSON.stringify(out, mapReplacer), 'utf-8');
await fs.rename(tmpPath, cachePath);
await fs.writeFile(path.join(tmpDir, CACHE_INDEX_FILENAME), JSON.stringify(index), 'utf-8');
await fs.rm(cacheDir, { recursive: true, force: true });
await fs.rename(tmpDir, cacheDir);
await fs.rm(getLegacyCachePath(storagePath), { force: true });
};
/**
@@ -0,0 +1,14 @@
#include "base_lib.h"
namespace app {
struct Token : base_one::Base {};
void run() {
Token t;
collide(t);
}
}
namespace other {
struct Token : base_two::Base {};
}
@@ -0,0 +1,11 @@
#pragma once
namespace base_one {
struct Base {};
void collide(Base);
}
namespace base_two {
struct Base {};
void collide(Base);
}
@@ -0,0 +1,16 @@
#include "base_lib.h"
namespace app {
struct HiddenDerived : HiddenBase {};
struct MissingDerived : missing_ns::UnknownBase {};
void run_hidden() {
HiddenDerived d;
hidden_probe(d);
}
void run_missing() {
MissingDerived d;
unresolved_probe(d);
}
}
@@ -0,0 +1,6 @@
#pragma once
namespace {
struct HiddenBase {};
void hidden_probe(HiddenBase);
}
@@ -0,0 +1,22 @@
#include "base_lib.h"
namespace app {
struct Derived : base_lib::Base {};
struct MultiLevel : middle_lib::Mid {};
struct DiamondDerived : diamond_lib::LeftBranch, diamond_lib::RightBranch {};
void run_single() {
Derived d;
log(d);
}
void run_multi() {
MultiLevel m;
trace(m);
}
void run_diamond() {
DiamondDerived d;
ping(d);
}
}
@@ -0,0 +1,20 @@
#pragma once
namespace base_lib {
struct Base {};
void log(Base);
struct Root {};
void trace(Root);
}
namespace middle_lib {
struct Mid : base_lib::Root {};
}
namespace diamond_lib {
struct DiamondBase {};
struct LeftBranch : DiamondBase {};
struct RightBranch : DiamondBase {};
void ping(DiamondBase);
}
@@ -0,0 +1,13 @@
#include "audit.h"
namespace app {
void run() {
// Block-scope function declaration (not via using-declaration).
// Per ISO C++ [basic.lookup.argdep], this suppresses ADL — even
// though `e` is audit::Event, audit::record should NOT be discovered.
void record(int);
audit::Event e;
record(e);
}
}
@@ -0,0 +1,6 @@
#pragma once
namespace audit {
struct Event {};
void record(Event e);
}
@@ -0,0 +1,8 @@
#include "color.h"
namespace app {
void run() {
color::Channel ch = color::Channel::R;
serialize(ch);
}
}
@@ -0,0 +1,6 @@
#pragma once
namespace color {
enum class Channel { R, G, B };
void serialize(Channel c);
}
@@ -0,0 +1,7 @@
#include "utils.h"
namespace caller {
void run() {
with_callback(utils::worker);
}
}
@@ -0,0 +1,7 @@
#pragma once
namespace utils {
void worker();
void worker(int n);
void with_callback(int n);
}
@@ -0,0 +1,7 @@
#include "utils.h"
namespace caller {
void run() {
with_callback(utils::worker);
}
}
@@ -0,0 +1,6 @@
#pragma once
namespace utils {
void worker();
void with_callback(int n);
}
@@ -0,0 +1,8 @@
#include "lib.h"
namespace app {
void run() {
lib::Foo f;
process(f);
}
}
@@ -0,0 +1,7 @@
#pragma once
namespace lib {
struct Foo {
friend void process(Foo& f) {}
};
}
@@ -0,0 +1,8 @@
#include "audit.h"
namespace app {
void run() {
audit::Event e;
record(e);
}
}
@@ -0,0 +1,13 @@
#pragma once
namespace audit {
struct Event {};
inline namespace v1 {
void record(Event e);
}
}
namespace other {
void record(int x);
}
@@ -0,0 +1,21 @@
#include "data.h"
// Outer namespace has a non-callable `swap` (variable).
namespace app {
int swap = 0;
namespace inner {
// Inner namespace re-declares `swap` as a function.
void swap(int, int);
void run() {
data::Pair a, b;
// Ordinary lookup finds `inner::swap(int,int)` first (callable at
// nearest scope). The outer `app::swap` variable should NOT suppress
// ADL because ordinary lookup stopped at `inner` scope where a
// callable was found. ADL contributes `data::swap(Pair&,Pair&)` which
// wins via argTypes narrowing.
swap(a, b);
}
}
}
@@ -0,0 +1,6 @@
#pragma once
namespace data {
struct Pair {};
void swap(Pair& a, Pair& b);
}
@@ -0,0 +1,13 @@
#include "audit.h"
namespace app {
void run() {
// `g` is a locally-declared function-pointer variable. audit::g() also
// exists in the workspace. The local-fp guard (foundAsLocalFunctionPointer)
// must detect `g` as a function-pointer variable declaration and return
// EMPTY_ADL_ARG, preventing the workspace scan that would otherwise find
// audit::g and contribute `audit` to the ADL associated set.
void (*g)();
record(g);
}
}
@@ -0,0 +1,11 @@
#pragma once
namespace audit {
// A free function named `g` exists in the workspace. Without the local-fp
// guard, a locally-declared `void (*g)()` variable would fall through to
// EMPTY_ADL_ARG and not be treated as a free-function ref — but this test
// specifically verifies that the local fp variable shadows the workspace
// function of the same name and no namespace is contributed.
void g();
void record(void (*fn)());
}
@@ -0,0 +1,11 @@
#include "data.h"
namespace app {
void swap(int a, int b);
void run() {
data::Pair a;
data::Pair b;
swap(a, b);
}
}
@@ -0,0 +1,6 @@
#pragma once
namespace data {
struct Pair {};
void swap(Pair& a, Pair& b);
}
@@ -0,0 +1,6 @@
#pragma once
namespace alpha {
struct Token {};
void probe(Token t);
}
@@ -0,0 +1,8 @@
#include "alpha.h"
namespace app {
void run() {
alpha::Token t;
probe(t, 42);
}
}
@@ -0,0 +1,10 @@
#include "audit.h"
namespace app {
int record = 0;
void run() {
audit::Event e;
record(e);
}
}
@@ -0,0 +1,6 @@
#pragma once
namespace audit {
struct Event {};
void record(Event e);
}
@@ -0,0 +1,14 @@
#include "utils.h"
namespace caller {
// `callback` is a plain int parameter, not a function reference.
// Without the fix: `callback` is not found in the compound_statement
// (parameters live in parameter_list) → lookupAdlIdentifierType returns null
// → treated as free-function ref → workspace scan finds utils::callback
// → `utils` added to ADL set → run_with resolves to utils::run_with (false positive).
// With the fix: isIdentifierAFunctionParameter detects `callback` in the
// parameter_list → returns EMPTY_ADL_ARG → no namespace contributed → 0 CALLS edges.
void run(int callback) {
run_with(callback);
}
}
@@ -0,0 +1,10 @@
#pragma once
namespace utils {
// A function named `callback` exists in the `utils` namespace. Without the
// parameter-list guard, passing a function *parameter* also named `callback`
// would trigger a workspace scan, find utils::callback, contribute `utils`
// to the ADL set, and emit a false-positive CALLS edge to utils::run_with.
void callback();
void run_with(int n);
}
@@ -0,0 +1,14 @@
#include "data.h"
namespace caller {
// data::value is a namespace-qualified VARIABLE, not a function.
// ADL must NOT contribute `data` to the associated namespace set — the
// argument type is `int`, which has no associated namespaces in ISO C++.
// GitNexus guards: collectFunctionRefNamespaces verifies a Function/Method
// named `value` exists in `data` before contributing. Since `data::value`
// is a variable (not a function), `data` is NOT added, and process() is
// not resolved via ADL.
void run() {
process(data::value);
}
}
@@ -0,0 +1,6 @@
#pragma once
namespace data {
extern int value;
void process(int n);
}
@@ -0,0 +1,23 @@
#include "audit.h"
namespace app {
void run() {
std::vector<N::T> v;
apply(v);
}
void runNested() {
std::map<std::string, std::vector<N::T>> m;
applyNested(m);
}
void runArray() {
std::array<N::T, 4> a;
applyArray(a);
}
void runStdConflict() {
std::vector<N::T> v;
applyStdConflict(v);
}
}
@@ -0,0 +1,19 @@
#pragma once
#include <array>
#include <map>
#include <string>
#include <vector>
namespace N {
struct T {};
void apply(std::vector<T> v);
void applyNested(std::map<std::string, std::vector<T>> m);
void applyArray(std::array<T, 4> a);
void applyStdConflict(std::vector<T> v);
}
namespace std {
void applyStdConflict(vector<N::T> v);
}
@@ -0,0 +1,14 @@
#include "lib.h"
namespace caller {
void run() {
// Unqualified `worker` — not in local compound_statement scope → treated
// as a potential free-function reference. The workspace scan finds
// worker() in BOTH alpha and beta namespaces, so BOTH are added to the
// associated set. run_with() exists in both namespaces as well, so the
// lookup yields two candidates (alpha::run_with, beta::run_with).
// Merged-narrowing ambiguity suppression in free-call-fallback emits
// zero CALLS edges rather than picking one arbitrarily.
run_with(worker);
}
}
@@ -0,0 +1,12 @@
#pragma once
namespace alpha {
// `worker` exists in both alpha and beta namespaces.
void worker();
void run_with(void (*fn)());
}
namespace beta {
void worker();
void run_with(void (*fn)());
}
@@ -0,0 +1,5 @@
#include "lib.h"
void run() {
outer::foo(42);
}
@@ -0,0 +1,10 @@
#pragma once
namespace outer {
inline namespace v1 {
void foo(int x);
}
inline namespace v2 {
void foo(double y);
}
}

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