Merge branch 'abhigyanpatwari:main' into main

This commit is contained in:
Иван 2026-09-03 22:32:01 +03:00 • committed by GitHub
commit b887f52b6c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
255 changed files with 26359 additions and 1105 deletions

View file

@ -28,12 +28,13 @@ Run from the project root. This parses all source files, builds the knowledge gr
| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. |
| `--pdg` | Build the program-dependence layers used by `explain` and `pdg_query` (taint, CDG, and REACHING_DEF). |
| `--spring-actuator <path>` | Import opt-in Spring Boot Actuator mappings, beans, conditions, configprops, and env snapshots. Forces a full rebuild; unsupported with `--watch`. |
| `--asyncapi-spec <path>` | Read opt-in AsyncAPI 3.x documents (directory or single file) and mint `Destination` nodes from their operations. 2.x is refused, not mapped. Unsupported with `--watch`. |
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout.
For Spring runtime enrichment, pass a JSON bundle, one endpoint JSON file, or a directory containing endpoint files. Route evidence is authoritative only when `runtimeConfirmed === true`; `runtimeSource` records provenance and may also accompany `handler-conflict`. Env/configprops values are never persisted.
Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index.
Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Scheduled remote clone/pull is a different command: `gitnexus auto-sync`. Bare `gitnexus watch` is reserved and does not start either job. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index.
### status — Check index freshness

View file

@ -24,8 +24,14 @@ jobs:
shard: ${{ fromJSON(needs.shard-plan.outputs.cov_shards) }}
# Fail loudly (don't silently skip) if the FTS extension is unavailable, so
# FTS-dependent lbug integration suites are guaranteed to run in CI.
# Same contract for Zig's optionalDependency grammar: this runner is
# linux-x64, which @tree-sitter-grammars/tree-sitter-zig publishes a
# prebuild for, so an absent grammar here is a packaging regression and not
# an unsupported platform. Without it every Zig suite skips and the job is
# green having never executed the native Zig parser once.
env:
GITNEXUS_REQUIRE_FTS: '1'
GITNEXUS_REQUIRE_ZIG: '1'
steps:
# persist-credentials: false — runs tests + uploads a blob artifact; the
# default-persisted token must not be capturable through it (zizmor
@ -278,8 +284,14 @@ jobs:
shell: bash
run: python3 .github/scripts/check-tree-sitter-upgrade-readiness.py --assert-current
# GITNEXUS_REQUIRE_ZIG=1: every OS in this matrix has a published
# tree-sitter-zig prebuild, so the smoke's "optional grammar may be
# absent" exemption is revoked here and an ABI-broken Zig binding fails
# the job instead of being accepted as a clean absence.
- name: Run parser-loader ABI load-smoke (dynamic)
run: npx vitest run test/unit/parser-loader-abi.test.ts
env:
GITNEXUS_REQUIRE_ZIG: '1'
working-directory: gitnexus
# End-to-end smoke test for the #1728 packaging fix: pack the published

View file

@ -48,7 +48,7 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
languages: ${{ matrix.language }}
queries: security-and-quality
@ -79,6 +79,6 @@ jobs:
- 'gitnexus/src/server/grep-params.ts'
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
category: '/language:${{ matrix.language }}'

View file

@ -53,6 +53,6 @@ jobs:
retention-days: 5
- name: Upload to Security tab
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
sarif_file: results.sarif

View file

@ -76,7 +76,7 @@ jobs:
exit-code: '0'
- name: Upload to Security tab
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
sarif_file: trivy-${{ matrix.image.name }}.sarif
category: trivy-${{ matrix.image.name }}

View file

@ -76,7 +76,7 @@ jobs:
continue-on-error: true
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
with:
sarif_file: zizmor.sarif
category: zizmor

2
.gitignore vendored
View file

@ -31,6 +31,8 @@ npm-debug.log*
# Testing
coverage/
.tmp-test/
gitnexus/.tmp-test/
# Misc
*.local

View file

@ -51,8 +51,9 @@ RUN npm run postinstall --prefix gitnexus
# node:22-bookworm-slim
FROM node:22-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e AS runtime
# curl for the healthcheck; git for cloning; ca-certificates for TLS verification.
RUN apt-get update && apt-get install -y --no-install-recommends curl git ca-certificates && rm -rf /var/lib/apt/lists/* \
# curl for the healthcheck; git for cloning; procps for watch process identity;
# ca-certificates for TLS verification.
RUN apt-get update && apt-get install -y --no-install-recommends curl git procps ca-certificates && rm -rf /var/lib/apt/lists/* \
&& rm -rf /usr/local/lib/node_modules/npm \
&& rm -rf /usr/local/lib/node_modules/corepack \
&& rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack

View file

@ -450,12 +450,19 @@ gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow pa
gitnexus analyze --workers <n> # Parse worker pool size (>=1; default: cores-1, capped at 16,
# auto-sized to the repo). 0 is rejected — there is no sequential mode.
gitnexus analyze --spring-actuator ./actuator # Enrich with local Spring Boot Actuator JSON snapshots
gitnexus analyze --asyncapi-spec ./docs/asyncapi # Resolve broker addresses from AsyncAPI 3.x documents
gitnexus analyze --wal-checkpoint-threshold 67108864 # LadybugDB WAL auto-checkpoint threshold in bytes
# (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB)
```
`--spring-actuator` is explicitly opt-in and accepts either a JSON bundle keyed by `mappings`, `beans`, `conditions`, `configprops`, and/or `env`, or a directory containing endpoint-named JSON files. It confirms matching static nodes and adds conservative runtime-only routes, beans, and property keys. The configured input is excluded from source scanning; only normalized repository-relative exclusions are retained for future scans, never absolute paths. Env/configprops values, origins, condition messages, and source names are never persisted or printed. Because snapshots are external runtime state, an enabled run always rebuilds; the first later run without the option rebuilds once to remove runtime evidence. The same path can be set as `springActuator` in `.gitnexusrc`.
`--asyncapi-spec` is explicitly opt-in and accepts a directory of AsyncAPI documents or a single document; the path is resolved against the repository root, so a committed `docs/asyncapi` and an absolute cache written by something else both work. Each `operations[]` entry of an **AsyncAPI 3.x** document can contribute a `Destination` node keyed by broker and address, with `action: send` emitting `PUBLISHES_TO` and `action: receive` emitting `CONSUMES_FROM`, so a document and source code that name one address on one broker land on the same node. Edges start at the document, not at a callable — a document states that the service talks to an address, not which method does — and no address a document names is ever attached to an unresolved source site.
An operation must name a protocol, either through its own `bindings` or through the `servers[].protocol` of the servers its channel resolves to (a channel that lists no `servers` resolves to all of them); operations that name none are refused, as are operations whose two readings name different brokers, and channels that inherit a multi-protocol server set without choosing. HTTP and WebSocket documents are refused for destination minting: there the host rather than the address names the place, and an HTTP endpoint is already modelled as a `Route`. A parameterized address — a channel declaring `parameters`, or an address containing `{` — is refused rather than keyed: two services publishing `{env}.orders` share a pattern, not a queue. AsyncAPI **2.x is refused** under its own counted reason and never mapped, because its `publish`/`subscribe` are inverted relative to 3.x `send`/`receive` and a naive mapping would reverse the async graph while leaving it connected. Every refusal is counted, and a configured path that yields nothing is reported rather than passed over in silence.
Like Actuator snapshots, documents are external to git freshness — replacing one moves no commit and dirties no file — so an enabled run always rebuilds, and the first later run without the option rebuilds once to remove document-derived evidence. There is no glob-based auto-discovery, and the option is unsupported with `--watch`.
If `analyze` reports a worker parse timeout on a large or unusual repository, it keeps running and falls back safely. To give slow worker jobs more time, use `--worker-timeout 60` or set `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=60000`. For very large files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` controls the worker job byte budget.
**Embeddings node limit** — `gitnexus analyze --embeddings` generates semantic search vectors with a default 50,000-node safety cap to protect memory on large repositories:
@ -470,6 +477,46 @@ If embeddings are skipped on a large repository, the indexed graph likely exceed
</details>
<details>
<summary><strong>Keep remote repositories indexed with <code>gitnexus auto-sync</code></strong></summary>
`gitnexus auto-sync` clones or pulls configured repositories, analyzes new commits, and optionally syncs their group. It runs once immediately, then repeats on the configured interval. It runs in the foreground; use your process manager if it must survive a shell session. `gitnexus watch` is reserved and prints this split; it does not start auto-sync or local file watching.
```bash
# 1. Create the config once. It never overwrites an existing file.
gitnexus auto-sync init
# 2. Edit $GITNEXUS_HOME/watch_config.yml, then start it.
gitnexus auto-sync start # `gitnexus auto-sync` is equivalent
gitnexus auto-sync status
gitnexus auto-sync restart # Required after config changes
gitnexus auto-sync stop
gitnexus auto-sync reset # Clear failure state; leaves clones and indexes intact
```
`GITNEXUS_HOME` defaults to `~/.gitnexus`. A minimal configuration:
```yaml
sync_interval_minutes: 10
analyze_timeout: 5m
projects:
- local_path: /absolute/path/to/clones
branches: [main, master]
overwrite_local_changes: false
remote_urls:
- git@github.com:owner/repo.git
```
- `sync_interval_minutes` must be at least `5`; `local_path` must be an absolute path. Clones are stored below it as `host/namespace/repo`.
- Remote URLs must use SSH SCP form and are limited to GitHub, GitLab, or Gitee.
- `branches` are tried in order. The legacy `branch` field is supported, but do not set both.
- Analysis runs in an isolated worker; `analyze_timeout` defaults to, and cannot exceed, half of `sync_interval_minutes`. Timeout and `auto-sync stop` request safe cancellation; a worker in native work exits after reaching a JS-visible safe point. Until then, auto-sync reports `cancelling` or `stopping` and retains ownership so another auto-sync cannot take over, for up to 5 seconds — after that the parent stops waiting and leaves the worker to exit on its own rather than killing it mid-write. This behavior is the same on macOS and Windows. `overwrite_local_changes` defaults to `false`, so a dirty local clone is skipped rather than overwritten; setting it to `true` also deletes untracked files in the clone, while keeping ignored paths.
- Add `group_name` only after creating that group with `gitnexus group create <name>`. Partial clone output is isolated and removed after 14 days.
See the [full auto-sync configuration and runtime reference](gitnexus/README.md#gitnexus-auto-sync) for concurrency, timeouts, failure thresholds, and runtime files.
</details>
<details>
<summary><strong>Repository groups</strong> (multi-repo / monorepo service tracking)</summary>
@ -610,6 +657,7 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas
| C | — | — | ✓ | — | ✓ | ✓ | — | ✓ | ✓ |
| C++ | — | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| Dart | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| Zig | ✓ | — | ✓ | — | ✓ | ✓ | ✓ | — | ✓ |
**Imports** — cross-file import resolution · **Named Bindings** — `import { X as Y }` / re-export tracking · **Exports** — public/exported symbol detection · **Heritage** — class inheritance, interfaces, mixins · **Type Annotations** — explicit type extraction for receiver resolution · **Constructor Inference** — infer receiver type from constructor calls (`self`/`this` resolution included for all languages) · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics

View file

@ -19,14 +19,14 @@
*
* False-positive suppression:
* - Skips calls whose receiver is a known non-tree-sitter library (`JSON`,
* `URL`, `marked`, `Number`).
* `URL`, `marked`, `Number`, `path`).
* - Skips calls whose first argument is a string-literal (grammar-load smoke
* tests like `_testParser.parse('service X { rpc Y (R) returns (R); }')`).
* - Skips test files (`.test.ts`/`.test.tsx`/`.spec.ts`).
* - Skips the `safe-parse.ts` helper itself.
*/
const SKIPPED_RECEIVERS = new Set(['JSON', 'URL', 'marked', 'Number', 'Math']);
const SKIPPED_RECEIVERS = new Set(['JSON', 'URL', 'marked', 'Number', 'Math', 'path']);
export default {
meta: {
@ -74,7 +74,7 @@ export default {
// Receiver-text-shape skip: anything matching well-known JS APIs that
// happen to have a `.parse(<expr>)` shape but aren't tree-sitter.
if (
/^(JSON|URL|marked|Number|Math|Date|globalThis\.JSON)\b/.test(receiverText) ||
/^(JSON|URL|marked|Number|Math|Date|path|globalThis\.JSON)\b/.test(receiverText) ||
/\bjson\.parse\b/i.test(receiverText)
) {
return;

View file

@ -28,12 +28,13 @@ Run from the project root. This parses all source files, builds the knowledge gr
| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. |
| `--pdg` | Build the program-dependence layers used by `explain` and `pdg_query` (taint, CDG, and REACHING_DEF). |
| `--spring-actuator <path>` | Import opt-in Spring Boot Actuator mappings, beans, conditions, configprops, and env snapshots. Forces a full rebuild; unsupported with `--watch`. |
| `--asyncapi-spec <path>` | Read opt-in AsyncAPI 3.x documents (directory or single file) and mint `Destination` nodes from their operations. 2.x is refused, not mapped. Unsupported with `--watch`. |
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout.
For Spring runtime enrichment, pass a JSON bundle, one endpoint JSON file, or a directory containing endpoint files. Route evidence is authoritative only when `runtimeConfirmed === true`; `runtimeSource` records provenance and may also accompany `handler-conflict`. Env/configprops values are never persisted.
Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index.
Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Scheduled remote clone/pull is a different command: `gitnexus auto-sync`. Bare `gitnexus watch` is reserved and does not start either job. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index.
### status — Check index freshness

View file

@ -306,4 +306,32 @@ export interface GraphRelationship {
readonly weight: number;
readonly note?: string;
}[];
/**
* When `true`, the call site this edge was resolved from sits in a
* branch that is provably unreachable from the indexed source at
* compile time — e.g. a Zig `if (CONST_FALSE)` body, or the `else` of
* `if (CONST_TRUE)`, where the condition folds to a comptime-known
* boolean.
*
* This does NOT change what a `CALLS` edge means. `CALLS` still means
* "there is a resolved call site from A to B"; it has never meant "B is
* reachable from A", and this flag does not make it mean that.
* `staticGated` is additional, statically provable path-feasibility
* metadata on the edge: an opt-in
* analysis layer that a consumer may read (for example to rank or
* filter callers in an impact view) and that no core pass acts on.
* The edge is emitted, persisted, traversed and counted exactly as it
* was before the flag existed.
*
* Scope: only conditions that fold from constants in the source.
* Anything the index cannot know (build mode, target, environment) is
* never marked; the index has no production build configuration and
* does not claim to.
*
* Optional and additive: edges from languages that don't compute
* static gating leave this `undefined`, which readers treat
* identically to `false` (live). Currently set only by the Zig
* scope-capture emitter.
*/
staticGated?: boolean;
}

View file

@ -53,6 +53,7 @@ const EXTENSION_MAP: Record<SupportedLanguages, readonly string[]> = {
[SupportedLanguages.Dart]: ['.dart'],
[SupportedLanguages.Vue]: ['.vue'],
[SupportedLanguages.Cobol]: ['.cbl', '.cob', '.cpy', '.cobol'],
[SupportedLanguages.Zig]: ['.zig'],
} satisfies Record<SupportedLanguages, readonly string[]>; // Ensure exhaustiveness
/** Pre-built reverse lookup: extension → language (built once at module load). */
@ -121,6 +122,7 @@ const SYNTAX_MAP: Record<SupportedLanguages, string> = {
[SupportedLanguages.Dart]: 'dart',
[SupportedLanguages.Vue]: 'typescript',
[SupportedLanguages.Cobol]: 'cobol',
[SupportedLanguages.Zig]: 'zig',
} satisfies Record<SupportedLanguages, string>; // Ensure exhaustiveness
/** Non-code file extensions → Prism-compatible syntax identifiers */

View file

@ -22,4 +22,5 @@ export enum SupportedLanguages {
Vue = 'vue',
/** Standalone regex processor — no tree-sitter, no LanguageProvider. */
Cobol = 'cobol',
Zig = 'zig',
}

View file

@ -1072,6 +1072,7 @@ const CALLABLE_OR_TYPE_LIKE: ReadonlySet<string> = new Set([
'Interface',
'Enum',
'Struct',
'Union',
'Record',
'Trait',
'Namespace',

View file

@ -12,6 +12,9 @@
* - experimental: vue (embedded-language / SFC complexity),
* cobol (regex-provider path)
* - quarantined: (none)
*
* Added after Ring 1: zig enters as `experimental` (new language
* integration; promotion to `production` is a separate governance PR).
*/
import { SupportedLanguages } from '../languages.js';
@ -41,6 +44,7 @@ export const LanguageClassifications: Readonly<Record<SupportedLanguages, Langua
[SupportedLanguages.Dart]: 'production',
[SupportedLanguages.Vue]: 'experimental',
[SupportedLanguages.Cobol]: 'experimental',
[SupportedLanguages.Zig]: 'experimental',
};
/** Convenience predicate: is this language gating Ring 4 retirement? */

View file

@ -208,6 +208,16 @@ export interface ReferenceSite {
* else, so every other language's sites stay byte-identical.
*/
readonly embeddedAsPointer?: boolean;
/**
* The call sits inside a branch that is provably unreachable from the
* indexed source at compile time — a Zig `if (CONST_FALSE)` body, or the
* `else` of `if (CONST_TRUE)`,
* where the condition folds to a comptime-known boolean. Set only when
* `kind === 'call'` and only by languages that compute static gating (Zig
* today); absent everywhere else, so every other site stays byte-identical.
* Threaded to `Reference.staticGated` and then `GraphRelationship.staticGated`.
*/
readonly staticGated?: boolean;
}
/**

View file

@ -24,6 +24,9 @@
import type { NodeLabel } from '../graph/types.js';
import type { SymbolDefinition } from './symbol-definition.js';
// Type-only, so the `reference-site.ts` → `types.ts` import cycle is erased
// at compile time.
import type { CallForm } from './reference-site.js';
// ─── §2.1 Type aliases ──────────────────────────────────────────────────────
@ -752,6 +755,20 @@ export interface Reference {
| 'import-use'
| 'value-ref'
| 'macro';
/**
* Call form of the site this reference was resolved from, copied verbatim
* from `ReferenceSite.callForm`; set only when `kind === 'call'`. The
* emit phase reads it to tell a construction site (`T{…}`, `new T()`,
* `T { .. }` — form `'constructor'`) apart from an invocation, which in the
* graph are both `CALLS` edges. Optional and additive: a `Reference` built
* without it is emitted exactly as before.
*/
readonly callForm?: CallForm;
/** Copied from `ReferenceSite.staticGated` for `kind === 'call'`: the site is
* in a branch provably unreachable from the indexed source at compile time.
* The emit phase writes it to `GraphRelationship.staticGated` as metadata;
* see the contract note there. Optional and additive. */
readonly staticGated?: boolean;
readonly confidence: number;
readonly evidence: readonly ResolutionEvidence[];
}

View file

@ -16,7 +16,7 @@
"@langchain/openai": "^1.5.3",
"@sigma/edge-curve": "^3.1.0",
"@tailwindcss/vite": "^4.3.3",
"axios": "^1.19.0",
"axios": "^1.20.0",
"d3": "^7.9.0",
"dompurify": "^3.4.13",
"gitnexus-shared": "file:../gitnexus-shared",
@ -31,7 +31,7 @@
"langchain": "^1.5.4",
"lru-cache": "^11.5.2",
"lucide-react": "^1.31.0",
"mermaid": "^11.16.1",
"mermaid": "^11.17.2",
"mnemonist": "^0.40.4",
"pandemonium": "^2.4.0",
"react": "^19.2.5",
@ -48,9 +48,9 @@
},
"devDependencies": {
"@babel/types": "^8.0.4",
"@playwright/test": "^1.62.0",
"@playwright/test": "^1.62.1",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.6",
"@types/dompurify": "^3.2.0",
"@types/node": "^26.0.1",
@ -59,7 +59,7 @@
"@types/react-syntax-highlighter": "^15.5.13",
"@vercel/node": "^5.10.2",
"@vitejs/plugin-react": "^6.0.5",
"@vitest/coverage-v8": "^4.1.9",
"@vitest/coverage-v8": "^4.1.11",
"jsdom": "^29.1.1",
"tree-sitter-wasms": "^0.1.13",
"typescript": "^5.4.5",
@ -1323,9 +1323,9 @@
}
},
"node_modules/@mermaid-js/parser": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.0.tgz",
"integrity": "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.1.tgz",
"integrity": "sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==",
"license": "MIT",
"dependencies": {
"@chevrotain/types": "~11.1.2"
@ -1397,13 +1397,13 @@
}
},
"node_modules/@playwright/test": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
"integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.0"
"playwright": "1.62.1"
},
"bin": {
"playwright": "cli.js"
@ -2073,9 +2073,9 @@
"license": "MIT"
},
"node_modules/@testing-library/react": {
"version": "16.3.2",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
"integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
"version": "16.3.3",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz",
"integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2742,14 +2742,14 @@
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz",
"integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz",
"integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.10",
"@vitest/utils": "4.1.11",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
@ -2763,8 +2763,8 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.10",
"vitest": "4.1.10"
"@vitest/browser": "4.1.11",
"vitest": "4.1.11"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@ -2773,16 +2773,16 @@
}
},
"node_modules/@vitest/expect": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@ -2791,13 +2791,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.10",
"@vitest/spy": "4.1.11",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@ -2828,9 +2828,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2841,13 +2841,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.10",
"@vitest/utils": "4.1.11",
"pathe": "^2.0.3"
},
"funding": {
@ -2855,14 +2855,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/utils": "4.1.10",
"@vitest/pretty-format": "4.1.11",
"@vitest/utils": "4.1.11",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@ -2871,9 +2871,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
"dev": true,
"license": "MIT",
"funding": {
@ -2881,13 +2881,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/pretty-format": "4.1.11",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@ -3052,9 +3052,9 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz",
"integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==",
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz",
"integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
@ -4312,9 +4312,9 @@
"license": "Unlicense"
},
"node_modules/fast-uri": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
"dev": true,
"funding": [
{
@ -4328,6 +4328,15 @@
],
"license": "BSD-3-Clause"
},
"node_modules/fastdom": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/fastdom/-/fastdom-1.0.12.tgz",
"integrity": "sha512-LB+xjSTEbjHE1cWsxu+tN2Xqr1kpi+V9aADI7sVM5ZMaXyYGPHULQMzpJMYqOTULK/73pUkWVzzObFRBkPr+hg==",
"license": "MIT",
"dependencies": {
"strictdom": "^1.0.1"
}
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@ -6006,26 +6015,27 @@
}
},
"node_modules/mermaid": {
"version": "11.16.1",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.16.1.tgz",
"integrity": "sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g==",
"version": "11.17.2",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.17.2.tgz",
"integrity": "sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg==",
"license": "MIT",
"dependencies": {
"@braintree/sanitize-url": "^7.1.2",
"@iconify/utils": "^3.0.2",
"@mermaid-js/parser": "^1.2.0",
"@mermaid-js/parser": "^1.2.1",
"@types/d3": "^7.4.3",
"@upsetjs/venn.js": "^2.0.0",
"cytoscape": "^3.33.3",
"cytoscape": "^3.34.0",
"cytoscape-cose-bilkent": "^4.1.0",
"cytoscape-fcose": "^2.2.0",
"d3": "^7.9.0",
"d3-sankey": "^0.12.3",
"dagre-d3-es": "7.0.14",
"dayjs": "^1.11.20",
"dayjs": "^1.11.21",
"dompurify": "^3.3.3",
"es-toolkit": "^1.45.1",
"katex": "^0.16.45",
"fastdom": "1.0.12",
"katex": "^0.16.47",
"khroma": "^2.1.0",
"marked": "^16.3.0",
"roughjs": "^4.6.6",
@ -7091,13 +7101,13 @@
}
},
"node_modules/playwright": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.0"
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
@ -7110,9 +7120,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@ -7728,6 +7738,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/strictdom": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/strictdom/-/strictdom-1.0.1.tgz",
"integrity": "sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg==",
"license": "MIT"
},
"node_modules/stringify-entities": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
@ -8288,19 +8304,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.10",
"@vitest/mocker": "4.1.10",
"@vitest/pretty-format": "4.1.10",
"@vitest/runner": "4.1.10",
"@vitest/snapshot": "4.1.10",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"@vitest/expect": "4.1.11",
"@vitest/mocker": "4.1.11",
"@vitest/pretty-format": "4.1.11",
"@vitest/runner": "4.1.11",
"@vitest/snapshot": "4.1.11",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@ -8328,12 +8344,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.10",
"@vitest/browser-preview": "4.1.10",
"@vitest/browser-webdriverio": "4.1.10",
"@vitest/coverage-istanbul": "4.1.10",
"@vitest/coverage-v8": "4.1.10",
"@vitest/ui": "4.1.10",
"@vitest/browser-playwright": "4.1.11",
"@vitest/browser-preview": "4.1.11",
"@vitest/browser-webdriverio": "4.1.11",
"@vitest/coverage-istanbul": "4.1.11",
"@vitest/coverage-v8": "4.1.11",
"@vitest/ui": "4.1.11",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"

View file

@ -26,7 +26,7 @@
"@langchain/openai": "^1.5.3",
"@sigma/edge-curve": "^3.1.0",
"@tailwindcss/vite": "^4.3.3",
"axios": "^1.19.0",
"axios": "^1.20.0",
"d3": "^7.9.0",
"dompurify": "^3.4.13",
"gitnexus-shared": "file:../gitnexus-shared",
@ -41,7 +41,7 @@
"langchain": "^1.5.4",
"lru-cache": "^11.5.2",
"lucide-react": "^1.31.0",
"mermaid": "^11.16.1",
"mermaid": "^11.17.2",
"mnemonist": "^0.40.4",
"pandemonium": "^2.4.0",
"react": "^19.2.5",
@ -58,9 +58,9 @@
},
"devDependencies": {
"@babel/types": "^8.0.4",
"@playwright/test": "^1.62.0",
"@playwright/test": "^1.62.1",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.6",
"@types/dompurify": "^3.2.0",
"@types/node": "^26.0.1",
@ -69,7 +69,7 @@
"@types/react-syntax-highlighter": "^15.5.13",
"@vercel/node": "^5.10.2",
"@vitejs/plugin-react": "^6.0.5",
"@vitest/coverage-v8": "^4.1.9",
"@vitest/coverage-v8": "^4.1.11",
"jsdom": "^29.1.1",
"tree-sitter-wasms": "^0.1.13",
"typescript": "^5.4.5",

View file

@ -249,6 +249,7 @@ gitnexus analyze --verbose # Log skipped files when parsers are unavailabl
gitnexus analyze --max-file-size 1024 # Skip files larger than N KB (default: 512, cap: 32768)
gitnexus analyze --worker-timeout 60 # Increase worker idle timeout for slow parses
gitnexus analyze --wal-checkpoint-threshold 67108864 # 64 MiB. Control LadybugDB WAL auto-checkpoint threshold (default: 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB)
gitnexus auto-sync [init|start|restart|stop|status|reset] # Scheduled remote clone/pull + analyze from GITNEXUS_HOME/watch_config.yml
gitnexus mcp # Start MCP server (stdio) — serves all indexed repos
gitnexus serve # Start local HTTP server (multi-repo) for web UI
gitnexus index # Register an existing .gitnexus/ folder into the global registry
@ -309,6 +310,28 @@ and `serve` processes periodically check for a newly published index and reopen
it without a restart. MCP checks are throttled to once every five seconds, so a
tool call before the next check can briefly use the previous index.
### `gitnexus auto-sync`
`gitnexus auto-sync` is a different product from `gitnexus analyze --watch`. It is the explicit long-running auto-sync entrypoint that clones or pulls configured remotes. `gitnexus watch` is reserved and does not start either job: it prints this split. `GITNEXUS_HOME` defaults to `~/.gitnexus`; `gitnexus auto-sync init` creates its default `$GITNEXUS_HOME/watch_config.yml`. Bare `gitnexus auto-sync` is the same as `gitnexus auto-sync start`; `restart`, `stop`, `status`, and `reset` manage the same `GITNEXUS_HOME` instance. `reset` removes only the derived analysis state and commit snapshot; clones, indexes, and registry entries are untouched. `start` runs in the foreground, reads the configuration once at startup, runs once immediately, then repeats on `sync_interval_minutes`; restart it after changing the configuration. Watch runtime artifacts live under `$GITNEXUS_HOME/watch/`: `project_commit_info.txt` is the human-readable per-loop snapshot, `auto-sync-state.json` is the machine state used for commit skipping and analyze failure thresholds, `watch.mutex` prevents multiple auto-sync processes for one home, `watch.owner.json` records ownership metadata, `watch.pid` plus `watch.status.json` expose process state, `watch.stop.<ownerId>.json` is a temporary owner-fenced stop request, and `quarantine/` stores partial clone output before entries are removed after 14 days, keeping at most the five newest entries per repository regardless of age. Mutexes with verified dead owners are reclaimed automatically after an abnormal exit. Invalid or legacy mutexes fail closed; confirm no auto-sync process is running before manually removing `watch.mutex` and stale `watch.pid` / `watch.owner.json`.
```yaml
sync_interval_minutes: 10
max_concurrency: 1
repo_git_timeout: 10s
analyze_timeout: 5m
analyze_failure_threshold: 3
projects:
- local_path: /abs/path/to/repos
branches: [master, main]
overwrite_local_changes: false
remote_urls:
- git@github.com:owner/repo.git
- git@gitlab.com:group/repo.git
- git@gitee.com:owner/repo.git
```
`sync_interval_minutes` must be an integer of at least `5`. `local_path` must be an absolute path without traversal; each remote is cloned below it as `host/namespace/repo`, preventing same-basename repositories from colliding. `remote_urls` must use SSH SCP form for github.com, gitlab.com, or gitee.com. `repo_git_timeout` applies to each repo clone/pull and defaults to `10s`; a bare number such as `10` is interpreted as seconds, while `10000ms`, `10s`, and `1m` keep their explicit units. It must not exceed one hour or `sync_interval_minutes`, whichever is smaller — so a bare `600000` is rejected, because it means 600000 seconds rather than milliseconds. `analyze_timeout` applies to each isolated analysis worker, defaults to half of `sync_interval_minutes`, and cannot exceed that value; this keeps it within Node's timer range. Timeout and `auto-sync stop` request safe cancellation; a worker already in native work exits after it returns to a JS-visible safe point. While waiting, auto-sync reports `cancelling` or `stopping` and keeps its ownership files so another auto-sync cannot take over. The parent waits up to 5 seconds for the worker to exit; after that it stops waiting, releases its ownership files, and leaves the worker to finish and exit on its own rather than killing it mid-write. `auto-sync stop` uses this same control path on macOS and Windows. `overwrite_local_changes` defaults to `false`; a dirty local clone is skipped with an error log, while `true` allows branch fallback to replace local changes and additionally discards untracked files and directories in the clone after checkout — ignored paths, including GitNexus's own `.gitnexus/` storage, are preserved. `max_concurrency` defaults to `1` and is capped at runtime by `floor(availableMemoryGB / 2)` with a minimum of `1`; the effective value is printed at the start of each loop. Each analysis worker's heap cap is the machine-wide cap divided by the number of repositories analyzed in parallel, so concurrent workers share one memory budget instead of each claiming the whole machine. `analyze_failure_threshold` defaults to `3`, must be at least `2`, and pauses repeated failures only for the same repo branch and commit; a new commit or `gitnexus auto-sync reset` clears the block and allows analysis again. Repositories are registered and added to groups by their full remote identity (`host/namespace/repo`), so repositories with the same basename remain distinct. Use `branches` to try branches in order; legacy `branch` remains supported, but the two fields cannot be set together. If all branches are unavailable or time out, watch logs an error, records the repo status, and skips that repo for the loop. Leave `group_name` empty or omit it to skip group add/sync for that project; otherwise create the group first with `gitnexus group create <name>`. `$GITNEXUS_HOME/watch/project_commit_info.txt` is for inspection only; GitNexus stores machine state separately in `$GITNEXUS_HOME/watch/auto-sync-state.json`.
GraphQL contract matching is opt-in in the group's `group.yaml`:
```yaml
@ -326,6 +349,12 @@ anchors are deliberately omitted. Add common infrastructure fields such as `/hea
`--spring-actuator` is explicitly opt-in. The path may be a JSON bundle keyed by `mappings`, `beans`, `conditions`, `configprops`, and/or `env`, or a directory containing endpoint-named JSON files. Runtime mappings and beans confirm matching static nodes; conditions and configuration property keys enrich existing evidence, with conservative runtime-only nodes added when no match exists. The configured input is excluded from source scanning; only normalized repository-relative exclusions are retained for future scans, never absolute paths. Env/configprops values, origins, condition messages, and source names are never persisted or printed. Enabled runs always rebuild because runtime snapshots are external to git freshness; omitting the option later rebuilds once to remove runtime evidence. Project config can set the same path with `springActuator` in `.gitnexusrc`.
`--asyncapi-spec` is explicitly opt-in and accepts a directory of AsyncAPI documents or a single document; the path is resolved against the repository root, so a committed `docs/asyncapi` and an absolute cache written by something else both work. Each `operations[]` entry of an **AsyncAPI 3.x** document can contribute a `Destination` node keyed by broker and address, with `action: send` emitting `PUBLISHES_TO` and `action: receive` emitting `CONSUMES_FROM`, so a document and source code that name one address on one broker land on the same node. Edges start at the document, not at a callable — a document states that the service talks to an address, not which method does — and no address a document names is ever attached to an unresolved source site.
An operation must name a protocol, either through its own `bindings` or through the `servers[].protocol` of the servers its channel resolves to (a channel that lists no `servers` resolves to all of them); operations that name none are refused, as are operations whose two readings name different brokers, and channels that inherit a multi-protocol server set without choosing. HTTP and WebSocket documents are refused for destination minting: there the host rather than the address names the place, and an HTTP endpoint is already modelled as a `Route`. A parameterized address — a channel declaring `parameters`, or an address containing `{` — is refused rather than keyed: two services publishing `{env}.orders` share a pattern, not a queue. AsyncAPI **2.x is refused** under its own counted reason and never mapped, because its `publish`/`subscribe` are inverted relative to 3.x `send`/`receive` and a naive mapping would reverse the async graph while leaving it connected. Every refusal is counted, and a configured path that yields nothing is reported rather than passed over in silence.
Like Actuator snapshots, documents are external to git freshness — replacing one moves no commit and dirties no file — so an enabled run always rebuilds, and the first later run without the option rebuilds once to remove document-derived evidence. There is no glob-based auto-discovery, and the option is unsupported with `--watch`.
> **`gitnexus uninstall`** reverses `gitnexus setup` — it removes the GitNexus MCP entries, hooks, and skill directories it added to each detected editor. Skill directories are identified **by bundled gitnexus skill name** (e.g. `gitnexus-cli/`), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass `--force` to apply. Per-repo indexes (`gitnexus clean --all`) and the global npm package (`npm uninstall -g gitnexus`) are left for you to remove.
## Remote Embeddings
@ -387,7 +416,7 @@ GitNexus supports indexing multiple repositories. Each `gitnexus analyze` regist
## Supported Languages
TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift, Ruby, Dart
TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift, Ruby, Dart, Zig
### Language Feature Matrix
@ -407,6 +436,7 @@ TypeScript, JavaScript, Python, Java, C, C++, C#, Go, Rust, PHP, Kotlin, Swift,
| C | — | — | ✓ | — | ✓ | ✓ | — | ✓ | ✓ |
| C++ | — | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| Dart | ✓ | — | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ |
| Zig | ✓ | — | ✓ | — | ✓ | ✓ | ✓ | — | ✓ |
**Imports** — cross-file import resolution · **Named Bindings** — `import { X as Y }` / re-export tracking · **Exports** — public/exported symbol detection · **Heritage** — class inheritance, interfaces, mixins · **Type Annotations** — explicit type extraction for receiver resolution · **Constructor Inference** — infer receiver type from constructor calls (`self`/`this` resolution included for all languages) · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics
@ -565,7 +595,7 @@ results until you re-run `gitnexus analyze --repair-fts` from a shell where the
### Installation fails with native module errors
Some optional language grammars (Dart, Proto, Swift, Kotlin) require native compilation. If they fail, GitNexus still works — those languages will be skipped. To skip them intentionally (no C++ toolchain needed), set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before installing.
Some optional language grammars (Dart, Proto, Swift, Kotlin) require native compilation. If they fail, GitNexus still works — those languages will be skipped. To skip them intentionally (no C++ toolchain needed), set `GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1` before installing. Zig (`@tree-sitter-grammars/tree-sitter-zig`, an npm `optionalDependency`) behaves the same way: if its native binding fails to install, `.zig` files are skipped.
If `npm install -g gitnexus` fails on native modules:

View file

@ -1,4 +1,5 @@
{
"fingerprint": "381de8dede253140953775c290bf9a25f82ebf3cc8ecb5250ae06a63f648b089",
"fingerprint": "dadb6eb19a751c3d315b7c53effc6605f89e0a766b01cd5a2ae611b3720d0d55",
"_rebaselined_3161_static_gated_column": "Same change as baselines.json's `_rebaselined_3161_static_gated_column`: every relationship row now ends in a `staticGated` cell (`0` here, no PDG edge is a gated call), so each of the 36,000 `rel_BasicBlock_BasicBlock.csv` data rows grew by 2 bytes (`...,\"T\",0` -> `...,\"T\",0,0`) while the 7,200 BasicBlock rows are unchanged. Evidence is the inverse operation: stripping the trailing `,0` from the rel rows and re-hashing the sorted bb + rel rows gives EXACTLY the prior baseline 381de8dede253140953775c290bf9a25f82ebf3cc8ecb5250ae06a63f648b089. byte_identical_nodes / byte_identical_edges stayed true and resident_basic_blocks stayed 0 while the fingerprint gate was red, so the streamed sink still matches the whole-graph emit byte for byte and the RSS bound holds. Prior 381de8dede253140953775c290bf9a25f82ebf3cc8ecb5250ae06a63f648b089 -> dadb6eb19a751c3d315b7c53effc6605f89e0a766b01cd5a2ae611b3720d0d55.",
"_note": "Byte-identity + bounded-retention gate for streaming/chunked PDG emit (#2202). fingerprint = sha256 of the sorted, header-stripped BasicBlock + PDG-edge data rows of the canonical synthetic set. --check also asserts the streamed PdgEmitSink output is byte-identical to the whole-graph streamAllCSVsToDisk emit (byte_identical_nodes/edges) and that the in-memory graph retains 0 BasicBlocks (resident_basic_blocks === 0, the O(chunk) RSS bound). Regenerate via `node --import tsx bench/emit-persistence/measure-streaming.mjs`."
}

View file

@ -1,11 +1,12 @@
{
"fingerprint": "72096279092d4f118de7e179333705c19c9aff2664f77d71a7da48cd9f73fb5a",
"fingerprint": "011485e5180c005be9dfec7ac8dc4e3bb0fcfc4a680a16dcae2cf2d8af5ef7e2",
"_rebaselined_3161_static_gated_column": "Every relationship row gained a trailing `staticGated` BOOLEAN column (RELATION_SCHEMA in src/core/lbug/schema.ts, REL_CSV_HEADER + buildRelRow in csv-generator.ts, the fallback CREATE in lbug-adapter.ts), written as `0` for every edge that does not carry the flag and `1` for a Zig call site inside a comptime-false branch (PR #3161). Unlike the earlier header-only rebaselines this one touches ROWS, so the evidence is the inverse operation rather than a file-set diff: re-emitting this bench's 2,400-entity graph on the branch produces the same 36 CSV files; exactly 3 of them differ from a copy with the new column stripped (`rel_File_Class.csv` 156228 -> 151416 bytes, `rel_File_Function.csv` 334008 -> 324396, `rel_Function_Function.csv` 185028 -> 180216: one header field plus `,0` per row, 4,800 / 9,600 / 4,800 rows, 2 bytes each), the other 33 files are byte-identical, and the per-file fingerprint over the stripped copies is EXACTLY the prior baseline 72096279092d4f118de7e179333705c19c9aff2664f77d71a7da48cd9f73fb5a. So no row moved between pair files and no row reordered; the only bytes that changed are the appended column. Prior 72096279092d4f118de7e179333705c19c9aff2664f77d71a7da48cd9f73fb5a -> 011485e5180c005be9dfec7ac8dc4e3bb0fcfc4a680a16dcae2cf2d8af5ef7e2. Timing gates passed while the guard was red: scaling_ratio 0.82 against the 1.8 budget, elapsed_ms_large 185.54ms against the 1000ms backstop, so no throughput claim is being rebaselined away.",
"scaling_budget": 1.8,
"max_ms_large": 1000,
"_rebaselined_destination_broker_conflict_column_removed": "The `Destination` node table lost its trailing `brokerConflict` STRING column (DESTINATION_SCHEMA in src/core/lbug/schema.ts, the COPY statement in lbug-adapter.ts, and the `destinationWriter` header plus its row cell in csv-generator.ts). The column existed only to say WHY a destination's address had been withdrawn when two brokers claimed it; the address is no longer withdrawn — a resolved `Destination` is now keyed by `(broker, address)` via `ingestion/destination-key.ts`, so two brokers on one name are two ordinary joinable nodes and there is nothing to diagnose. That makes this a header-only shrink, and it was verified as one rather than assumed: dumping every CSV this bench emits with csv-generator.ts at the merge base and again on this branch, then diffing per file by filename, byte length and sha256, shows the file SET identical at 36 CSVs on both sides, 35 of the 36 byte-IDENTICAL (same sha256, not merely same length), and the sole difference `destination.csv` shrinking 112 -> 97 bytes: `id,name,filePath,startLine,endLine,address,broker,resolution,configKey,configDefault,brokerConflict,description` -> the same list without `brokerConflict`. That file is header-only on both sides — the synthetic benchmark graph contains no Destination nodes — so no row moved, was re-routed to another pair file, or reordered, which is the class of change this fingerprint exists to catch. Prior 4b339233662b0eebb236738abad8f7c039b9930bc1222dcad7b814afa6332fbf -> 72096279092d4f118de7e179333705c19c9aff2664f77d71a7da48cd9f73fb5a, reproduced identically across two consecutive runs. Both timing gates passed while the guard was red (scaling_ratio 0.818 and 0.751 across those two runs against the 1.8 budget; elapsed_ms_large 65.64ms and 59.32ms against the 1000ms backstop), so no throughput claim is being rebaselined away.",
"_rebaselined_3132_destination_node_table": "A new `Destination` node table (async messaging overlay; see DESTINATION_SCHEMA in src/core/lbug/schema.ts) means `streamAllCSVsToDisk` writes one more FILE, not one more column — the first rebaseline here that changes the file SET rather than a header. That makes the usual evidence more important, not less, so it was gathered the same way: dump every CSV this bench emits on the merge base and on this branch, then diff per-file by filename, byte length and sha256. Result: 35 files -> 36, the sole addition is `destination.csv`, and ALL 35 pre-existing files are byte-IDENTICAL — not merely same-length, same sha256. So nothing was re-routed into the new file and nothing reordered, which is exactly what this fingerprint exists to catch. `destination.csv` is 112 bytes, header only: the synthetic benchmark graph has no Destination nodes, so no row exists to move. Prior 7b2ec01a110dcbc66868fba2c97714aaece8c3864c2eba00df68b5c027f034d2 -> 4b339233662b0eebb236738abad8f7c039b9930bc1222dcad7b814afa6332fbf, reproduced identically across two runs. Both timing gates passed while this was red (scaling_ratio 0.711 vs the 1.8 budget, elapsed_ms_large 58.88ms vs the 1000ms backstop), so no throughput claim is being rebaselined away.",
"_rebaselined_2856_property_is_detail": "Third and last of the bench guards this branch left red. The Property node table gained an `isDetail` BOOLEAN column (see PROPERTY_SCHEMA in src/core/lbug/schema.ts), so `streamAllCSVsToDisk` writes one more header field and one more cell per Property row — csv-generator.ts `propertyHeader` and the `node.label === 'Property'` tail. Verified to be header-only drift rather than a change in what is emitted: dumping every CSV this bench produces on `origin/main` and on this branch and diffing per-file (filename, byte length, sha256) shows the file SET is identical at 35 CSVs on both sides, 34 of the 35 are byte-identical, and the sole difference is `property.csv` growing 68 -> 77 bytes, `id,name,filePath,startLine,endLine,content,description,declaredType` -> `...,declaredType,isDetail`. The synthetic graph has no Property nodes, so no ROW moved at all. That is the check that matters here: a row routed to the wrong pair file, or a within-file reordering, is what this fingerprint exists to catch, and neither happened. Prior 69e9182ae205183ade24c3d8ad5d7292aea677144b1cbe443dd631bc25b0cafe -> 4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5. Both timing gates passed unchanged while this was red (scaling_ratio 0.783 vs budget 1.8, elapsed_ms_large 229ms vs the 1000ms backstop), so no throughput claim is being rebaselined away.",
"_rebaselined_destination_broker_conflict_column_removed": "The `Destination` node table lost its trailing `brokerConflict` STRING column (DESTINATION_SCHEMA in src/core/lbug/schema.ts, the COPY statement in lbug-adapter.ts, and the `destinationWriter` header plus its row cell in csv-generator.ts). The column existed only to say WHY a destination's address had been withdrawn when two brokers claimed it; the address is no longer withdrawn \u2014 a resolved `Destination` is now keyed by `(broker, address)` via `ingestion/destination-key.ts`, so two brokers on one name are two ordinary joinable nodes and there is nothing to diagnose. That makes this a header-only shrink, and it was verified as one rather than assumed: dumping every CSV this bench emits with csv-generator.ts at the merge base and again on this branch, then diffing per file by filename, byte length and sha256, shows the file SET identical at 36 CSVs on both sides, 35 of the 36 byte-IDENTICAL (same sha256, not merely same length), and the sole difference `destination.csv` shrinking 112 -> 97 bytes: `id,name,filePath,startLine,endLine,address,broker,resolution,configKey,configDefault,brokerConflict,description` -> the same list without `brokerConflict`. That file is header-only on both sides \u2014 the synthetic benchmark graph contains no Destination nodes \u2014 so no row moved, was re-routed to another pair file, or reordered, which is the class of change this fingerprint exists to catch. Prior 4b339233662b0eebb236738abad8f7c039b9930bc1222dcad7b814afa6332fbf -> 72096279092d4f118de7e179333705c19c9aff2664f77d71a7da48cd9f73fb5a, reproduced identically across two consecutive runs. Both timing gates passed while the guard was red (scaling_ratio 0.818 and 0.751 across those two runs against the 1.8 budget; elapsed_ms_large 65.64ms and 59.32ms against the 1000ms backstop), so no throughput claim is being rebaselined away.",
"_rebaselined_3132_destination_node_table": "A new `Destination` node table (async messaging overlay; see DESTINATION_SCHEMA in src/core/lbug/schema.ts) means `streamAllCSVsToDisk` writes one more FILE, not one more column \u2014 the first rebaseline here that changes the file SET rather than a header. That makes the usual evidence more important, not less, so it was gathered the same way: dump every CSV this bench emits on the merge base and on this branch, then diff per-file by filename, byte length and sha256. Result: 35 files -> 36, the sole addition is `destination.csv`, and ALL 35 pre-existing files are byte-IDENTICAL \u2014 not merely same-length, same sha256. So nothing was re-routed into the new file and nothing reordered, which is exactly what this fingerprint exists to catch. `destination.csv` is 112 bytes, header only: the synthetic benchmark graph has no Destination nodes, so no row exists to move. Prior 7b2ec01a110dcbc66868fba2c97714aaece8c3864c2eba00df68b5c027f034d2 -> 4b339233662b0eebb236738abad8f7c039b9930bc1222dcad7b814afa6332fbf, reproduced identically across two runs. Both timing gates passed while this was red (scaling_ratio 0.711 vs the 1.8 budget, elapsed_ms_large 58.88ms vs the 1000ms backstop), so no throughput claim is being rebaselined away.",
"_rebaselined_2856_property_is_detail": "Third and last of the bench guards this branch left red. The Property node table gained an `isDetail` BOOLEAN column (see PROPERTY_SCHEMA in src/core/lbug/schema.ts), so `streamAllCSVsToDisk` writes one more header field and one more cell per Property row \u2014 csv-generator.ts `propertyHeader` and the `node.label === 'Property'` tail. Verified to be header-only drift rather than a change in what is emitted: dumping every CSV this bench produces on `origin/main` and on this branch and diffing per-file (filename, byte length, sha256) shows the file SET is identical at 35 CSVs on both sides, 34 of the 35 are byte-identical, and the sole difference is `property.csv` growing 68 -> 77 bytes, `id,name,filePath,startLine,endLine,content,description,declaredType` -> `...,declaredType,isDetail`. The synthetic graph has no Property nodes, so no ROW moved at all. That is the check that matters here: a row routed to the wrong pair file, or a within-file reordering, is what this fingerprint exists to catch, and neither happened. Prior 69e9182ae205183ade24c3d8ad5d7292aea677144b1cbe443dd631bc25b0cafe -> 4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5. Both timing gates passed unchanged while this was red (scaling_ratio 0.783 vs budget 1.8, elapsed_ms_large 229ms vs the 1000ms backstop), so no throughput claim is being rebaselined away.",
"_rebaselined_3040_convex_endpoint_factory": "Const and Function gained a trailing convexEndpointFactory column. A deterministic 2,400-entity emit produced the same 35 CSV files and fingerprint c4d799c5336d616955b3530ba051b7dca300d1a0e412a66741cf2f27e04c533e. Removing the new Const and Function header fields plus the new trailing empty Function cell from each of 4,800 Function rows restored the exact prior fingerprint 4ee15e742a9839671a900df4f57c1c91196c64256c8cab2ac445bec605a092d5. No file or row moved or reordered. The measured scaling ratio remained 0.826 against the 1.8 budget and elapsed_ms_large was 307.75ms against the 1000ms backstop.",
"_rebaselined_3107_route_runtime_evidence": "Route gained trailing runtimeConfirmed BOOLEAN, runtimeSource STRING, and runtimeStatus STRING columns in its schema, CSV header/rows, COPY statement, and graph API projection. The deterministic emit still produces the same 35 CSV files; the synthetic benchmark graph has no Route rows, so the only byte drift is the Route CSV header and no row moved or reordered. Prior c4d799c5336d616955b3530ba051b7dca300d1a0e412a66741cf2f27e04c533e -> 7b2ec01a110dcbc66868fba2c97714aaece8c3864c2eba00df68b5c027f034d2. While the guard was red, scaling_ratio was 1.044 against the 1.8 budget and elapsed_ms_large was 121.08ms against the 1000ms backstop.",
"_note": "fingerprint = sha256 over per-file digests (filename + sha256(file bytes)), entry list sorted — binds each emitted line to its file so a row routed to the WRONG pair file changes the hash, AND catches within-file row reordering (file bytes hashed as-written). Byte-identity gate for #2203 U2/U3. NOTE: a future change that legitimately reorders emit (without changing the node/edge SET) will trip --check; regenerate then, and record WHY in a `_rebaselined_<reason>` key alongside — bench/scope-capture/baselines.json sets that convention and it is what makes a regenerated hash reviewable. scaling_budget bounds (t_large/t_small)/(LARGE/SMALL): observed ~0.95-1.05 (linear); 1.8 tolerates disk-I/O timing noise on CI while still catching an O(n^2) re-regression (~4x). max_ms_large=1000ms is a coarse absolute backstop (observed ~200ms) that catches a gross uniform slowdown the ratio gate misses; generous so CI host noise won't flake it. Regenerate via `node --import tsx bench/emit-persistence/measure.mjs`."
"_note": "fingerprint = sha256 over per-file digests (filename + sha256(file bytes)), entry list sorted \u2014 binds each emitted line to its file so a row routed to the WRONG pair file changes the hash, AND catches within-file row reordering (file bytes hashed as-written). Byte-identity gate for #2203 U2/U3. NOTE: a future change that legitimately reorders emit (without changing the node/edge SET) will trip --check; regenerate then, and record WHY in a `_rebaselined_<reason>` key alongside \u2014 bench/scope-capture/baselines.json sets that convention and it is what makes a regenerated hash reviewable. scaling_budget bounds (t_large/t_small)/(LARGE/SMALL): observed ~0.95-1.05 (linear); 1.8 tolerates disk-I/O timing noise on CI while still catching an O(n^2) re-regression (~4x). max_ms_large=1000ms is a coarse absolute backstop (observed ~200ms) that catches a gross uniform slowdown the ratio gate misses; generous so CI host noise won't flake it. Regenerate via `node --import tsx bench/emit-persistence/measure.mjs`."
}

File diff suppressed because one or more lines are too long

View file

@ -4,7 +4,9 @@
* `--check` inventory arm at the foot of this file fails when the two disagree
* — over ONE shared corpus so the arms are directly comparable. One arm per
* registered language, plus a second `csharp` arm carrying csproj configs
* (#2902), so there is one more arm than there are languages.
* (#2902), so there is one more arm than there are languages. The newest row
* is `zig` (PR #1432), added the day its resolver registered — the inventory
* arm below is what noticed it missing, which is the arm doing its job.
*
* NO LANGUAGE IS OMITTED, and that is the point of the list rather than an
* accident of it. Nine of these arms (go, csharp, csharp_csproj, dart, ruby,
@ -109,6 +111,19 @@
* depth-then-lexicographic tie-break, so the collide arm (a `mod{n}` header
* in every service's `include/`) is where it grows: 2.54 / 2.64 against
* 1.06 on file count.
* - zig: `resolveZigImportInternal` is rust's shape — an `@import("…zig")`
* path is walked component by component from the importer's directory and
* probed with two `allFiles.has(...)` calls (as written, then `+ '.zig'`),
* and a bare name is a Map lookup in the build config or a miss. No index
* is built, so the cost is O(path SEGMENTS) and flat in the file count,
* and — as for rust — its collide arm is a deep tree whose spellings carry
* ~4x the components rather than a shared-leaf layout that cannot fail.
* The arm passes NO build config (`buildZon` null): the bare-name legs
* (`b.addModule` roots, zon `.path` deps) read `build.zig` / `build.zig.zon`
* through `loadZigBuildConfig` and are gated by
* `test/unit/zig-import-resolver.test.ts`, so this fingerprint pins the
* path-walking resolver alone and does not move when that config parsing
* changes.
*
* Two properties of the corpus are load-bearing and must not be "simplified":
*
@ -221,7 +236,9 @@
* corpus is a deep module tree whose targets carry ~2x the `::` segments,
* which is the axis that CAN grow; the ratio across file counts staying at
* 1.06 on it is the assertion, and `collide_ms_ceiling` bounds the absolute
* cost of the long-path probe.
* cost of the long-path probe. zig's collide arm is built the same way and
* for the same reason: a deep tree whose `../../…/l4/mod{n}/file.zig`
* spellings walk ~4x the components of the unique arm's `../mod{n}/…`.
*
* This is a scope-of-claim limit, not a regression: on the MISS path with a
* shared leaf name the bucket grows with the file count BY CONSTRUCTION, and
@ -308,7 +325,9 @@
*
* Only rust's exclusion survived unchanged: 16 B at 8000 files and 16 B at
* 32 000, identical in all five runs, because it probes candidate paths with
* `allFilePaths.has(...)` and builds nothing.
* `allFilePaths.has(...)` and builds nothing. zig joined that tier on the same
* reading for the same reason (`resolveZigImportInternal` holds no per-pass
* structure at all), and takes rust's absolute 1 MiB bound.
*
* So the nine are still not BUDGETED — their ceilings, floors and ratio arms
* are not this change to write — but they are all measured and all bounded. See
@ -463,6 +482,7 @@ import { javaScopeResolver } from '../../src/core/ingestion/languages/java/scope
import { cobolScopeResolver } from '../../src/core/ingestion/languages/cobol/scope-resolver.ts';
import { resolveSwiftImportTarget } from '../../src/core/ingestion/languages/swift/import-target.ts';
import { resolveRustImportTarget } from '../../src/core/ingestion/languages/rust/import-target.ts';
import { resolveZigImportInternal } from '../../src/core/ingestion/import-resolvers/zig.ts';
import { resolvePythonImportTarget } from '../../src/core/ingestion/languages/python/import-target.ts';
import { makeJsResolveImportTarget } from '../../src/core/ingestion/languages/javascript/import-target.ts';
import { makeVueResolveImportTarget } from '../../src/core/ingestion/languages/vue/import-target.ts';
@ -736,6 +756,7 @@ const EXTENSION = {
vue: '.vue',
c: '.c',
cpp: '.cpp',
zig: '.zig',
};
/** C and C++ resolve `#include` against HEADERS, which reach the resolver
* through `resolutionConfig` rather than through `allFilePaths` — see
@ -859,6 +880,13 @@ function uniqueDir(lang, d, i) {
// `resolutionConfig` load-bearing. Odd `i` is the header.
if (lang === 'c' || lang === 'cpp') return i % 2 === 1 ? `include/comp${d}` : `src/comp${d}`;
if (lang === 'ruby') return `lib/mod${d}`;
// One flat `src/mod{d}/` per index and NO nested slice, on purpose: a Zig
// import is spelled RELATIVE TO THE IMPORTER, and `uniqueTarget` does not
// know which file issues it, so every importer has to sit at one depth for
// `../mod{n}/file{j}.zig` to mean the same file from all of them. The miss
// share the other unique arms take from a nested directory comes from the
// target instead (see `uniqueTarget`).
if (lang === 'zig') return `src/mod${d}`;
throw unwiredLanguage('uniqueDir', lang);
}
@ -947,6 +975,12 @@ function collideDir(lang, d, i) {
if (lang === 'vue') return `src/pkg${d}/components`;
if (lang === 'c' || lang === 'cpp') return i % 2 === 1 ? `svc${d}/include` : `svc${d}/src`;
if (lang === 'ruby') return `svc${d}/lib/models`;
// Rust's reasoning, verbatim: the resolver walks path components and probes
// `.has()`, never searches, so file count is not an axis its cost has and a
// shared-leaf layout would be an arm that cannot fail. A deep tree is the
// axis that CAN grow — `collideTarget` spells its imports up through the
// tree and back down, ~4x the components of the unique arm.
if (lang === 'zig') return `src/l0/l1/l2/l3/l4/mod${d}`;
throw unwiredLanguage('collideDir', lang);
}
@ -1373,6 +1407,29 @@ function uniqueTarget(lang, { local, r, d, j, dirs }) {
? ['json', 'set', 'net/http', 'digest'][(r >>> 4) % 4]
: `gem${(r >>> 4) % 97}/missing/thing`;
}
if (lang === 'zig') {
// `@import("../mod{n}/file{j}.zig")`, importer-relative — every file sits
// in `src/mod{d}/`, so one `..` reaches `src/` from all of them (see
// `uniqueDir`). The target is file `j`'s OWN directory, `j % dirs`, so a
// hit is a real file; the `d % 7` slice names an `inner/` that exists
// nowhere and misses, which is where the resolved count comes from, as in
// the rust arm. One local spelling in three drops the extension, which is
// the second `.has()` probe (`candidate + '.zig'`) — the leg an
// extension-only corpus would never reach. The misses are the three
// kinds a Zig file has: the compiler's own modules (`std`, `builtin`,
// `root`), which the resolver rejects by name before any walk; a bare
// package name with no build config to map it, which falls through every
// leg to null; and a relative path to a vendored file that is not in the
// corpus, which walks to the end and misses on both probes.
if (local) {
if (d % 7 === 0) return `../mod${d}/inner/file${j}.zig`;
return (r >>> 3) % 3 === 0 ? `../mod${j % dirs}/file${j}` : `../mod${j % dirs}/file${j}.zig`;
}
const miss = (r >>> 3) % 3;
if (miss === 0) return ['std', 'builtin', 'root'][(r >>> 4) % 3];
if (miss === 1) return `ghost${(r >>> 4) % 97}`;
return `../vendor${(r >>> 4) % 97}/missing.zig`;
}
throw unwiredLanguage('uniqueTarget', lang);
}
@ -1583,6 +1640,27 @@ function collideTarget(lang, { local, r, d, j, dirs }) {
? ['json', 'set', 'net/http', 'digest'][(r >>> 4) % 4]
: `gem${(r >>> 4) % 97}/missing/thing`;
}
if (lang === 'zig') {
// The same three families in the same proportions as the unique arm, so
// the resolved count is identical by construction (asserted), spelled up
// six levels to `src/` and back down through `l0/…/l4` — thirteen
// components against the unique arm's three, in the hits and in the path
// misses alike, because component count is the only axis this resolver's
// cost has. The `d % 7` slice and the extension-less third mirror the
// unique arm's; the by-name misses are unchanged, since no walk is what
// they measure.
const up = '../../../../../../l0/l1/l2/l3/l4';
if (local) {
if (d % 7 === 0) return `${up}/mod${d}/inner/file${j}.zig`;
return (r >>> 3) % 3 === 0
? `${up}/mod${j % dirs}/file${j}`
: `${up}/mod${j % dirs}/file${j}.zig`;
}
const miss = (r >>> 3) % 3;
if (miss === 0) return ['std', 'builtin', 'root'][(r >>> 4) % 3];
if (miss === 1) return `ghost${(r >>> 4) % 97}`;
return `${up}/vendor${(r >>> 4) % 97}/missing.zig`;
}
throw unwiredLanguage('collideTarget', lang);
}
@ -1784,6 +1862,10 @@ function resolveOne(lang, from, target, pass) {
);
}
if (lang === 'rust') return resolveRustImportTarget(target, from, allFilePaths, undefined);
// Quotes already stripped — `configs/zig.ts` strips them before this call in
// production too. `null` build config: this arm pins the path walk alone
// (see the header); the config legs are gated by their own unit tests.
if (lang === 'zig') return resolveZigImportInternal(from, target, allFilePaths, null);
if (lang === 'python') {
// `from <target> import X` — the spelling the orchestrator actually hands
// the provider, and the ONLY one that reads `context.parsedFiles`: a
@ -2062,7 +2144,7 @@ const HEAP_PROBE_TARGET = {
// - `kotlin` misses after building its declared-package/module-binding index;
// - `cobol` misses in both tier maps, `swift` in `byModule`, and `rust`
// probes candidate paths and builds nothing — that last is the reading
// the exclusion rests on;
// the exclusion rests on, and `zig` shares it exactly;
// - `typescript`, `vue` and `cpp` carry the same spelling shape as the
// `javascript` and `c` arms they are excluded as duplicates OF, so the
// bound compares like with like. `vue`'s is bare rather than `@/…`
@ -2073,6 +2155,10 @@ const HEAP_PROBE_TARGET = {
cobol: 'VENDOR0',
swift: 'ExternalPkg0',
rust: 'ghost0::Missing',
// A relative path to a file the corpus does not hold: both `.has()` probes
// miss after the full component walk, which is the longest leg the resolver
// has (a by-name miss returns before any walk).
zig: '../vendor0/missing.zig',
typescript: 'vendor0/lib/missing',
vue: 'vendor0/lib/Missing.vue',
cpp: 'vendor0/missing.hpp',
@ -2308,6 +2394,7 @@ const LANG_REGISTRY = {
vue: SupportedLanguages.Vue,
c: SupportedLanguages.C,
cpp: SupportedLanguages.CPlusPlus,
zig: SupportedLanguages.Zig,
};
const LANGS = Object.keys(LANG_REGISTRY);
/**
@ -2853,17 +2940,19 @@ for (const lang of HEAP_BUDGETED) {
* `heap_bound_bytes` is the "exclusion still holds" bound. It does not claim
* these indexes are small enough, which is what a ceiling claims about a
* budgeted one; it claims each is still the SIZE the decision to leave it out
* was taken on. `HEAP_BOUNDED` derives to THREE today — cobol, swift, rust.
* was taken on. `HEAP_BOUNDED` derives to SEVEN today — cobol, swift, rust,
* the ts family (#2953), and zig, which reads what rust reads (16 B) because
* `resolveZigImportInternal` builds nothing and takes rust's absolute bound.
* The prose below still counts nine because six were promoted to tier one
* after it was written; read the counts as history, and `HEAP_BOUNDED` itself
* as the answer. The re-entry condition the MEMORY section states — "if any of
* the four ever diverges in what it ASKS, it earns an arm the same way" — is a
* claim about growth, and this is the only thing in the file that can see it.
*
* NO FLOOR, and the reason is per language rather than uniform. rust reads 16 B
* because it builds nothing, so any floor at all would be a floor on noise and
* `1.5 x 0 B` is 0 — its bound is ABSOLUTE (1 MiB) for the same reason: a
* multiplier on 16 B fails on the first byte of anything. The other eight are
* NO FLOOR, and the reason is per language rather than uniform. rust (and zig)
* reads 16 B because it builds nothing, so any floor at all would be a floor
* on noise and `1.5 x 0 B` is 0 — its bound is ABSOLUTE (1 MiB) for the same
* reason: a multiplier on 16 B fails on the first byte of anything. The other eight are
* stable enough today to floor (0.24% peak-to-peak at worst over five runs).
* The two this paragraph named as floor candidates, kotlin and dart, TOOK that
* promotion: both now carry a ceiling and a recorded reading in tier one, which

View file

@ -1 +1 @@
2600a1f6f8a042eb4f520a7870c34d9ca292765824537c3bc861b40dac8769a8
7412edd9db56b4626c77fc09363e0d49a2f95763453756dceb82db6606280c28

View file

@ -199,15 +199,16 @@
}
},
"countArm": {
"callDrops": 102,
"totalDropsAllKinds": 148,
"callDrops": 113,
"totalDropsAllKinds": 159,
"bySiteKind": {
"call": 102,
"call": 113,
"read": 27,
"write": 19
},
"callDropsByExtension": {
".java": 49,
".zig": 11,
".cs": 8,
".ts": 7,
".cpp": 7,
@ -224,14 +225,14 @@
"callDropsByShape": {
"chain-field": 60,
"chain-call": 27,
"no-chain": 12,
"no-chain": 23,
"chain-mixed": 2,
"chain-unwrap": 1
},
"callDropsByOrigin": {
"external": 44,
"in-program": 36,
"unknown": 22
"in-program": 43,
"unknown": 26
}
}
}

View file

@ -35,7 +35,7 @@
"_rebaselined": "#1919 open-language coverage: new lang-resolution fixtures + intended capture additions (F5/F9 c-cpp, F26/F28/F29 dart, F47/F48/F49/F51/F52 kotlin, F75/F79 swift). Fingerprint-only drift; scaling_ratio ~1.0 (linear, no perf regression)."
},
"cpp": {
"fingerprint": "bf3587674267be1759e7c45abef143c3b81fe8629cfd17da5f8af40e83cc39ec",
"fingerprint": "3aaee42523f02718795ba348782d78ec1418f35eff2e1b602422b192544fed96",
"scaling_budget": 1.5,
"_rebaselined_2833_qualified_member_fields": "#2833 follow-up: the six per-qualifier-depth `field_declaration` type-binding rules for a QUALIFIED generic member are replaced by three depth-agnostic ones that match the outer `qualified_identifier` itself, with the qualifier reduced to its top-level tail in `interpret.ts` (`cppQualifiedTail`). This is a CAPTURE-LOGIC change and it moves the fingerprint in two places at once. (1) A qualified NON-generic member (`ns::Address addr;`, `std::string name;`) was captured by nothing at all and now binds \u2014 that is the whole +24 on the fixture corpus, every one of them a `std::string` member. (2) Qualifier depth is no longer enumerated, so `a::b::c::Repo<User>` (depth 3+) is captured where the old rules stopped at 2. Capture-name histogram, cpp-* corpus (278 files): `@type-binding.field` 8 -> 32, `@type-binding.name` and `@type-binding.type` 401 -> 425; synthetic DAO-20: `@type-binding.field` 40 -> 60, `@type-binding.name` and `@type-binding.type` 61 -> 81 (= 20 entities x the one `std::string name;` member the DAO unit already declared). NO OTHER TAG MOVED in either set \u2014 not one `@declaration.*`, `@scope.*` or `@reference.*` count \u2014 which is the property that says three rules replaced six without widening what a field_declaration matches. Measured over the 13 cpp-* fixture repos whose sources gained a binding, the distinct CALLS edge set is byte-identical before and after (32 edges): a reduced tail that names no workspace class binds nothing. Prior bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a -> db1156d81b3e3341faf5e938a4a34417f4fd246588b6150b4686481823262529; scaling 1.04 < 1.5.",
"_rebaselined_2833_generic_member_fields": "#2833 review follow-up: the cpp DAO generator's unit gains two GENERIC member fields \u2014 `Repo<Entity_n> repo;` (bare template_type) and `std::vector<Entity_n> items;` (qualified_identifier wrapping a template_type) \u2014 plus the header declaring `template <typename T> class Repo`. CORPUS CHANGE, NOT A CAPTURE-LOGIC CHANGE: no extractor edit accompanies it. It exists because the corpus had ZERO template-typed member fields and, across 279 cpp-* fixtures, not one qualified generic member either, so BOTH rounds of new `field_declaration` type-binding rules landed with a byte-identical cpp fingerprint \u2014 the gate was structurally blind to the exact thing being changed. Measured under the new corpus, the three states now differ: pre-#2833 query 0e7cbda71360b7ff35dd76091c77f288d6af6a5cfa9185ad85a372aae8c85191 (4521 groups) -> the three template_type field rules de07d8b5300ed867b460918e16b4d80259c7eb6efc1034d32bebe9ff7cab126d (4541) -> the six qualified rules bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a (4561); under the OLD corpus all three were 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc. Capture-name histogram over the synthetic DAO-20: `@type-binding.field` 0 -> 40, `@declaration.field` 40 -> 80, `@type-binding.type`/`@type-binding.name` 20 -> 61, `@declaration.name` 104 -> 147 \u2014 40 = 20 entities x 2 fields, with the residual +1/+2/+3 attributable to the one-off header declaration; every `@reference.*` count is unchanged. Prior 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc -> bd47c82d09a83cbf0ac857f41876fa31d22304043735582e913bccde06cf2c1a; scaling 1.058 < 1.5. `c` is unaffected (3418cded..., unchanged).",
@ -53,12 +53,13 @@
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|\u2026` instead of `1|\u2026`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 7e27aea46f3e17f33c41babbe0ddd982d1ab5920f143864763e0a1c6aef882a5 -> 856d02f3f9d22cb973877211100aee8e052d4bc545922f78704b1a21ce49ddcc.",
"capture_groups_small": 5021,
"capture_groups_large": 16021,
"capture_groups_fp": 4605,
"fixture_count": 279
"capture_groups_fp": 4601,
"fixture_count": 279,
"_rebaselined_1432_member_call_callee_name": "#1432 (Zig): the shared callable-flow reader no longer names a callee by simple name for a MEMBER call (`@callable-flow.direct-callee-name` requires a direct designator: `f(x)`, `ns.f(x)`), and a member call is a field-stored-callable invoke only when a MEMBER store (`o.f = handler`) or a declared callable-typed field is visible - a same-named plain binding no longer gates it. CAPTURE-EMISSION CHANGE, not fixture growth (fixture_count unchanged). Only drift: `choice.select(1)` / `choice.select(1.5)` (cpp-deleted-overload/main.cpp) no longer mint an INDIRECT `@callable-flow.invoke` (callee-kind binding) plus its synthetic `@reference.call.free` - that invoke was gated only by the same-named free `select` binding while the site is a genuine method call already captured as `@reference.call.member`, so the free-call duplicate is gone. capture_groups_fp 4605 -> 4601 (-4: 2 invokes + 2 synthetic call.free). Prior bf3587674267be1759e7c45abef143c3b81fe8629cfd17da5f8af40e83cc39ec -> 3aaee42523f02718795ba348782d78ec1418f35eff2e1b602422b192544fed96."
},
"csharp": {
"_rebaselined": "#1956 synth-widening: + csharp-qualified-base fixture; the synth now walks record_declaration + struct_declaration base_lists and handles alias_qualified_name (matching the #1940 legacy leg), so record/struct heritage now emits. csharp-record-base gains a record inherits capture. (record->record SAME-namespace EXTENDS is a separate registry resolution gap, tracked as follow-up.) Linear (~1.00). (Earlier #1956: heritage-bearing scale source.) | #942: scope-resolution-only cleanup reworded fixture comments; capture byte-positions shift, capture LOGIC unchanged. | #1924 F16: record primary-constructor base bindings now exclude constructor arguments; capture fingerprint changes, scaling remains linear. | #2036 review follow-up: csharp-record-base now exercises primary-constructor base dispatch end to end; +2 capture groups, scaling remains linear.",
"fingerprint": "2930ef49fdce984a4c051409880bddfe8445e30e1c6bf802bd90a0a0f8f6b094",
"fingerprint": "9c4d2ca55707a03185ea45e554462e81e6d83c9f8d0ea3cd63db5c362ad1bfec",
"scaling_budget": 1.5,
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior f31544530924748f9aa37d11cec570bc10c3ddf9d9b237e6df7a17623fd2bb3a -> 75cf380209fa7d1a8a3ec873be1a9424b4e5173be0b08234c2291e8521a9b3c1; scaling 1.061 < 1.5.",
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: C# method-group/delegate callable flow facts with invocation-result suppression. Prior 2bb5bc8c19cb8eb08c9590545ad8a1968a7152951f7e12746e2d7901d542fed9 -> f31544530924748f9aa37d11cec570bc10c3ddf9d9b237e6df7a17623fd2bb3a; scaling 1.115 < 1.5.",
@ -69,7 +70,8 @@
"capture_groups_small": 4259,
"capture_groups_large": 13609,
"capture_groups_fp": 2657,
"fixture_count": 178
"fixture_count": 178,
"_rebaselined_1432_member_call_callee_name": "#1432 (Zig): the shared callable-flow reader no longer names a callee by simple name for a MEMBER call (`@callable-flow.direct-callee-name` requires a direct designator: `f(x)`, `ns.f(x)`), and a member call is a field-stored-callable invoke only when a MEMBER store (`o.f = handler`) or a declared callable-typed field is visible - a same-named plain binding no longer gates it. CAPTURE-EMISSION CHANGE, not fixture growth (fixture_count unchanged). Only drift: `string.Join(\", \", args)` (csharp-variadic-resolution/Utils/Logger.cs) loses `direct-callee-name|Join`; the argument fact itself is unchanged, capture_groups_fp 2657 (unchanged). Prior 2930ef49fdce984a4c051409880bddfe8445e30e1c6bf802bd90a0a0f8f6b094 -> 9c4d2ca55707a03185ea45e554462e81e6d83c9f8d0ea3cd63db5c362ad1bfec."
},
"rust": {
"fingerprint": "e61653008ff2de506cfd47f905fa9eb22d82fbbfe94d2a1d8190c358211b57b7",
@ -169,7 +171,7 @@
"capture_groups_fp": 680
},
"typescript": {
"fingerprint": "05d1dadd6c9ef35c74079fa50f341b1b36e4fb02c9a89dd1b59f32b7cfd5e633",
"fingerprint": "fed04ed1d5db112387781e405da208ae6b3ab803889773b0455be96f01b893ff",
"scaling_budget": 1.5,
"_rebaselined_2934_import_type_only": "#2934: `import-decomposer.ts` attaches a presence-only `@import.type-only` synthetic capture to specifiers `tsc` erases, so `check --cycles` can stop counting type-only edges as initialization cycles. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE \u2014 the tag is added to import matches that already existed, never a new match, the same shape as the #2747 receiver-chain rebaseline. Every count is unchanged: capture_groups_fp 2414, fixture_count 155, capture_groups_small/large 4503/14403 (those measure the SYNTHETIC scaling source, which has no imports at all). The fingerprint moves because `canonicalizeMatch` in measure.mjs hashes every TAG on every match, synthetics included, so one extra presence-only tag on an existing match rewrites that match's canonical string. Attribution is exact, not inferred: neutralizing ONLY the `m['@import.type-only'] = \u2026` assignment in import-decomposer.ts and re-running returns the fingerprint to c2fbf8a89e5686dd\u2026 byte-for-byte, so nothing else in the TypeScript capture stream moved. All 14 other languages report ok. Scaling 0.997 < 1.5. NOTE ON THE CONTROL: javascript did not move (2026993b\u2026, 43 fixtures), but it is a WEAK control here \u2014 `import type` is TypeScript-only syntax, so a JS corpus cannot express the construct and could not have drifted either way. It evidences no collateral damage, not the correctness of the TS change; the exact-attribution check above is what does that. Prior c2fbf8a89e5686dd1ff3659b20d41d8b05ebcc9790356e3653ee0c8ca5d365c8 -> f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f.",
"_rebaselined_callable_flow_2522_review": "PR #2522 review hardening: callable operands retain expression/qualified identity and formals retain signature metadata. Prior 27f937bfb47d4bded316ea3c785ff659c8cd88a5761d928f113477a08c802c78 -> e05446620c5b80b7aae291cfdf32f693580fada2ae687124769b04a0c03bfe63; scaling 0.983 < 1.5.",
@ -191,7 +193,8 @@
"_rebaselined_blind_spots_2856": "#2856 blind-spots series: the JS/TS SCOPE queries gained capture rules, so fingerprint drift is expected and additive. Verified before re-baselining by diffing the capture-name sets in both scope queries against origin/main: TypeScript gained exactly @reference.read.identifier (A2 bare-identifier reads in value positions) and @reference.type (R2-2 type references, so a declared contract stops reporting incoming:{}); JavaScript gained exactly @reference.read.identifier, @reference.read.destructured (R2-1c) and @reference.write.property-key (R2-1b record-construction writes). NOTHING was removed on either side \u2014 the delta is a pure superset, which is the check that no existing capture moved. capture_groups_small/large are unchanged (4503/14403) because those measure the SYNTHETIC scaling source, which this branch does not touch; only the fixture-corpus count moves. capture_groups_fp 2097 -> 2338 and fixture_count 146 -> 151 from 21 new lang-resolution fixtures. Scaling stayed linear and inside budget: typescript 1.116 < 1.5, javascript 1.010 < 1.5. Prior typescript ed92588e0fc7b28b3a0174339ac378b4dd85965fe007db1208dea97a65ce0571 -> f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7; prior javascript 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594 -> 2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3.",
"_rebaselined_type_parameter_shadowing_w2_8": "W2-8: `@declaration.type-parameters` is now captured on generic FUNCTIONS, generator functions and type ALIASES, not only on class/interface declarations. NO NEW CAPTURE NAME \u2014 verified by diffing the capture-name sets against the wave-1 branch, which returns empty; the tag already existed and simply fires on more declarations. That is the whole delta: capture_groups_fp 2338 -> 2371 (+33 occurrences of an existing tag) and fixture_count 151 -> 152 (one new fixture, typescript-type-parameters). capture_groups_small/large unchanged at 4503/14403, since those measure the synthetic scaling source this does not touch. Scaling 1.06 < 1.5. JavaScript is untouched \u2014 it has no type parameters \u2014 and its fingerprint does not move, which is the check that this is the TS declaration rules and not something broader. Prior f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7 -> 62c7f1bfbe568eed927fb78f00061ed5e49d12511fd8260648b876df386f3b4c.",
"_rebaselined_2899_review_type_parameter_scope_fixtures": "PR #2899 review follow-up: FIXTURE-CORPUS GROWTH ONLY \u2014 no query rule changed and no capture name was added or removed. `typescript/query.ts` is byte-identical to the previous baseline; the type-parameter shadowing defect was fixed on the RESOLUTION side (`walkers.ts` gains a `declarationOpenedScope` gate so a declaration's `typeParameters` bind only inside the scope that declaration opened, and the `USES` guard moved from `graph-bridge/references-to-edges.ts` to `resolve-references.ts` where the spelled `site.name` is in hand). The fingerprint moves because measure.mjs fingerprints the whole `lang-resolution/typescript-*` fixture corpus and the regression tests add three files to `typescript-type-parameters/src/` (values.ts, aliased.ts, namespaced.ts) plus two scope-less generic aliases in shapes.ts. Per-file accounting sums exactly to the delta: shapes.ts 33->35 (+2), values.ts +11, aliased.ts +10, namespaced.ts +20 = +43. capture_groups_fp 2371 -> 2414; fixture_count 152 -> 155. capture_groups_small/large unchanged at 4503/14403 (they measure the SYNTHETIC scaling source, untouched). JAVASCRIPT IS THE CONTROL AND DID NOT MOVE (fingerprint 2026993b..., 43 fixtures) \u2014 which is the check that this is corpus growth and not a capture regression; all 14 other languages report `ok`. Scaling 0.976 < 1.5. Prior 62c7f1bfbe568eed927fb78f00061ed5e49d12511fd8260648b876df386f3b4c -> c2fbf8a89e5686dd1ff3659b20d41d8b05ebcc9790356e3653ee0c8ca5d365c8.",
"_rebaselined_2953_workspace_fixture": "#2953 adds test/fixtures/lang-resolution/typescript-pnpm-workspace-imports, a pnpm monorepo of 12 .ts files, and the TypeScript capture corpus is collected from test/fixtures. CORPUS GROWTH ONLY, NOT A CAPTURE CHANGE: fixture_count 155 -> 167 and capture_groups_fp 2414 -> 2465 are the 12 new files' own matches; capture_groups_small/large are unchanged at 4503/14403 because those measure the SYNTHETIC scaling source, which the fixture corpus does not feed. Attribution is exact rather than inferred: moving that one fixture directory aside and re-running returns typescript to f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f byte-for-byte with fixture_count back at 155, and [scope-capture --check] PASSES for all 15 languages - so nothing in the TypeScript capture stream moved. #2953 changes import RESOLUTION, which runs after capture and feeds no capture tag. Prior f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f -> 05d1dadd6c9ef35c74079fa50f341b1b36e4fb02c9a89dd1b59f32b7cfd5e633."
"_rebaselined_2953_workspace_fixture": "#2953 adds test/fixtures/lang-resolution/typescript-pnpm-workspace-imports, a pnpm monorepo of 12 .ts files, and the TypeScript capture corpus is collected from test/fixtures. CORPUS GROWTH ONLY, NOT A CAPTURE CHANGE: fixture_count 155 -> 167 and capture_groups_fp 2414 -> 2465 are the 12 new files' own matches; capture_groups_small/large are unchanged at 4503/14403 because those measure the SYNTHETIC scaling source, which the fixture corpus does not feed. Attribution is exact rather than inferred: moving that one fixture directory aside and re-running returns typescript to f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f byte-for-byte with fixture_count back at 155, and [scope-capture --check] PASSES for all 15 languages - so nothing in the TypeScript capture stream moved. #2953 changes import RESOLUTION, which runs after capture and feeds no capture tag. Prior f719163eb03a447c9e40ca316a905dd76cee82192a75a403df478ebbdc13e98f -> 05d1dadd6c9ef35c74079fa50f341b1b36e4fb02c9a89dd1b59f32b7cfd5e633.",
"_rebaselined_1432_member_call_callee_name": "#1432 (Zig): the shared callable-flow reader no longer names a callee by simple name for a MEMBER call (`@callable-flow.direct-callee-name` requires a direct designator: `f(x)`, `ns.f(x)`), and a member call is a field-stored-callable invoke only when a MEMBER store (`o.f = handler`) or a declared callable-typed field is visible - a same-named plain binding no longer gates it. CAPTURE-EMISSION CHANGE, not fixture growth (fixture_count unchanged). Drift: `await svc.verify<GuestPayload>(token, ...)` (typescript-generic-calls/src/guest.ts, member call) and `initializer()(() => {...})` (typescript-hof-callbacks/src/store.ts, call-of-call) lose `direct-callee-name`. `await verifyToken<AdminPayload>(token, ...)` (admin.ts/auth.ts) KEEPS `direct-callee-name|verifyToken`: tree-sitter-typescript parses `await f<T>(x)` as call_expression(function: await_expression(f), type_arguments, ...), and wrappedExpression now unwraps `await_expression` so the direct designator survives as it does for the un-awaited spelling. capture_groups_fp 2465 (unchanged). Prior 05d1dadd6c9ef35c74079fa50f341b1b36e4fb02c9a89dd1b59f32b7cfd5e633 -> fed04ed1d5db112387781e405da208ae6b3ab803889773b0455be96f01b893ff."
},
"javascript": {
"fingerprint": "2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3",
@ -209,7 +212,7 @@
"_rebaselined_blind_spots_2856": "#2856 blind-spots series: the JS/TS SCOPE queries gained capture rules, so fingerprint drift is expected and additive. Verified before re-baselining by diffing the capture-name sets in both scope queries against origin/main: TypeScript gained exactly @reference.read.identifier (A2 bare-identifier reads in value positions) and @reference.type (R2-2 type references, so a declared contract stops reporting incoming:{}); JavaScript gained exactly @reference.read.identifier, @reference.read.destructured (R2-1c) and @reference.write.property-key (R2-1b record-construction writes). NOTHING was removed on either side \u2014 the delta is a pure superset, which is the check that no existing capture moved. capture_groups_small/large are unchanged (4503/14403) because those measure the SYNTHETIC scaling source, which this branch does not touch; only the fixture-corpus count moves. capture_groups_fp 2097 -> 2338 and fixture_count 146 -> 151 from 21 new lang-resolution fixtures. Scaling stayed linear and inside budget: typescript 1.116 < 1.5, javascript 1.010 < 1.5. Prior typescript ed92588e0fc7b28b3a0174339ac378b4dd85965fe007db1208dea97a65ce0571 -> f66a3e6f1e096431e7046505129a627deaa00ca0de5bc846b080591b397248f7; prior javascript 806f70ad3cce5fc849f6d06a08ace8a95f92a1ea84a2418fddabb1eef5846594 -> 2026993b81b873839dd2ef8797d9c14d9c48516b2b57b05ac17d8d43f2f4eba3."
},
"kotlin": {
"fingerprint": "aeafc7a87402c933786ef582b7c98683b1822b78fa909e605cb97552867fa0d5",
"fingerprint": "a9d3f0db7547ff47856159debf15d2a6f427efca97a6af27b4004174ed432132",
"scaling_budget": 1.5,
"_rebaselined_interface_abstract_2885": "#2885: Kotlin interface property accessors stay in the capture set (groups still 5753/18403 and capture_groups_fp 2563) but Method isAbstract is now true for body-less interface properties, which changes accessor-plan identity in the fixture digest. Prior 82ae5e1f750580383344d4c84c400a290474528cd502be4af8cd56705819a683 -> aeafc7a87402c933786ef582b7c98683b1822b78fa909e605cb97552867fa0d5; CI scaling 0.838 < 1.5.",
"_rebaselined_jvm_property_accessors_2885": "#2885: Kotlin val/var properties now emit JVM getter/setter scope and declaration captures, including data-class constructor properties and custom accessors. Synthetic scaling counts move 4753/15203 -> 5753/18403; fixture-corpus groups move 2367 -> 2563. Accessor declaration sidecars use the canonical @declaration.qualified_name key, preserve same-name owner identity, follow JvmAbi is-prefix naming, and suppress @JvmName-renamed accessors until their custom names are modeled. Prior f98e7e936afbce0e99588285cfc603bf945fd58c5de45271860509a5d90eb832 -> 82ae5e1f750580383344d4c84c400a290474528cd502be4af8cd56705819a683; scaling 0.869 < 1.5.",
@ -228,6 +231,9 @@
"capture_groups_small": 5753,
"capture_groups_large": 18403,
"capture_groups_fp": 2563,
"fixture_count": 141
"fixture_count": 141,
"_rebaselined_1432_member_call_callee_name": "#1432 (Zig): the shared callable-flow reader no longer names a callee by simple name for a MEMBER call (`@callable-flow.direct-callee-name` requires a direct designator: `f(x)`, `ns.f(x)`), and a member call is a field-stored-callable invoke only when a MEMBER store (`o.f = handler`) or a declared callable-typed field is visible - a same-named plain binding no longer gates it. CAPTURE-EMISSION CHANGE, not fixture growth (fixture_count unchanged). Only drift: `users.map { it.name }.forEach { name -> println(name) }` (kotlin-lambda-scopes/App.kt) loses `direct-callee-name|forEach` (member call). capture_groups_fp 2334 (unchanged). Prior a184f8ff0ae40d246db855b63f7ff26bda3afac03e5f4c76e4593c7e2cefce54 -> 5a181af0dbc9451937da0964c40d3f3f9820914ca429d873bb5c812b5e2b9284.",
"_rebaselined_1432_rebase_onto_2960": "#1432 rebase onto main @ aac7515d: the kotlin fingerprint is a COMBINATION of two independent changes, so neither side of the merge conflict was correct on its own and resolving it by picking a side would have committed a fingerprint no run can reproduce. main's #2960 added four declared-package fixture files (fixture_count 137 -> 141, capture_groups_fp 2334 -> 2367); this branch's `_rebaselined_1432_member_call_callee_name` drops `direct-callee-name|forEach` from one member call. Recomputed under both: capture_groups_fp 2367 and fixture_count 141 match main's committed counts EXACTLY (this branch's change is emission-only and moves no count), capture_groups_small/large stay 4753/15203 (the SYNTHETIC scaling source, which neither change touches), scaling 1.003 < 1.5, and the other 14 languages report ok against their committed baselines in the same run - which is the check that the rebase replayed nothing else into the capture stream. Attribution is exact rather than inferred: moving test/fixtures/lang-resolution/kotlin-import-package-evidence aside and re-running returns kotlin to 5a181af0dbc9451937da0964c40d3f3f9820914ca429d873bb5c812b5e2b9284 byte-for-byte with fixture_count back at 137 and capture_groups_fp back at 2334 - this branch's pre-rebase value - so the whole delta is #2960's corpus growth layered on top, with nothing else moving. Prior (this branch, pre-rebase) 5a181af0dbc9451937da0964c40d3f3f9820914ca429d873bb5c812b5e2b9284 and (main) f98e7e936afbce0e99588285cfc603bf945fd58c5de45271860509a5d90eb832 -> 973d702510002dda76166e017c5eca90cae37a139f5a512877b7a1b04ad19dc5.",
"_rebaselined_1432_merge_main_2885": "#1432 merge of main @ 212e007a: the kotlin fingerprint is again a COMBINATION of two independent changes \u2014 main's #2885 JVM property accessors / interface-abstract (4753/15203 -> 5753/18403, capture_groups_fp 2367 -> 2563) and this branch's `_rebaselined_1432_member_call_callee_name` (drops `direct-callee-name|forEach` from one member call). Neither side's value reproduces under the merged tree. Recomputed under both: counts 5753/18403/2563 and fixture_count 141 match main's committed counts EXACTLY (this branch's change is emission-only), scaling 1.061 < 1.5, and csharp / cpp / typescript measure byte-for-byte at this branch's committed values (main did not touch them since the merge-base) while the other 11 languages report ok \u2014 the check that the merge replayed nothing else into the capture stream. Prior (main) aeafc7a87402c933786ef582b7c98683b1822b78fa909e605cb97552867fa0d5 and (this branch) 973d702510002dda76166e017c5eca90cae37a139f5a512877b7a1b04ad19dc5 -> a9d3f0db7547ff47856159debf15d2a6f427efca97a6af27b4004174ed432132."
}
}

View file

@ -75,6 +75,7 @@
},
"optionalDependencies": {
"@huggingface/transformers": "^4.1.0",
"@tree-sitter-grammars/tree-sitter-zig": "1.1.2",
"onnxruntime-node": "^1.24.0"
}
},
@ -1760,6 +1761,26 @@
"tslib": "^2.8.0"
}
},
"node_modules/@tree-sitter-grammars/tree-sitter-zig": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@tree-sitter-grammars/tree-sitter-zig/-/tree-sitter-zig-1.1.2.tgz",
"integrity": "sha512-J0L31HZ2isy3F5zb2g5QWQOv2r/pbruQNL9ADhuQv2pn5BQOzxt80WcEJaYXBeuJ8GHxVT42slpCna8k1c8LOw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4"
},
"peerDependencies": {
"tree-sitter": "^0.22.1"
},
"peerDependenciesMeta": {
"tree-sitter": {
"optional": true
}
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@ -2810,9 +2831,9 @@
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@ -3031,9 +3052,9 @@
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
"funding": [
{
"type": "github",
@ -3422,9 +3443,9 @@
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@ -3492,9 +3513,9 @@
}
},
"node_modules/ignore": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz",
"integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==",
"version": "7.0.7",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.7.tgz",
"integrity": "sha512-dML0wP6oak21rsNYCJpJB6O1BJIEwNpGrTw0URPfAk4hm0e3pRfCtzkfB6olBcXcVlU2rouCyz7lCyRB0OMVCA==",
"license": "MIT",
"engines": {
"node": ">= 4"
@ -3631,9 +3652,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.0.tgz",
"integrity": "sha512-jE7vUJIebKzYQI5xu4co5CRBDlDEYnHrdzsxs4O2giCz4v2SbVMYKpmt1D9L38OKQAeCWmrOTRiCV93u0UkaJA==",
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.1.tgz",
"integrity": "sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==",
"funding": [
{
"type": "github",
@ -4659,12 +4680,13 @@
}
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
@ -4999,14 +5021,14 @@
}
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
@ -5018,13 +5040,13 @@
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
@ -5501,9 +5523,9 @@
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.23.12",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz",
"integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==",
"version": "4.23.13",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz",
"integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==",
"dev": true,
"license": "MIT",
"dependencies": {

View file

@ -98,6 +98,7 @@
},
"optionalDependencies": {
"@huggingface/transformers": "^4.1.0",
"@tree-sitter-grammars/tree-sitter-zig": "1.1.2",
"onnxruntime-node": "^1.24.0"
},
"trustedDependencies": [
@ -127,6 +128,9 @@
"sharp": ">=0.35.0",
"@huggingface/transformers": {
"onnxruntime-node": "$onnxruntime-node"
},
"@tree-sitter-grammars/tree-sitter-zig": {
"tree-sitter": "$tree-sitter"
}
},
"engines": {

View file

@ -28,12 +28,13 @@ Run from the project root. This parses all source files, builds the knowledge gr
| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. |
| `--pdg` | Build the program-dependence layers used by `explain` and `pdg_query` (taint, CDG, and REACHING_DEF). |
| `--spring-actuator <path>` | Import opt-in Spring Boot Actuator mappings, beans, conditions, configprops, and env snapshots. Forces a full rebuild; unsupported with `--watch`. |
| `--asyncapi-spec <path>` | Read opt-in AsyncAPI 3.x documents (directory or single file) and mint `Destination` nodes from their operations. 2.x is refused, not mapped. Unsupported with `--watch`. |
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout.
For Spring runtime enrichment, pass a JSON bundle, one endpoint JSON file, or a directory containing endpoint files. Route evidence is authoritative only when `runtimeConfirmed === true`; `runtimeSource` records provenance and may also accompany `handler-conflict`. Env/configprops values are never persisted.
Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index.
Use `node .gitnexus/run.cjs analyze --watch` for a long-lived local Git repository. It performs an initial analysis, queues scanner-admitted file changes, and retries intact failed batches with bounded backoff. Watch refreshes update only the graph: they skip AGENTS.md / CLAUDE.md injection and standard skill installation, so run a one-shot `analyze` when those generated files need updating. Watch rejects one-shot or context-output flags including `--force`, embedding flags, `--skills`, `--default-branch`, `--skip-agents-md`, `--skip-skills`, `--no-stats`, `--self-commit`, `--index-only`, and `--skip-git`. It never pulls remotes. Scheduled remote clone/pull is a different command: `gitnexus auto-sync`. Bare `gitnexus watch` is reserved and does not start either job. Running MCP and `serve` processes periodically check for a published replacement and reopen it without a restart. MCP checks are throttled to once every five seconds, so a tool call before the next check can briefly use the previous index.
### status — Check index freshness

View file

@ -129,6 +129,13 @@ export interface AnalyzeOptions {
* bundle or a directory containing endpoint JSON files. Disabled by default.
*/
springActuator?: string;
/**
* Explicit local AsyncAPI 3.x document input. Accepts a directory of
* documents or a single document, resolved against the repository root so an
* out-of-band cache and a committed directory are equally usable. Disabled by
* default.
*/
asyncapiSpec?: string;
/** OpenAI-compatible embeddings base URL (incl. /v1). Overrides GITNEXUS_EMBEDDING_URL. */
embeddingBaseUrl?: string;
/** Embedding model name. Overrides GITNEXUS_EMBEDDING_MODEL. */

View file

@ -0,0 +1,560 @@
/** Local incremental watch (`gitnexus analyze --watch`). Remote auto-sync lives in `auto-sync.ts`. */
import path from 'node:path';
import fs from 'node:fs/promises';
import { watch, type FSWatcher } from 'chokidar';
import { createWatchIgnorePredicate } from '../config/ignore-service.js';
import {
analyzeFailureMayHaveMutatedLiveIndex,
runFullAnalysis,
type AnalyzeOptions as CoreAnalyzeOptions,
type AnalyzeResult,
} from '../core/run-analyze.js';
import { getGitRoot, hasGitDir } from '../storage/git.js';
import type { AnalyzerRunnerIdentity } from '../storage/repo-manager.js';
import { GITNEXUS_DIR } from '../storage/repo-meta.js';
import {
loadAnalyzeConfigStrict,
mergeAnalyzeOptions,
validateBranchName,
} from './analyze-config.js';
import type { AnalyzeOptions } from './analyze-options.js';
import { ensureHeap } from './analyze.js';
import { cliError, cliInfo, cliWarn } from './cli-message.js';
import {
WATCH_FULL_REFRESH_PATH,
WatchRefreshQueue,
type WatchRefreshError,
} from './watch-queue.js';
const DEFAULT_DEBOUNCE_MS = 300;
const MAX_TIMER_DELAY_MS = 2_147_483_647;
const MAX_FILE_SIZE_KB = 32 * 1024;
const TRANSIENT_WATCH_ERROR_CODES = new Set(['EACCES', 'ENOENT', 'ENOTDIR', 'EPERM']);
export type WatchCliOptions = AnalyzeOptions;
function posixWatchPath(filePath: string): string {
return filePath.replace(/\\/g, '/').replace(/^\.\/+/, '');
}
export function isRelevantWatchPath(filePath: string): boolean {
const normalized = posixWatchPath(filePath);
return (
normalized.length > 0 &&
normalized !== '.' &&
!normalized.startsWith('../') &&
!path.posix.isAbsolute(normalized) &&
!path.win32.isAbsolute(filePath)
);
}
function isIgnoreControlPath(filePath: string): boolean {
const normalized = posixWatchPath(filePath);
return normalized === '.gitignore' || normalized === '.gitnexusignore';
}
function isConfigControlPath(filePath: string): boolean {
return posixWatchPath(filePath) === '.gitnexusrc';
}
function isAnalyzerOwnedWatchPath(filePath: string): boolean {
const normalized = posixWatchPath(filePath).replace(/\/+$/, '');
return normalized === GITNEXUS_DIR || normalized.startsWith(`${GITNEXUS_DIR}/`);
}
function repoRelativeWatchPath(repoPath: string, candidate: string): string | null {
const relative = path.relative(repoPath, candidate).replace(/\\/g, '/');
if (!relative || relative.startsWith('../') || path.isAbsolute(relative)) return null;
return relative;
}
export interface WatchEnvironmentBaseline {
readonly maxFileSize: string | undefined;
readonly workerTimeout: string | undefined;
readonly verbose: string | undefined;
}
function setEnvironment(name: string, value: string | undefined): void {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
function positiveInteger(
value: string | undefined,
flag: string,
maximum?: number,
): number | undefined {
if (value === undefined) return undefined;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 1)
throw new Error(`${flag} must be a positive integer`);
if (maximum !== undefined && parsed > maximum) {
throw new Error(`${flag} must not exceed ${maximum}`);
}
return parsed;
}
export async function resolveWatchOptions(
repoPath: string,
cli: WatchCliOptions,
baseline: WatchEnvironmentBaseline,
reportIgnoredConfig: (names: readonly string[]) => void = () => {},
): Promise<CoreAnalyzeOptions> {
const config = (await loadAnalyzeConfigStrict(repoPath)) ?? {};
const merged = mergeAnalyzeOptions(cli, config);
const unsupported = [
['--force', cli.force],
['--repair-fts', cli.repairFts],
['--embeddings', cli.embeddings],
['--drop-embeddings', cli.dropEmbeddings],
['--skills', cli.skills],
['--default-branch', cli.defaultBranch],
['--skip-agents-md', cli.skipAgentsMd],
['--skip-skills', cli.skipSkills],
['--no-stats', cli.stats === false],
['--self-commit', cli.selfCommit],
['--index-only', cli.indexOnly],
['--skip-git', cli.skipGit],
['--spring-actuator', cli.springActuator],
// Rejected under --watch for the same reason as --spring-actuator: the
// watcher reacts to source changes, and nothing watches an out-of-band
// document directory. Honouring the flag here would read the documents once
// and then quietly serve a stale answer for the rest of the session.
['--asyncapi-spec', cli.asyncapiSpec],
['walCheckpointThreshold', cli.walCheckpointThreshold],
['embeddingThreads', cli.embeddingThreads],
['embeddingBatchSize', cli.embeddingBatchSize],
['embeddingSubBatchSize', cli.embeddingSubBatchSize],
['embeddingDevice', cli.embeddingDevice],
['embeddingBaseUrl', cli.embeddingBaseUrl],
['embeddingModel', cli.embeddingModel],
['--embedding-auth-token', cli.embeddingAuthToken],
['--embedding-dims', cli.embeddingDims],
].filter(([, value]) => value !== undefined && value !== false);
if (unsupported.length > 0) {
throw new Error(
`analyze --watch does not support ${unsupported.map(([name]) => name).join(', ')}`,
);
}
reportIgnoredConfig(
[
['embeddings', config.embeddings],
['dropEmbeddings', config.dropEmbeddings],
['defaultBranch', config.defaultBranch],
['skipAgentsMd', config.skipAgentsMd !== undefined],
['skipSkills', config.skipSkills !== undefined],
['stats', config.stats !== undefined],
['springActuator', config.springActuator],
['walCheckpointThreshold', config.walCheckpointThreshold],
['embeddingThreads', config.embeddingThreads],
['embeddingBatchSize', config.embeddingBatchSize],
['embeddingSubBatchSize', config.embeddingSubBatchSize],
['embeddingDevice', config.embeddingDevice],
['embeddingBaseUrl', config.embeddingBaseUrl],
['embeddingModel', config.embeddingModel],
]
.filter(([, value]) => value !== undefined && value !== false)
.map(([name]) => String(name)),
);
const branch =
merged.branch === undefined ? undefined : validateBranchName(merged.branch, '--branch');
const workerPoolSize = positiveInteger(merged.workers, '--workers');
const workerTimeoutSeconds = positiveInteger(merged.workerTimeout, 'workerTimeout');
const maxFileSize = positiveInteger(merged.maxFileSize, 'maxFileSize', MAX_FILE_SIZE_KB);
setEnvironment(
'GITNEXUS_MAX_FILE_SIZE',
maxFileSize === undefined ? baseline.maxFileSize : String(maxFileSize),
);
if (workerTimeoutSeconds !== undefined) {
process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS = String(workerTimeoutSeconds * 1000);
} else {
setEnvironment('GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS', baseline.workerTimeout);
}
setEnvironment('GITNEXUS_VERBOSE', merged.verbose ? '1' : baseline.verbose);
return {
pdg: merged.pdg,
branch,
registryName: merged.name,
allowDuplicateName: merged.allowDuplicateName,
workerPoolSize,
fetchWrappers: merged.fetchWrappers,
skipAgentsMd: true,
skipSkills: true,
noStats: true,
atomicIncremental: process.platform !== 'win32',
};
}
function refreshSummary(
result: AnalyzeResult,
observedPaths: readonly string[],
durationMs: number,
lastSuccessfulRefreshAt: string,
): string {
const measured = result.incrementalStats;
const changed = measured?.changedFiles ?? (result.alreadyUpToDate ? 0 : observedPaths.length);
const reparsed =
measured?.reparsedFiles ??
(typeof result.pipelineResult?.reparsedFileCount === 'number'
? result.pipelineResult.reparsedFileCount
: 0);
const dependents = measured?.affectedDependents ?? 0;
const mode = measured?.writeMode ?? (result.alreadyUpToDate ? 'no-op' : 'full');
return (
`Refresh complete: ${changed} changed, ${reparsed} re-parsed, ` +
`${dependents} affected dependent(s), ${durationMs}ms, ${mode}; ` +
`last success ${lastSuccessfulRefreshAt}`
);
}
async function waitUntilReady(watcher: FSWatcher): Promise<void> {
await new Promise<void>((resolve, reject) => {
const ready = () => {
watcher.off('error', failed);
resolve();
};
const failed = (error: unknown) => {
watcher.off('ready', ready);
reject(error);
};
watcher.once('ready', ready);
watcher.once('error', failed);
});
}
export interface WatchFileLoop {
readonly waitForIdle: () => Promise<void>;
readonly close: () => Promise<void>;
}
class WatchControlReloadError extends Error {
constructor(cause: unknown) {
super(cause instanceof Error ? cause.message : String(cause), { cause });
this.name = 'WatchControlReloadError';
}
}
export function shouldStopAfterWatchRefreshFailure(
error: unknown,
paths: readonly string[],
): boolean {
return (
paths.length > 0 &&
!(error instanceof WatchControlReloadError) &&
analyzeFailureMayHaveMutatedLiveIndex(error)
);
}
/** Start the real filesystem watcher with bounded, serialized refreshes. */
export async function startWatchFileLoop(
repoPath: string,
debounceMs: number,
refresh: (paths: readonly string[]) => Promise<void>,
onError: WatchRefreshError,
onWatcherError: (error: unknown) => void = (error) => onError(error, []),
): Promise<WatchFileLoop> {
let ignorePath = await createWatchIgnorePredicate(repoPath);
let ignoreControlValid = true;
let rearmPending = false;
let closed = false;
const queue = new WatchRefreshQueue(
async (paths) => {
if (paths.some(isIgnoreControlPath) || !ignoreControlValid) {
const retryingInvalidControls = !ignoreControlValid;
try {
ignorePath = await createWatchIgnorePredicate(repoPath);
ignoreControlValid = true;
rearmPending = true;
} catch (error) {
ignoreControlValid = false;
throw new WatchControlReloadError(
retryingInvalidControls
? new Error(
'Ignore controls remain invalid; fix them before indexing more changes.',
{
cause: error,
},
)
: error,
);
}
}
// Re-arm before refreshing, never after: the refresh reads the whole
// repository, so a write that lands while the replacement watcher is
// arming is still picked up by this refresh, and a write that lands
// afterwards is reported by the armed watcher.
if (rearmPending) await rearmWatcher();
await refresh(paths);
},
onError,
debounceMs,
{
maxWaitMs: Math.max(2_000, debounceMs * 10),
maxPendingPaths: 1_000,
holdEventsUntilInitialRefresh: true,
isPriorityPath: (filePath) => isIgnoreControlPath(filePath) || isConfigControlPath(filePath),
},
);
const createWatcher = (): FSWatcher => {
const created: FSWatcher = watch(repoPath, {
ignoreInitial: true,
atomic: true,
followSymlinks: false,
awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 20 },
ignored: (candidate, stats) => {
const relative = repoRelativeWatchPath(repoPath, candidate);
if (relative !== null && isAnalyzerOwnedWatchPath(relative)) return true;
if (relative !== null && (isIgnoreControlPath(relative) || isConfigControlPath(relative))) {
return false;
}
return ignorePath(candidate, stats?.isDirectory() ?? false);
},
});
// Events from an instance being retired are kept: they overlap with the
// replacement's coverage and the queue coalesces the duplicates.
created.on('all', (event, changedPath) => {
if (event !== 'add' && event !== 'change' && event !== 'unlink') return;
const relative = repoRelativeWatchPath(repoPath, changedPath);
if (relative && isRelevantWatchPath(relative) && !isAnalyzerOwnedWatchPath(relative)) {
queue.enqueue(relative);
}
});
created.on('error', (error) => {
// Replacement failures before the swap are reported through `waitUntilReady`.
if (created !== watcher) return;
// Chokidar can surface a transient EPERM on Windows while an ignored
// analyzer-owned path is replaced. Re-arm the watcher and force one
// bounded catch-up refresh so a missed event cannot leave the graph
// stale. Other watcher errors may mean coverage was lost and stay fatal.
if (TRANSIENT_WATCH_ERROR_CODES.has((error as NodeJS.ErrnoException).code ?? '')) {
rearmPending = true;
queue.enqueue(WATCH_FULL_REFRESH_PATH);
return;
}
onWatcherError(error);
});
return created;
};
let watcher: FSWatcher = createWatcher();
// Chokidar emits `ready` once per instance and `add()` returns before the
// rescan it starts has finished, with no signal for that completion. A file
// an ignore-rule reload has just unignored is therefore still unregistered
// when `add()` returns, and because `ignoreInitial` suppresses the `add` the
// rescan would emit, an immediate rewrite of that file is dropped for good
// (reproduced on chokidar 4 and 5). So re-arm by arming a replacement
// watcher and awaiting its `ready` instead. The outgoing instance keeps
// reporting until the replacement is armed, so the swap has no blind window,
// and a replacement that fails to arm leaves the working instance in place.
const rearmWatcher = async (): Promise<void> => {
rearmPending = false;
if (closed) return;
const replacement = createWatcher();
try {
await waitUntilReady(replacement);
} catch (error) {
rearmPending = true;
try {
await replacement.close();
} catch {
// The instance never became live; the arm error is the one to report.
}
throw new WatchControlReloadError(
new Error('Unable to re-arm the filesystem watcher', { cause: error }),
);
}
const retired = watcher;
watcher = replacement;
await retired.close();
// `close()` can land between arming the replacement and the swap above.
if (closed) await replacement.close();
};
try {
await waitUntilReady(watcher);
await queue.runInitial();
} catch (error) {
closed = true;
await watcher.close();
await queue.close();
throw error;
}
return {
waitForIdle: () => queue.waitForIdle(),
close: async () => {
closed = true;
await watcher.close();
await queue.close();
},
};
}
export async function watchCommandWithRunnerIdentity(
runnerIdentityAtBootstrap: AnalyzerRunnerIdentity,
inputPath?: string,
cliOptions: WatchCliOptions = {},
): Promise<void> {
if (await ensureHeap({ cleanForwardedTermination: true })) return;
const requestedRepoPath = inputPath ? path.resolve(inputPath) : getGitRoot(process.cwd());
if (requestedRepoPath === null || !hasGitDir(requestedRepoPath)) {
cliError(' gitnexus analyze --watch requires a Git repository.');
process.exitCode = 1;
return;
}
const repoPath = await fs.realpath(requestedRepoPath);
const baselineEnvironment: WatchEnvironmentBaseline = {
maxFileSize: process.env.GITNEXUS_MAX_FILE_SIZE,
workerTimeout: process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS,
verbose: process.env.GITNEXUS_VERBOSE,
};
try {
let ignoredConfigSignature: string | undefined;
const reportIgnoredConfig = (names: readonly string[]) => {
const signature = [...names].sort().join(',');
if (signature === ignoredConfigSignature) return;
ignoredConfigSignature = signature;
if (names.length > 0) {
cliWarn(`Watch mode ignores unsupported .gitnexusrc settings: ${names.join(', ')}.`);
}
};
let debounceMs: number;
let analyzeOptions: CoreAnalyzeOptions;
try {
debounceMs =
positiveInteger(
cliOptions.debounce ?? String(DEFAULT_DEBOUNCE_MS),
'--debounce',
MAX_TIMER_DELAY_MS,
) ?? DEFAULT_DEBOUNCE_MS;
analyzeOptions = await resolveWatchOptions(
repoPath,
cliOptions,
baselineEnvironment,
reportIgnoredConfig,
);
} catch (error) {
cliError(` ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
return;
}
let stopWatching!: () => void;
const stopped = new Promise<void>((resolve) => {
stopWatching = resolve;
});
const stop = () => stopWatching();
process.once('SIGINT', stop);
process.once('SIGTERM', stop);
try {
let loop: WatchFileLoop;
let fatalRefreshError: unknown;
let configControlValid = true;
let lastSuccessfulRefreshAt: string | undefined;
try {
loop = await startWatchFileLoop(
repoPath,
debounceMs,
async (paths) => {
if (paths.some(isConfigControlPath) || !configControlValid) {
const retryingInvalidConfig = !configControlValid;
try {
analyzeOptions = await resolveWatchOptions(
repoPath,
cliOptions,
baselineEnvironment,
reportIgnoredConfig,
);
configControlValid = true;
} catch (error) {
configControlValid = false;
throw new WatchControlReloadError(
retryingInvalidConfig
? new Error(
'Configuration remains invalid; fix it before indexing more changes.',
{
cause: error,
},
)
: error,
);
}
}
const startedAt = Date.now();
const result = await runFullAnalysis(
repoPath,
analyzeOptions,
{
onProgress: () => {},
onLog:
process.env.GITNEXUS_VERBOSE === '1'
? (message) => cliInfo(` ${message}`)
: undefined,
},
runnerIdentityAtBootstrap,
);
lastSuccessfulRefreshAt = new Date().toISOString();
if (paths.length === 0) {
cliInfo(
result.alreadyUpToDate
? `Watching ${repoPath}; index is up to date.`
: `Watching ${repoPath}; initial index ready in ${Date.now() - startedAt}ms.`,
);
} else {
cliInfo(
refreshSummary(result, paths, Date.now() - startedAt, lastSuccessfulRefreshAt),
);
}
},
(error, paths) => {
const detail = paths.length > 0 ? ` (${paths.length} queued path(s))` : '';
if (shouldStopAfterWatchRefreshFailure(error, paths)) {
fatalRefreshError = error;
cliError(
`Refresh failed${detail}: ${error instanceof Error ? error.message : String(error)}. ` +
'Watch mode is stopping because the live index may have been updated in place.',
);
stopWatching();
return;
}
const lastSuccess = lastSuccessfulRefreshAt ?? 'none yet';
cliWarn(
`Refresh failed${detail}: ${error instanceof Error ? error.message : String(error)}. ` +
`Retry scheduled; last success ${lastSuccess}.`,
);
},
(error) => {
fatalRefreshError = error;
cliError(
`Watcher failed: ${error instanceof Error ? error.message : String(error)}. ` +
'Watch mode is stopping.',
);
stopWatching();
},
);
} catch (error) {
cliError(
` Unable to start watcher: ${error instanceof Error ? error.message : String(error)}`,
);
process.exitCode = 1;
return;
}
await stopped;
await loop.close();
if (fatalRefreshError !== undefined) process.exitCode = 1;
} finally {
process.removeListener('SIGINT', stop);
process.removeListener('SIGTERM', stop);
}
} finally {
setEnvironment('GITNEXUS_MAX_FILE_SIZE', baselineEnvironment.maxFileSize);
setEnvironment('GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS', baselineEnvironment.workerTimeout);
setEnvironment('GITNEXUS_VERBOSE', baselineEnvironment.verbose);
}
}

View file

@ -776,7 +776,7 @@ export async function analyzeOrWatchCommandWithRunnerIdentity(
options: AnalyzeOptions = {},
): Promise<void> {
if (options.watch) {
const { watchCommandWithRunnerIdentity } = await import('./watch.js');
const { watchCommandWithRunnerIdentity } = await import('./analyze-watch.js');
await watchCommandWithRunnerIdentity(runnerIdentityAtBootstrap, inputPath, options);
return;
}
@ -1006,6 +1006,17 @@ const analyzeCommandImpl = async (
return;
}
// An empty value resolves to the repository root, so `--asyncapi-spec ""`
// walks the whole tree — defeating the module's own rule that there is no
// glob-based auto-discovery, and spending the walk budget on `node_modules`.
// The HTTP entry point already rejects exactly this value; two doors onto one
// option must not hold different rules.
if (options.asyncapiSpec !== undefined && options.asyncapiSpec.trim() === '') {
cliError(' --asyncapi-spec must be a non-empty path.\n');
process.exitCode = 1;
return;
}
if (options.embeddingDevice) {
const allowed = new Set(['auto', 'cpu', 'dml', 'cuda', 'wasm']);
if (!allowed.has(options.embeddingDevice)) {
@ -1375,6 +1386,7 @@ const analyzeCommandImpl = async (
// forwarded to the routes phase consumer scan.
fetchWrappers: options.fetchWrappers,
springActuatorPath: options.springActuator,
asyncApiSpecPath: options.asyncapiSpec,
// The CLI always process.exit()s after this returns (success path at the
// end of analyzeCommandImpl, error/interrupt paths via process.exit too),
// so the finalize close skips the native conn/db close — it can double-free

View file

@ -0,0 +1,125 @@
/** Remote auto-sync CLI (`gitnexus auto-sync`). Local incremental watch lives in `analyze-watch.ts`. */
import fs from 'node:fs/promises';
import path from 'node:path';
import {
getAutoSyncConfigPath,
getAutoSyncMutexPath,
readAutoSyncWatchStatus,
resetAutoSyncState,
startAutoSyncWatch,
stopAutoSyncWatch,
type WatchStatusRecord,
} from '../core/auto-sync/index.js';
export async function autoSyncCommand(action = 'start'): Promise<void> {
if (action === 'init') {
await initWatchConfig();
return;
}
if (action === 'reset') {
if (!(await resetAutoSyncState())) {
process.stderr.write(
`[auto-sync] Cannot reset analysis state while the watch mutex is held. Confirm no watch process is running, then remove ${getAutoSyncMutexPath()}.\n`,
);
process.exitCode = 1;
return;
}
process.stdout.write('[auto-sync] Reset analysis state.\n');
return;
}
if (action === 'status') {
printStatus(await readAutoSyncWatchStatus());
return;
}
if (action === 'stop') {
if ((await stopAutoSyncWatch()) !== 'stopped') process.exitCode = 1;
return;
}
if (action === 'restart') {
const result = await stopAutoSyncWatch();
if (result === 'refused' || result === 'timeout') {
process.exitCode = 1;
return;
}
await startWatchProcess();
return;
}
if (action !== 'start') {
process.stderr.write(`[auto-sync] Unknown auto-sync action: ${action}\n`);
process.exitCode = 1;
return;
}
await startWatchProcess();
}
async function startWatchProcess(): Promise<void> {
const handle = await startAutoSyncWatch();
if (!handle) {
process.exitCode = 1;
return;
}
const stop = () => {
void handle.stop().then(
() => {
process.stderr.write('[auto-sync] Watch stopped.\n');
process.exit(0);
},
(error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`[auto-sync] Failed to stop watch: ${message}\n`);
process.exit(1);
},
);
};
process.once('SIGINT', stop);
process.once('SIGTERM', stop);
}
function printStatus(status: WatchStatusRecord): void {
const parts = [`state=${status.state}`];
if (status.pid) parts.push(`pid=${status.pid}`);
if (status.configPath) parts.push(`config=${status.configPath}`);
if (status.message) parts.push(`message=${status.message}`);
parts.push(`updated_at=${status.updatedAt}`);
process.stdout.write(`${parts.join(' ')}\n`);
}
async function initWatchConfig(): Promise<void> {
const configPath = getAutoSyncConfigPath();
try {
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
defaultSyncConfig(path.resolve(path.dirname(configPath), 'repos')),
{
flag: 'wx',
},
);
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'EEXIST') {
process.stderr.write(`[auto-sync] Config already exists: ${configPath}\n`);
process.exitCode = 1;
return;
}
throw err;
}
process.stdout.write(`[auto-sync] Created ${configPath}\n`);
}
function defaultSyncConfig(localPath: string): string {
return [
'sync_interval_minutes: 10',
'max_concurrency: 1',
'repo_git_timeout: 10s',
'analyze_timeout: 5m',
'analyze_failure_threshold: 3',
'projects:',
` - local_path: ${localPath}`,
' branches: [master, main]',
' overwrite_local_changes: false',
' remote_urls:',
' - git@github.com:owner/repo.git',
'',
].join('\n');
}

View file

@ -6,6 +6,7 @@ type DetectChangesSummary = {
changed_count?: number;
affected_count?: number;
risk_level?: string;
message?: string;
};
type ChangedSymbol = {
@ -55,6 +56,29 @@ export function formatDetectChangesResult(result: unknown): string {
);
if ((summary.changed_count ?? 0) === 0) {
// Parse-fail payloads set `partial` and an honest `message` (#2915/#3131).
// Production *clean* trees also set English `message: 'No changes detected.'`
// — that must go through `t('tool.detectChanges.noChanges')` or zh-CN never
// fires. Only pass the backend string through on a degraded/parse-fail run.
if (
payload.partial &&
typeof summary.message === 'string' &&
summary.message.trim().length > 0
) {
return [...notes, summary.message.trim()].join('\n');
}
// Confirmed no-overlap: files parsed, mapping succeeded, zero symbols.
// `queryDegraded` is `partial: true` with the same counts and no message —
// do not call that a confirmed mapping (#3131 honesty).
if (!payload.partial && (summary.changed_files ?? 0) > 0) {
return [
...notes,
t('tool.detectChanges.noOverlappingSymbols', { files: summary.changed_files }),
].join('\n');
}
if (payload.partial) {
return notes.join('\n');
}
return [...notes, t('tool.detectChanges.noChanges')].join('\n');
}

View file

@ -13,6 +13,8 @@ const COMMAND_DESCRIPTION_KEYS = {
'': 'help.description.root',
setup: 'help.command.setup.description',
uninstall: 'help.command.uninstall.description',
watch: 'help.command.watch.description',
'auto-sync': 'help.command.autoSync.description',
analyze: 'help.command.analyze.description',
index: 'help.command.index.description',
serve: 'help.command.serve.description',

View file

@ -76,6 +76,8 @@ export const en = {
'tool.warn.unknownKind':
"--kind '{{kind}}' is not a known symbol kind (e.g. Function, Class, Method); it will not narrow the result.",
'tool.detectChanges.noChanges': 'No changes detected.',
'tool.detectChanges.noOverlappingSymbols':
'Diff touched {{files}} file(s) but no indexed symbols overlap those hunks — not a clean tree.',
'tool.detectChanges.partial':
'PARTIAL RESULT: a graph query failed, so changed symbols may be missing. Do not read this as a clean pre-commit check.',
'tool.detectChanges.truncated':
@ -143,6 +145,16 @@ export const en = {
'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex',
'help.command.uninstall.description':
'Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors',
'help.command.autoSync.description':
'Control scheduled repository clone/pull and analysis from GITNEXUS_HOME/watch_config.yml',
'help.autoSync.details':
'\nActions: init, start (default), restart, stop, status, reset\nConfiguration: GITNEXUS_HOME/watch_config.yml\nRuntime files: GITNEXUS_HOME/watch/watch.pid, watch.mutex, watch.owner.json, watch.status.json, auto-sync-state.json\nRecovery: mutexes with verified dead owners are reclaimed automatically; invalid or legacy mutexes fail closed and require manual removal after confirming no watch process is running.\nWrites: GITNEXUS_HOME/watch/project_commit_info.txt\nRemote URLs: only SSH URLs on github.com, gitlab.com, and gitee.com are allowed.\nRuns once immediately, then repeats on sync_interval_minutes.',
'help.command.watch.description':
'Ambiguous: use `analyze --watch` for local files, or `auto-sync` for scheduled remotes',
'help.watch.details':
'\n`gitnexus watch` does not start a watcher.\n Local working-tree incremental index: gitnexus analyze --watch\n Scheduled remote clone/pull + analyze: gitnexus auto-sync start\n',
'error.watch.ambiguous':
'`gitnexus watch` is ambiguous.\n Local working-tree incremental index: gitnexus analyze --watch\n Scheduled remote clone/pull + analyze: gitnexus auto-sync start\n',
'help.command.analyze.description': 'Index a repository (full analysis)',
'help.command.index.description':
'Register an existing .gitnexus/ folder into the global registry (no re-analysis needed)',

View file

@ -78,6 +78,8 @@ export const zhCN = {
'tool.warn.unknownKind':
"--kind '{{kind}}' 不是已知的符号类型(如 Function、Class、Method),不会用于缩小结果范围。",
'tool.detectChanges.noChanges': '未检测到变更。',
'tool.detectChanges.noOverlappingSymbols':
'diff 触及 {{files}} 个文件,但没有索引符号与这些 hunk 重叠 — 并非干净工作区。',
'tool.detectChanges.partial':
'结果不完整:图查询失败,可能遗漏已变更符号。请勿将其视为通过的提交前检查。',
'tool.detectChanges.truncated':
@ -142,6 +144,16 @@ export const zhCN = {
'一次性设置:为 Cursor、Claude Code、Antigravity、OpenCode、CodeBuddy、Qoder、Codex 配置 MCP',
'help.command.uninstall.description':
'撤销 `setup`:从所有检测到的编辑器中移除 GitNexus 的 MCP 配置、技能和钩子',
'help.command.autoSync.description':
'控制基于 GITNEXUS_HOME/watch_config.yml 的定时 clone/pull 和分析',
'help.autoSync.details':
'\n操作:init、start(默认)、restart、stop、status、reset\n配置:GITNEXUS_HOME/watch_config.yml\n运行时文件:GITNEXUS_HOME/watch/watch.pid、watch.mutex、watch.owner.json、watch.status.json、auto-sync-state.json\n恢复:已验证 owner 退出的 mutex 会自动回收;无效或旧版 mutex 会安全拒绝,确认没有 watch 进程运行后再手动删除。\n写入:GITNEXUS_HOME/watch/project_commit_info.txt\n远程地址:仅允许 github.com、gitlab.com 和 gitee.com 上的 SSH 地址。\n启动后立即运行一次,之后按 sync_interval_minutes 重复。',
'help.command.watch.description':
'含义不明确:本地文件请用 `analyze --watch`,定时远程同步请用 `auto-sync`',
'help.watch.details':
'\n`gitnexus watch` 不会启动监视器。\n 本地工作区增量索引:gitnexus analyze --watch\n 定时远程 clone/pull 并分析:gitnexus auto-sync start\n',
'error.watch.ambiguous':
'`gitnexus watch` 含义不明确。\n 本地工作区增量索引:gitnexus analyze --watch\n 定时远程 clone/pull 并分析:gitnexus auto-sync start\n',
'help.command.analyze.description': '索引仓库(完整分析)',
'help.command.index.description': '将现有 .gitnexus/ 文件夹注册到全局注册表(无需重新分析)',
'help.command.serve.description': '启动供 Web UI 连接的本地 HTTP 服务器',

View file

@ -45,6 +45,22 @@ program
.option('-f, --force', 'Apply the changes (default is a dry-run preview)')
.action(createLazyAction(() => import('./uninstall.js'), 'uninstallCommand'));
program
.command('auto-sync [action]')
.description(
'Control scheduled repository clone/pull and analysis from GITNEXUS_HOME/watch_config.yml',
)
.addHelpText('after', () => t('help.autoSync.details'))
.action(createLazyAction(() => import('./auto-sync.js'), 'autoSyncCommand'));
program
.command('watch [action]')
.description(
'Ambiguous: use `analyze --watch` for local files, or `auto-sync` for scheduled remotes',
)
.addHelpText('after', () => t('help.watch.details'))
.action(createLazyAction(() => import('./watch.js'), 'watchAmbiguousCommand'));
// Baseline of GITNEXUS_EMBEDDING_DIMS captured by the analyze preAction hook
// before it overwrites the var, so the postAction hook can restore it. The
// analyzeCommand env snapshot is taken AFTER this hook runs, so it cannot undo
@ -147,6 +163,11 @@ program
'Import local Spring Boot Actuator JSON snapshots (mappings, beans, conditions, ' +
'configprops, env). Explicit opt-in; disabled by default.',
)
.option(
'--asyncapi-spec <path>',
'Read AsyncAPI 3.x documents from this directory or file and resolve broker ' +
'addresses from them. Explicit opt-in; disabled by default.',
)
.option('--embedding-threads <n>', 'Limit local ONNX embedding CPU threads')
.option('--embedding-batch-size <n>', 'Number of nodes per embedding batch')
.option('--embedding-sub-batch-size <n>', 'Number of chunks per embedding model call')

View file

@ -7,19 +7,23 @@
* prebuilds activated via node-gyp-build. All can be skipped via
* GITNEXUS_SKIP_OPTIONAL_GRAMMARS=1 (postinstall scripts), or can silently
* soft-fail when no prebuild matches the host platform (and a source build was
* unavailable / not attempted).
* unavailable / not attempted). tree-sitter-zig is the one npm-installed
* optionalDependency in the list; its `probe` overrides the vendored load.
*
* Either path produces the same observable: the .node binding is absent
* at runtime. This helper detects that condition and surfaces a single
* stderr line per missing grammar so users learn why .dart/.proto/.swift/.kt
* stderr line per missing grammar so users learn why .dart/.proto/.swift/.kt/.zig
* support is unavailable instead of silently getting a degraded index.
*/
import { createRequire } from 'node:module';
import { SupportedLanguages } from 'gitnexus-shared';
import { isGrammarRuntimeSkipped } from '../core/tree-sitter/parser-loader.js';
import { requireVendoredGrammar } from '../core/tree-sitter/vendored-grammars.js';
import { cliWarn } from './cli-message.js';
const _require = createRequire(import.meta.url);
interface OptionalGrammar {
/** Display name in warnings */
name: string;
@ -34,6 +38,12 @@ interface OptionalGrammar {
* `.proto`, which is a gRPC-extractor concern, not a SupportedLanguages.
*/
language?: SupportedLanguages;
/**
* Availability probe. Defaults to `requireVendoredGrammar(pkg)`; grammars
* that install from npm as an optionalDependency (zig) override it with a
* plain `require` of the package.
*/
probe?: () => unknown;
}
const OPTIONAL_GRAMMARS: OptionalGrammar[] = [
@ -56,6 +66,14 @@ const OPTIONAL_GRAMMARS: OptionalGrammar[] = [
extensions: ['.kt', '.kts'],
language: SupportedLanguages.Kotlin,
},
{
name: 'tree-sitter-zig',
pkg: '@tree-sitter-grammars/tree-sitter-zig',
extensions: ['.zig'],
language: SupportedLanguages.Zig,
// npm optionalDependency, not vendored — probe via plain require.
probe: () => _require('@tree-sitter-grammars/tree-sitter-zig'),
},
];
/**
@ -105,7 +123,11 @@ export function detectMissingOptionalGrammars(): MissingGrammar[] {
continue;
}
try {
requireVendoredGrammar(g.pkg);
if (g.probe !== undefined) {
g.probe();
} else {
requireVendoredGrammar(g.pkg);
}
} catch (err) {
const code = (err as NodeJS.ErrnoException | undefined)?.code;
const msg = err instanceof Error ? err.message : String(err);

View file

@ -1,503 +1,7 @@
import path from 'node:path';
import fs from 'node:fs/promises';
import { watch, type FSWatcher } from 'chokidar';
import { createWatchIgnorePredicate } from '../config/ignore-service.js';
import {
analyzeFailureMayHaveMutatedLiveIndex,
runFullAnalysis,
type AnalyzeOptions as CoreAnalyzeOptions,
type AnalyzeResult,
} from '../core/run-analyze.js';
import { getGitRoot, hasGitDir } from '../storage/git.js';
import type { AnalyzerRunnerIdentity } from '../storage/repo-manager.js';
import { GITNEXUS_DIR } from '../storage/repo-meta.js';
import {
loadAnalyzeConfigStrict,
mergeAnalyzeOptions,
validateBranchName,
} from './analyze-config.js';
import type { AnalyzeOptions } from './analyze-options.js';
import { ensureHeap } from './analyze.js';
import { cliError, cliInfo, cliWarn } from './cli-message.js';
import {
WATCH_FULL_REFRESH_PATH,
WatchRefreshQueue,
type WatchRefreshError,
} from './watch-queue.js';
/** Reserved CLI verb: never starts either watch product. */
import { t } from './i18n/index.js';
const DEFAULT_DEBOUNCE_MS = 300;
const MAX_TIMER_DELAY_MS = 2_147_483_647;
const MAX_FILE_SIZE_KB = 32 * 1024;
const TRANSIENT_WATCH_ERROR_CODES = new Set(['EACCES', 'ENOENT', 'ENOTDIR', 'EPERM']);
export type WatchCliOptions = AnalyzeOptions;
function posixWatchPath(filePath: string): string {
return filePath.replace(/\\/g, '/').replace(/^\.\/+/, '');
}
export function isRelevantWatchPath(filePath: string): boolean {
const normalized = posixWatchPath(filePath);
return (
normalized.length > 0 &&
normalized !== '.' &&
!normalized.startsWith('../') &&
!path.posix.isAbsolute(normalized) &&
!path.win32.isAbsolute(filePath)
);
}
function isIgnoreControlPath(filePath: string): boolean {
const normalized = posixWatchPath(filePath);
return normalized === '.gitignore' || normalized === '.gitnexusignore';
}
function isConfigControlPath(filePath: string): boolean {
return posixWatchPath(filePath) === '.gitnexusrc';
}
function isAnalyzerOwnedWatchPath(filePath: string): boolean {
const normalized = posixWatchPath(filePath).replace(/\/+$/, '');
return normalized === GITNEXUS_DIR || normalized.startsWith(`${GITNEXUS_DIR}/`);
}
function repoRelativeWatchPath(repoPath: string, candidate: string): string | null {
const relative = path.relative(repoPath, candidate).replace(/\\/g, '/');
if (!relative || relative.startsWith('../') || path.isAbsolute(relative)) return null;
return relative;
}
export interface WatchEnvironmentBaseline {
readonly maxFileSize: string | undefined;
readonly workerTimeout: string | undefined;
readonly verbose: string | undefined;
}
function setEnvironment(name: string, value: string | undefined): void {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
function positiveInteger(
value: string | undefined,
flag: string,
maximum?: number,
): number | undefined {
if (value === undefined) return undefined;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 1)
throw new Error(`${flag} must be a positive integer`);
if (maximum !== undefined && parsed > maximum) {
throw new Error(`${flag} must not exceed ${maximum}`);
}
return parsed;
}
export async function resolveWatchOptions(
repoPath: string,
cli: WatchCliOptions,
baseline: WatchEnvironmentBaseline,
reportIgnoredConfig: (names: readonly string[]) => void = () => {},
): Promise<CoreAnalyzeOptions> {
const config = (await loadAnalyzeConfigStrict(repoPath)) ?? {};
const merged = mergeAnalyzeOptions(cli, config);
const unsupported = [
['--force', cli.force],
['--repair-fts', cli.repairFts],
['--embeddings', cli.embeddings],
['--drop-embeddings', cli.dropEmbeddings],
['--skills', cli.skills],
['--default-branch', cli.defaultBranch],
['--skip-agents-md', cli.skipAgentsMd],
['--skip-skills', cli.skipSkills],
['--no-stats', cli.stats === false],
['--self-commit', cli.selfCommit],
['--index-only', cli.indexOnly],
['--skip-git', cli.skipGit],
['--spring-actuator', cli.springActuator],
['walCheckpointThreshold', cli.walCheckpointThreshold],
['embeddingThreads', cli.embeddingThreads],
['embeddingBatchSize', cli.embeddingBatchSize],
['embeddingSubBatchSize', cli.embeddingSubBatchSize],
['embeddingDevice', cli.embeddingDevice],
['embeddingBaseUrl', cli.embeddingBaseUrl],
['embeddingModel', cli.embeddingModel],
['--embedding-auth-token', cli.embeddingAuthToken],
['--embedding-dims', cli.embeddingDims],
].filter(([, value]) => value !== undefined && value !== false);
if (unsupported.length > 0) {
throw new Error(
`analyze --watch does not support ${unsupported.map(([name]) => name).join(', ')}`,
);
}
reportIgnoredConfig(
[
['embeddings', config.embeddings],
['dropEmbeddings', config.dropEmbeddings],
['defaultBranch', config.defaultBranch],
['skipAgentsMd', config.skipAgentsMd !== undefined],
['skipSkills', config.skipSkills !== undefined],
['stats', config.stats !== undefined],
['springActuator', config.springActuator],
['walCheckpointThreshold', config.walCheckpointThreshold],
['embeddingThreads', config.embeddingThreads],
['embeddingBatchSize', config.embeddingBatchSize],
['embeddingSubBatchSize', config.embeddingSubBatchSize],
['embeddingDevice', config.embeddingDevice],
['embeddingBaseUrl', config.embeddingBaseUrl],
['embeddingModel', config.embeddingModel],
]
.filter(([, value]) => value !== undefined && value !== false)
.map(([name]) => String(name)),
);
const branch =
merged.branch === undefined ? undefined : validateBranchName(merged.branch, '--branch');
const workerPoolSize = positiveInteger(merged.workers, '--workers');
const workerTimeoutSeconds = positiveInteger(merged.workerTimeout, 'workerTimeout');
const maxFileSize = positiveInteger(merged.maxFileSize, 'maxFileSize', MAX_FILE_SIZE_KB);
setEnvironment(
'GITNEXUS_MAX_FILE_SIZE',
maxFileSize === undefined ? baseline.maxFileSize : String(maxFileSize),
);
if (workerTimeoutSeconds !== undefined) {
process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS = String(workerTimeoutSeconds * 1000);
} else {
setEnvironment('GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS', baseline.workerTimeout);
}
setEnvironment('GITNEXUS_VERBOSE', merged.verbose ? '1' : baseline.verbose);
return {
pdg: merged.pdg,
branch,
registryName: merged.name,
allowDuplicateName: merged.allowDuplicateName,
workerPoolSize,
fetchWrappers: merged.fetchWrappers,
skipAgentsMd: true,
skipSkills: true,
noStats: true,
atomicIncremental: process.platform !== 'win32',
};
}
function refreshSummary(
result: AnalyzeResult,
observedPaths: readonly string[],
durationMs: number,
lastSuccessfulRefreshAt: string,
): string {
const measured = result.incrementalStats;
const changed = measured?.changedFiles ?? (result.alreadyUpToDate ? 0 : observedPaths.length);
const reparsed =
measured?.reparsedFiles ??
(typeof result.pipelineResult?.reparsedFileCount === 'number'
? result.pipelineResult.reparsedFileCount
: 0);
const dependents = measured?.affectedDependents ?? 0;
const mode = measured?.writeMode ?? (result.alreadyUpToDate ? 'no-op' : 'full');
return (
`Refresh complete: ${changed} changed, ${reparsed} re-parsed, ` +
`${dependents} affected dependent(s), ${durationMs}ms, ${mode}; ` +
`last success ${lastSuccessfulRefreshAt}`
);
}
async function waitUntilReady(watcher: FSWatcher): Promise<void> {
await new Promise<void>((resolve, reject) => {
const ready = () => {
watcher.off('error', failed);
resolve();
};
const failed = (error: unknown) => {
watcher.off('ready', ready);
reject(error);
};
watcher.once('ready', ready);
watcher.once('error', failed);
});
}
export interface WatchFileLoop {
readonly waitForIdle: () => Promise<void>;
readonly close: () => Promise<void>;
}
class WatchControlReloadError extends Error {
constructor(cause: unknown) {
super(cause instanceof Error ? cause.message : String(cause), { cause });
this.name = 'WatchControlReloadError';
}
}
export function shouldStopAfterWatchRefreshFailure(
error: unknown,
paths: readonly string[],
): boolean {
return (
paths.length > 0 &&
!(error instanceof WatchControlReloadError) &&
analyzeFailureMayHaveMutatedLiveIndex(error)
);
}
/** Start the real filesystem watcher with bounded, serialized refreshes. */
export async function startWatchFileLoop(
repoPath: string,
debounceMs: number,
refresh: (paths: readonly string[]) => Promise<void>,
onError: WatchRefreshError,
onWatcherError: (error: unknown) => void = (error) => onError(error, []),
): Promise<WatchFileLoop> {
let ignorePath = await createWatchIgnorePredicate(repoPath);
let ignoreControlValid = true;
const queue = new WatchRefreshQueue(
async (paths) => {
if (paths.some(isIgnoreControlPath) || !ignoreControlValid) {
const retryingInvalidControls = !ignoreControlValid;
try {
ignorePath = await createWatchIgnorePredicate(repoPath);
ignoreControlValid = true;
watcher.add(repoPath);
} catch (error) {
ignoreControlValid = false;
throw new WatchControlReloadError(
retryingInvalidControls
? new Error(
'Ignore controls remain invalid; fix them before indexing more changes.',
{
cause: error,
},
)
: error,
);
}
}
await refresh(paths);
},
onError,
debounceMs,
{
maxWaitMs: Math.max(2_000, debounceMs * 10),
maxPendingPaths: 1_000,
holdEventsUntilInitialRefresh: true,
isPriorityPath: (filePath) => isIgnoreControlPath(filePath) || isConfigControlPath(filePath),
},
);
const watcher: FSWatcher = watch(repoPath, {
ignoreInitial: true,
atomic: true,
followSymlinks: false,
awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 20 },
ignored: (candidate, stats) => {
const relative = repoRelativeWatchPath(repoPath, candidate);
if (relative !== null && isAnalyzerOwnedWatchPath(relative)) return true;
if (relative !== null && (isIgnoreControlPath(relative) || isConfigControlPath(relative))) {
return false;
}
return ignorePath(candidate, stats?.isDirectory() ?? false);
},
});
watcher.on('all', (event, changedPath) => {
if (event !== 'add' && event !== 'change' && event !== 'unlink') return;
const relative = repoRelativeWatchPath(repoPath, changedPath);
if (relative && isRelevantWatchPath(relative) && !isAnalyzerOwnedWatchPath(relative)) {
queue.enqueue(relative);
}
});
watcher.on('error', (error) => {
// Chokidar can surface a transient EPERM on Windows while an ignored
// analyzer-owned path is replaced. Re-arm the root and force one bounded
// catch-up refresh so a missed event cannot leave the graph stale. Other
// watcher errors may mean coverage was lost and remain fatal.
if (TRANSIENT_WATCH_ERROR_CODES.has((error as NodeJS.ErrnoException).code ?? '')) {
watcher.add(repoPath);
queue.enqueue(WATCH_FULL_REFRESH_PATH);
return;
}
onWatcherError(error);
});
try {
await waitUntilReady(watcher);
await queue.runInitial();
} catch (error) {
await watcher.close();
await queue.close();
throw error;
}
return {
waitForIdle: () => queue.waitForIdle(),
close: async () => {
await watcher.close();
await queue.close();
},
};
}
export async function watchCommandWithRunnerIdentity(
runnerIdentityAtBootstrap: AnalyzerRunnerIdentity,
inputPath?: string,
cliOptions: WatchCliOptions = {},
): Promise<void> {
if (await ensureHeap({ cleanForwardedTermination: true })) return;
const requestedRepoPath = inputPath ? path.resolve(inputPath) : getGitRoot(process.cwd());
if (requestedRepoPath === null || !hasGitDir(requestedRepoPath)) {
cliError(' gitnexus analyze --watch requires a Git repository.');
process.exitCode = 1;
return;
}
const repoPath = await fs.realpath(requestedRepoPath);
const baselineEnvironment: WatchEnvironmentBaseline = {
maxFileSize: process.env.GITNEXUS_MAX_FILE_SIZE,
workerTimeout: process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS,
verbose: process.env.GITNEXUS_VERBOSE,
};
try {
let ignoredConfigSignature: string | undefined;
const reportIgnoredConfig = (names: readonly string[]) => {
const signature = [...names].sort().join(',');
if (signature === ignoredConfigSignature) return;
ignoredConfigSignature = signature;
if (names.length > 0) {
cliWarn(`Watch mode ignores unsupported .gitnexusrc settings: ${names.join(', ')}.`);
}
};
let debounceMs: number;
let analyzeOptions: CoreAnalyzeOptions;
try {
debounceMs =
positiveInteger(
cliOptions.debounce ?? String(DEFAULT_DEBOUNCE_MS),
'--debounce',
MAX_TIMER_DELAY_MS,
) ?? DEFAULT_DEBOUNCE_MS;
analyzeOptions = await resolveWatchOptions(
repoPath,
cliOptions,
baselineEnvironment,
reportIgnoredConfig,
);
} catch (error) {
cliError(` ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
return;
}
let stopWatching!: () => void;
const stopped = new Promise<void>((resolve) => {
stopWatching = resolve;
});
const stop = () => stopWatching();
process.once('SIGINT', stop);
process.once('SIGTERM', stop);
try {
let loop: WatchFileLoop;
let fatalRefreshError: unknown;
let configControlValid = true;
let lastSuccessfulRefreshAt: string | undefined;
try {
loop = await startWatchFileLoop(
repoPath,
debounceMs,
async (paths) => {
if (paths.some(isConfigControlPath) || !configControlValid) {
const retryingInvalidConfig = !configControlValid;
try {
analyzeOptions = await resolveWatchOptions(
repoPath,
cliOptions,
baselineEnvironment,
reportIgnoredConfig,
);
configControlValid = true;
} catch (error) {
configControlValid = false;
throw new WatchControlReloadError(
retryingInvalidConfig
? new Error(
'Configuration remains invalid; fix it before indexing more changes.',
{
cause: error,
},
)
: error,
);
}
}
const startedAt = Date.now();
const result = await runFullAnalysis(
repoPath,
analyzeOptions,
{
onProgress: () => {},
onLog:
process.env.GITNEXUS_VERBOSE === '1'
? (message) => cliInfo(` ${message}`)
: undefined,
},
runnerIdentityAtBootstrap,
);
lastSuccessfulRefreshAt = new Date().toISOString();
if (paths.length === 0) {
cliInfo(
result.alreadyUpToDate
? `Watching ${repoPath}; index is up to date.`
: `Watching ${repoPath}; initial index ready in ${Date.now() - startedAt}ms.`,
);
} else {
cliInfo(
refreshSummary(result, paths, Date.now() - startedAt, lastSuccessfulRefreshAt),
);
}
},
(error, paths) => {
const detail = paths.length > 0 ? ` (${paths.length} queued path(s))` : '';
if (shouldStopAfterWatchRefreshFailure(error, paths)) {
fatalRefreshError = error;
cliError(
`Refresh failed${detail}: ${error instanceof Error ? error.message : String(error)}. ` +
'Watch mode is stopping because the live index may have been updated in place.',
);
stopWatching();
return;
}
const lastSuccess = lastSuccessfulRefreshAt ?? 'none yet';
cliWarn(
`Refresh failed${detail}: ${error instanceof Error ? error.message : String(error)}. ` +
`Retry scheduled; last success ${lastSuccess}.`,
);
},
(error) => {
fatalRefreshError = error;
cliError(
`Watcher failed: ${error instanceof Error ? error.message : String(error)}. ` +
'Watch mode is stopping.',
);
stopWatching();
},
);
} catch (error) {
cliError(
` Unable to start watcher: ${error instanceof Error ? error.message : String(error)}`,
);
process.exitCode = 1;
return;
}
await stopped;
await loop.close();
if (fatalRefreshError !== undefined) process.exitCode = 1;
} finally {
process.removeListener('SIGINT', stop);
process.removeListener('SIGTERM', stop);
}
} finally {
setEnvironment('GITNEXUS_MAX_FILE_SIZE', baselineEnvironment.maxFileSize);
setEnvironment('GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS', baselineEnvironment.workerTimeout);
setEnvironment('GITNEXUS_VERBOSE', baselineEnvironment.verbose);
}
export async function watchAmbiguousCommand(_action?: string): Promise<void> {
process.stderr.write(t('error.watch.ambiguous'));
process.exitCode = 1;
}

View file

@ -0,0 +1,26 @@
import { CLASS_FRAMEWORK_ANNOTATIONS_FEATURE } from './analysis-features.js';
import {
SPRING_AOP_FEATURE,
SPRING_BEAN_INVENTORY_FEATURE,
SPRING_CONDITIONALS_FEATURE,
SPRING_NON_HTTP_HANDLERS_FEATURE,
SPRING_ROUTE_BINDINGS_FEATURE,
} from './ingestion/frameworks/spring/analysis-features.js';
import {
JAVA_ENUM_INTERFACE_HERITAGE_FEATURE,
JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE,
SPRING_CONFIG_BINDINGS_FEATURE,
} from './ingestion/languages/java/analysis-features.js';
/** Production registry of independently versioned analysis capabilities. */
export const ANALYSIS_FEATURES = [
CLASS_FRAMEWORK_ANNOTATIONS_FEATURE,
SPRING_AOP_FEATURE,
SPRING_BEAN_INVENTORY_FEATURE,
SPRING_CONDITIONALS_FEATURE,
SPRING_NON_HTTP_HANDLERS_FEATURE,
SPRING_ROUTE_BINDINGS_FEATURE,
SPRING_CONFIG_BINDINGS_FEATURE,
JAVA_ENUM_INTERFACE_HERITAGE_FEATURE,
JAVA_RECORD_COMPONENT_ACCESSORS_FEATURE,
] as const;

View file

@ -0,0 +1,210 @@
import { fork, type ChildProcess } from 'node:child_process';
import { existsSync } from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import type { AnalyzeOptions, AnalyzeResult } from '../run-analyze.js';
import type { WorkerMessage } from '../../server/analyze-worker-protocol.js';
import { autoHeapCapMb } from '../ingestion/utils/effective-ram.js';
const _require = createRequire(import.meta.url);
export type AutoSyncAnalysisRunner = (
repoPath: string,
options: AnalyzeOptions,
timeoutMs: number,
signal?: AbortSignal,
onCancellationRequested?: () => void,
concurrency?: number,
) => Promise<Pick<AnalyzeResult, 'stats'>>;
interface AnalysisWorker extends Pick<ChildProcess, 'send' | 'on'> {
stdout?: Pick<NodeJS.ReadableStream, 'resume'> | null;
stderr?: Pick<NodeJS.ReadableStream, 'resume'> | null;
unref?: () => void;
channel?: { unref(): void } | null;
}
/**
* How long the parent keeps waiting after asking a worker to cancel.
*
* Must stay below `stopAutoSyncWatch`'s process-exit budget, or a worker wedged
* past its safe point still turns `watch stop` into a timeout.
*/
const AUTO_SYNC_CANCEL_GRACE_MS = 5_000;
export interface AutoSyncAnalysisLaunchDeps {
forkWorker: (workerPath: string, execArgv: string[]) => AnalysisWorker;
setTimeoutFn: typeof setTimeout;
clearTimeoutFn: typeof clearTimeout;
cancelGraceMs: number;
}
const DEFAULT_DEPS: AutoSyncAnalysisLaunchDeps = {
forkWorker: (workerPath, execArgv) =>
fork(workerPath, [], {
execArgv,
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
}),
setTimeoutFn: setTimeout,
clearTimeoutFn: clearTimeout,
cancelGraceMs: AUTO_SYNC_CANCEL_GRACE_MS,
};
/**
* Per-worker V8 heap cap for one tick.
*
* `autoHeapCapMb()` is a whole-machine figure, so handing it to every fork
* over-commits memory by the parallelism factor. Admission already bounds
* parallelism to `floor(availableMemoryGB / 2)`, so dividing here keeps the sum
* of worker heaps inside the machine budget while leaving `max_concurrency`
* free to mean what it says. The two rules compose to a ~1.5GB per-worker floor.
*/
export function resolveWorkerHeapMb(concurrency = 1): number {
const slots = Number.isFinite(concurrency) && concurrency >= 1 ? Math.floor(concurrency) : 1;
return Math.max(1, Math.min(8192, Math.floor(autoHeapCapMb() / slots)));
}
export function createAutoSyncAnalysisRunner(
overrides: Partial<AutoSyncAnalysisLaunchDeps> = {},
): AutoSyncAnalysisRunner {
const deps = { ...DEFAULT_DEPS, ...overrides };
return (repoPath, options, timeoutMs, signal, onCancellationRequested, concurrency) =>
new Promise<Pick<AnalyzeResult, 'stats'>>((resolve, reject) => {
if (signal?.aborted) {
reject(new Error('Analysis cancelled.'));
return;
}
const callerPath = fileURLToPath(import.meta.url);
const isDev = callerPath.endsWith('.ts');
const workerPath = path.join(
path.dirname(callerPath),
'../../server',
isDev ? 'analyze-worker.ts' : 'analyze-worker.js',
);
if (!existsSync(workerPath)) {
reject(new Error(`Auto-sync analyze worker is missing: ${workerPath}`));
return;
}
const workerHeapMb = resolveWorkerHeapMb(concurrency);
const execArgv = isDev
? [
'--import',
pathToFileURL(_require.resolve('tsx/esm')).href,
`--max-old-space-size=${workerHeapMb}`,
]
: [`--max-old-space-size=${workerHeapMb}`];
const child = deps.forkWorker(workerPath, execArgv);
child.stdout?.resume();
child.stderr?.resume();
let terminalOutcome: WorkerMessage | undefined;
let terminationError: Error | undefined;
let settled = false;
let graceTimer: ReturnType<typeof setTimeout> | undefined;
const cleanup = () => {
deps.clearTimeoutFn(timeout);
deps.clearTimeoutFn(graceTimer);
signal?.removeEventListener('abort', onAbort);
};
// Stop the parent owning a worker it has given up waiting for. An
// established IPC channel keeps this event loop alive even after unref,
// so both handles have to go. Never a kill: the child may be inside
// native work and is left to reach its own safe point.
const releaseChild = () => {
child.channel?.unref?.();
child.unref?.();
};
const settle = (error?: Error, result?: Pick<AnalyzeResult, 'stats'>) => {
if (settled) return;
settled = true;
cleanup();
if (error) reject(error);
else resolve(result!);
};
const requestCancellation = (error: Error) => {
if (settled || terminationError) return;
terminationError = error;
deps.clearTimeoutFn(timeout);
onCancellationRequested?.();
// IPC has the same semantics on macOS and Windows. The worker exits only
// after reaching a JS-visible safe point; this parent keeps ownership until then.
try {
child.send({ type: 'cancel' });
} catch {
// A closed IPC channel still has an exit/error path. Do not force-kill a
// worker that may be inside native code.
}
// Bounded wait. A worker stuck past its safe point would otherwise leave
// this promise pending forever, wedging `activeRun` so `stop()` — and the
// `watch stop` waiting on this process to exit — can never finish. Settle
// the parent's wait and drop the IPC channel's hold on this event loop;
// an established channel keeps the parent alive even after unref. The
// child is deliberately left running rather than killed mid-write.
graceTimer = deps.setTimeoutFn(() => {
if (settled) return;
releaseChild();
settle(
new Error(
`${error.message} The analyze worker did not exit within ${deps.cancelGraceMs}ms; ` +
'it was left running so its native work is not interrupted.',
),
);
}, deps.cancelGraceMs);
};
const timeout = deps.setTimeoutFn(
() => requestCancellation(new Error(`Analysis timed out after ${timeoutMs}ms.`)),
timeoutMs,
);
const onAbort = () => requestCancellation(new Error('Analysis cancelled.'));
signal?.addEventListener('abort', onAbort, { once: true });
child.on('message', (message: WorkerMessage) => {
// Once timeout/cancellation requested shutdown, its reason owns the
// result. A terminal IPC can already be queued behind cancellation.
if (message.type === 'progress' || terminalOutcome || terminationError) return;
terminalOutcome = message;
deps.clearTimeoutFn(timeout);
});
child.on('error', (error) => {
const workerError = new Error(`Auto-sync analyze worker error: ${error.message}`);
requestCancellation(workerError);
// This settles immediately rather than waiting out the grace, so the
// grace timer that would otherwise have released the child is cleared
// by cleanup(). Release it here instead — an errored channel does not
// mean the worker stopped.
releaseChild();
settle(workerError);
});
child.on('exit', (code, childSignal) => {
if (settled) return;
if (terminationError) {
settle(terminationError);
return;
}
if (terminalOutcome?.type === 'complete') {
settle(undefined, { stats: terminalOutcome.result.stats });
return;
}
if (terminalOutcome?.type === 'error') {
settle(new Error(terminalOutcome.message));
return;
}
settle(
new Error(
`Auto-sync analyze worker exited before completion (${childSignal ?? code ?? 'unknown'}).`,
),
);
});
try {
child.send({ type: 'start', repoPath, options });
} catch (error) {
const startError = new Error(
`Failed to start auto-sync analyze worker: ${(error as Error).message}`,
);
requestCancellation(startError);
settle(startError);
}
});
}
export const runAutoSyncAnalysis = createAutoSyncAnalysisRunner();

View file

@ -0,0 +1,367 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { createRequire } from 'node:module';
import { getGlobalDir } from '../../storage/repo-manager.js';
import { normalizeConfiguredCloneRoot } from './path-security.js';
const _require = createRequire(import.meta.url);
const yaml = _require('js-yaml') as typeof import('js-yaml');
export const AUTO_SYNC_CONFIG_FILE = 'watch_config.yml';
const GROUP_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
const MIN_SYNC_INTERVAL_MINUTES = 5;
const MAX_TIMER_DELAY_MS = 2_147_483_647;
const MAX_SYNC_INTERVAL_MINUTES = Math.floor(MAX_TIMER_DELAY_MS / 60_000);
const DEFAULT_REPO_GIT_TIMEOUT_MS = 10_000;
const DEFAULT_MAX_CONCURRENCY = 1;
export const DEFAULT_ANALYZE_FAILURE_THRESHOLD = 3;
const MIN_ANALYZE_FAILURE_THRESHOLD = 2;
const ALLOWED_REMOTE_HOSTS = new Set(['github.com', 'gitlab.com', 'gitee.com']);
/**
* A single clone/pull must fit inside one sync interval and inside an hour.
* This is also the guard for the unit slip the bare-number rule invites:
* `repo_git_timeout: 600000` means 600000 SECONDS (~7 days), which clears the
* Node timer ceiling and would silently disable the timeout.
*/
const MAX_REPO_GIT_TIMEOUT_MS = 3_600_000;
// Mirrors REPO_NAME_PATTERN in server/git-clone.ts. Deliberately duplicated
// rather than imported: git-clone.ts already imports from this module, so the
// reverse edge would be a cycle.
const REMOTE_REPO_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/;
// Same charset for a namespace segment: GitLab subgroups allow exactly these,
// and excluding separators is what stops a segment smuggling in traversal.
const REMOTE_PATH_SEGMENT_PATTERN = REMOTE_REPO_NAME_PATTERN;
export interface AutoSyncProjectConfig {
localPath: string;
groupName?: string;
overwriteLocalChanges: boolean;
branches: string[];
remoteUrls: string[];
}
export interface AutoSyncConfig {
configPath: string;
syncIntervalMinutes: number;
repoGitTimeoutMs: number;
analyzeTimeoutMs: number;
maxConcurrency: number;
analyzeFailureThreshold: number;
projects: AutoSyncProjectConfig[];
}
export type AutoSyncConfigLoadResult =
| { ok: true; config: AutoSyncConfig }
| { ok: false; reason: 'missing' | 'unreadable' | 'invalid'; message: string };
export function getAutoSyncConfigPath(gitnexusDir = getGlobalDir()): string {
return path.join(gitnexusDir, AUTO_SYNC_CONFIG_FILE);
}
export function parseBranchCandidates(branchValue: unknown): string[] {
const rawItems = Array.isArray(branchValue)
? branchValue.flatMap((item) => String(item).split(','))
: String(branchValue ?? '').split(',');
const branches: string[] = [];
const seen = new Set<string>();
for (const item of rawItems) {
const branch = item.trim();
if (!branch || seen.has(branch)) continue;
seen.add(branch);
branches.push(branch);
}
return branches;
}
export async function loadAutoSyncConfig(
configPath = getAutoSyncConfigPath(),
): Promise<AutoSyncConfigLoadResult> {
let content: string;
try {
content = await fs.readFile(configPath, 'utf-8');
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
return {
ok: false,
reason: 'missing',
message: `[auto-sync] Missing config file: ${configPath}. Auto sync is skipped.`,
};
}
return {
ok: false,
reason: 'unreadable',
message: `[auto-sync] Unable to read config file: ${configPath}. Auto sync is skipped.`,
};
}
try {
return { ok: true, config: parseAutoSyncConfig(content, configPath) };
} catch (err: unknown) {
return {
ok: false,
reason: 'invalid',
message: `[auto-sync] Invalid watch_config.yml: ${(err as Error).message}. Auto sync is skipped.`,
};
}
}
export function parseAutoSyncConfig(content: string, configPath: string): AutoSyncConfig {
const raw = yaml.load(content, { schema: yaml.JSON_SCHEMA }) as Record<string, unknown>;
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
throw new Error('expected a YAML object');
}
const errors: string[] = [];
const interval = Number(raw.sync_interval_minutes);
if (!Number.isInteger(interval) || interval <= 0) {
errors.push('sync_interval_minutes must be a positive integer');
} else if (interval < MIN_SYNC_INTERVAL_MINUTES) {
errors.push(`sync_interval_minutes must be at least ${MIN_SYNC_INTERVAL_MINUTES}`);
} else if (interval > MAX_SYNC_INTERVAL_MINUTES) {
errors.push(`sync_interval_minutes must not exceed ${MAX_SYNC_INTERVAL_MINUTES}`);
}
// YAML booleans survive JSON_SCHEMA (`true`/`false`). `Number(true) === 1`
// would otherwise pass the integer check and silently mean concurrency 1.
let maxConcurrency = DEFAULT_MAX_CONCURRENCY;
if (raw.max_concurrency !== undefined) {
if (typeof raw.max_concurrency !== 'number' || !Number.isInteger(raw.max_concurrency)) {
errors.push('max_concurrency must be a positive integer');
} else if (raw.max_concurrency <= 0) {
errors.push('max_concurrency must be a positive integer');
} else {
maxConcurrency = raw.max_concurrency;
}
}
const repoGitTimeoutMs =
raw.repo_git_timeout === undefined
? DEFAULT_REPO_GIT_TIMEOUT_MS
: parseDurationMs(raw.repo_git_timeout);
const maxRepoGitTimeoutMs =
Number.isInteger(interval) &&
interval >= MIN_SYNC_INTERVAL_MINUTES &&
interval <= MAX_SYNC_INTERVAL_MINUTES
? Math.min(interval * 60_000, MAX_REPO_GIT_TIMEOUT_MS)
: undefined;
if (!Number.isInteger(repoGitTimeoutMs) || repoGitTimeoutMs <= 0) {
errors.push('repo_git_timeout must be a positive duration such as 10s');
} else if (repoGitTimeoutMs > MAX_TIMER_DELAY_MS) {
errors.push(`repo_git_timeout must not exceed ${MAX_TIMER_DELAY_MS}ms`);
} else if (maxRepoGitTimeoutMs !== undefined && repoGitTimeoutMs > maxRepoGitTimeoutMs) {
errors.push(
`repo_git_timeout must not exceed ${maxRepoGitTimeoutMs}ms (the lesser of 1h and ` +
`sync_interval_minutes); a bare number is interpreted as seconds, so use an explicit ` +
`unit such as 600000ms or 10m`,
);
}
const maxAnalyzeTimeoutMs =
Number.isInteger(interval) &&
interval >= MIN_SYNC_INTERVAL_MINUTES &&
interval <= MAX_SYNC_INTERVAL_MINUTES
? interval * 30_000
: undefined;
const analyzeTimeoutMs =
raw.analyze_timeout === undefined
? (maxAnalyzeTimeoutMs ?? 0)
: parseDurationMs(raw.analyze_timeout);
if (!Number.isInteger(analyzeTimeoutMs) || analyzeTimeoutMs <= 0) {
errors.push('analyze_timeout must be a positive duration such as 30m');
} else if (maxAnalyzeTimeoutMs !== undefined && analyzeTimeoutMs > maxAnalyzeTimeoutMs) {
errors.push(
`analyze_timeout must not exceed half of sync_interval_minutes (${maxAnalyzeTimeoutMs / 60_000}m)`,
);
}
const analyzeFailureThreshold =
raw.analyze_failure_threshold === undefined
? DEFAULT_ANALYZE_FAILURE_THRESHOLD
: Number(raw.analyze_failure_threshold);
if (
!Number.isInteger(analyzeFailureThreshold) ||
analyzeFailureThreshold < MIN_ANALYZE_FAILURE_THRESHOLD
) {
errors.push(`analyze_failure_threshold must be an integer >= ${MIN_ANALYZE_FAILURE_THRESHOLD}`);
}
const rawProjects = raw.projects;
if (!Array.isArray(rawProjects) || rawProjects.length === 0) {
errors.push('projects must contain at least one project');
}
const projects: AutoSyncProjectConfig[] = [];
if (Array.isArray(rawProjects)) {
rawProjects.forEach((projectValue, index) => {
const project = projectValue as Record<string, unknown>;
if (!project || typeof project !== 'object' || Array.isArray(project)) {
errors.push(`projects[${index}] must be an object`);
return;
}
const localPath = typeof project.local_path === 'string' ? project.local_path.trim() : '';
if (!localPath) {
errors.push(`projects[${index}].local_path is required`);
} else {
try {
normalizeConfiguredCloneRoot(localPath);
} catch (err: unknown) {
errors.push(`projects[${index}].local_path ${(err as Error).message}`);
}
}
const remoteUrls = Array.isArray(project.remote_urls)
? project.remote_urls.map((url) => String(url).trim()).filter(Boolean)
: [];
if (remoteUrls.length === 0) {
errors.push(`projects[${index}].remote_urls must contain at least one URL`);
}
for (let urlIndex = 0; urlIndex < remoteUrls.length; urlIndex += 1) {
try {
validateAutoSyncRemoteUrl(remoteUrls[urlIndex]);
} catch (err: unknown) {
errors.push(`projects[${index}].remote_urls[${urlIndex}] ${(err as Error).message}`);
}
}
if (project.branch !== undefined && project.branches !== undefined) {
errors.push(`projects[${index}] must not set both branch and branches`);
}
const branches = parseBranchCandidates(
project.branches !== undefined ? project.branches : project.branch,
);
if (branches.length === 0) errors.push(`projects[${index}].branches is required`);
for (let branchIndex = 0; branchIndex < branches.length; branchIndex += 1) {
try {
validateAutoSyncBranchName(branches[branchIndex]);
} catch (err: unknown) {
errors.push(`projects[${index}].branches[${branchIndex}] ${(err as Error).message}`);
}
}
const groupName =
typeof project.group_name === 'string' && project.group_name.trim()
? project.group_name.trim()
: undefined;
if (groupName && !GROUP_NAME_PATTERN.test(groupName)) {
errors.push(`projects[${index}].group_name is invalid`);
}
const overwriteLocalChanges =
project.overwrite_local_changes === undefined ? false : project.overwrite_local_changes;
if (typeof overwriteLocalChanges !== 'boolean') {
errors.push(`projects[${index}].overwrite_local_changes must be a boolean`);
}
if (localPath && remoteUrls.length > 0 && branches.length > 0) {
projects.push({
localPath,
groupName,
overwriteLocalChanges: overwriteLocalChanges === true,
branches,
remoteUrls,
});
}
});
}
if (errors.length > 0) throw new Error(errors.join('; '));
return {
configPath,
syncIntervalMinutes: interval,
repoGitTimeoutMs,
analyzeTimeoutMs,
maxConcurrency,
analyzeFailureThreshold,
projects,
};
}
export function validateAutoSyncRemoteUrl(remoteUrl: string): void {
const trimmed = remoteUrl.trim();
if (trimmed.includes('?') || trimmed.includes('#')) {
throw new Error('must not include query strings or fragments');
}
const match = /^git@([^:\s/]+):([^\s]+)$/.exec(trimmed);
if (!match) {
throw new Error('must use an SSH URL on github.com, gitlab.com, or gitee.com');
}
const host = match[1].toLowerCase();
const repoPath = match[2];
if (!ALLOWED_REMOTE_HOSTS.has(host)) {
throw new Error('host must be one of github.com, gitlab.com, or gitee.com');
}
const pathParts = repoPath.split('/');
// Every segment becomes a directory component: the namespace segments build
// the clone path and the last one names the repo. So each is held to the same
// charset, which is what keeps a separator out of a segment — on Windows
// `..\..\outside` is traversal even though the segment is not literally `..`,
// and testing the raw string for `..` instead would reject an ordinary
// `foo..bar`. Traversal is a whole segment; a separator is a character.
const namespaceParts = pathParts.slice(0, -1);
if (
repoPath.startsWith('/') ||
pathParts.length < 2 ||
pathParts.some((part) => !part || part === '.' || part === '..') ||
namespaceParts.some((part) => !REMOTE_PATH_SEGMENT_PATTERN.test(part))
) {
throw new Error('path must include owner/repo without traversal');
}
// The final segment becomes the on-disk clone directory via `extractRepoName`,
// whose name rules are stricter than the path check above: a backslash — or
// anything outside `[A-Za-z0-9._-]` — passes here and then throws once per
// tick inside the sync loop instead of at config load. These rules are a
// strict superset, so anything accepted here is accepted there.
const lastSegment = pathParts[pathParts.length - 1];
const repoName = /\.git$/i.test(lastSegment) ? lastSegment.slice(0, -4) : lastSegment;
if (
!repoName ||
repoName === '.' ||
repoName === '..' ||
repoName === 'unknown' ||
repoName.startsWith('-') ||
!REMOTE_REPO_NAME_PATTERN.test(repoName)
) {
throw new Error(
'repository name must use only letters, digits, ".", "_", or "-" and must not be "unknown"',
);
}
}
export function validateAutoSyncBranchName(branch: string): void {
if (!branch.trim()) throw new Error('must not be empty');
if (/[\s\0-\x1f\x7f]/.test(branch))
throw new Error('must not contain whitespace or control characters');
if (/[~^:?*[\\]/.test(branch)) throw new Error('contains characters not allowed in a git ref');
if (branch.startsWith('-')) throw new Error('must not start with "-"');
if (branch.startsWith('/')) throw new Error('must not start with "/"');
if (branch.includes('..')) throw new Error('must not contain ".."');
if (branch.includes('`')) throw new Error('must not contain backticks');
if (branch.endsWith('/') || branch.endsWith('.')) throw new Error('must not end with "/" or "."');
if (branch.includes('//')) throw new Error('must not contain consecutive slashes');
if (branch.includes('@{')) throw new Error('must not contain "@{"');
if (
branch
.split('/')
.some(
(component) =>
component.startsWith('.') || component.endsWith('.') || component.endsWith('.lock'),
)
)
throw new Error('must not contain hidden, trailing-dot, or .lock path components');
}
export function parseDurationMs(value: unknown): number {
if (typeof value === 'number') return value * 1_000;
const raw = String(value ?? '').trim();
const match = /^(\d+)(ms|s|m)?$/.exec(raw);
if (!match) return Number.NaN;
const amount = Number(match[1]);
const unit = match[2] ?? 's';
if (unit === 'ms') return amount;
if (unit === 's') return amount * 1_000;
return amount * 60_000;
}

View file

@ -0,0 +1,57 @@
export {
AUTO_SYNC_CONFIG_FILE,
getAutoSyncConfigPath,
loadAutoSyncConfig,
parseAutoSyncConfig,
parseBranchCandidates,
parseDurationMs,
validateAutoSyncBranchName,
validateAutoSyncRemoteUrl,
type AutoSyncConfig,
type AutoSyncConfigLoadResult,
type AutoSyncProjectConfig,
} from './config.js';
export {
buildStateKey,
getAutoSyncMutexPath,
getAutoSyncWatchDir,
getAutoSyncStatePath,
getProjectCommitInfoPath,
loadAutoSyncState,
resetAutoSyncState,
saveAutoSyncState,
shouldAnalyzeCommit,
writeProjectCommitInfo,
type AutoSyncAnalyzeStatus,
type AutoSyncCommitState,
type AutoSyncCommitStateEntry,
type ProjectCommitInfoEntry,
} from './state.js';
export { extractRepoNameFromRemoteUrl } from './repo.js';
export {
normalizeConfiguredCloneRoot,
quarantineAutoSyncPartial,
resolveConfiguredCloneRoot,
type AutoSyncCloneRoot,
} from './path-security.js';
export {
addRepoToGroup,
getAutoSyncRepoIdentity,
getConfiguredRepoPath,
resolveActualConcurrency,
runAutoSyncOnce,
syncGroupByName,
type AutoSyncLogger,
type AutoSyncRunDeps,
type AutoSyncRunResult,
} from './runner.js';
export {
getAutoSyncWatchPaths,
readAutoSyncWatchStatus,
startAutoSyncWatch,
stopAutoSyncWatch,
type AutoSyncStartHandle,
type AutoSyncWatchStopResult,
type AutoSyncWatchPaths,
type WatchStatusRecord,
} from './starter.js';

View file

@ -0,0 +1,286 @@
import fs from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import os from 'node:os';
import path from 'node:path';
import { getGlobalDir } from '../../storage/repo-manager.js';
import { getAutoSyncWatchDir } from './state.js';
const WINDOWS_DANGEROUS_ROOTS =
process.platform === 'win32'
? [
process.env.SystemRoot,
process.env.ProgramData,
process.env.ProgramFiles,
process.env['ProgramFiles(x86)'],
].filter((entry): entry is string => Boolean(entry))
: [];
const DANGEROUS_ROOTS = new Set(
[
'/',
os.homedir(),
os.tmpdir(),
'/bin',
'/boot',
'/dev',
'/etc',
'/lib',
'/lib64',
'/opt',
'/proc',
'/private/tmp',
'/private/var',
'/root',
'/sbin',
'/sys',
'/tmp',
'/usr',
'/var',
...WINDOWS_DANGEROUS_ROOTS,
].map((entry) => path.resolve(entry)),
);
const DANGEROUS_PARENT_ROOTS = new Set(
[
os.tmpdir(),
'/bin',
'/boot',
'/dev',
'/etc',
'/lib',
'/lib64',
'/opt',
'/proc',
'/private/tmp',
'/private/var',
'/root',
'/sbin',
'/sys',
'/tmp',
'/usr',
'/var',
...WINDOWS_DANGEROUS_ROOTS,
].map((entry) => path.resolve(entry)),
);
const QUARANTINE_RETENTION_DAYS = 14;
const QUARANTINE_MAX_ENTRIES_PER_REPO = 5;
// `auto-sync-<stamp>-<pid>-<uuid>-<repo basename>` — see quarantineAutoSyncPartial.
// The UUID is the only fixed-shape field, so it anchors the grouping key, and
// everything after it is the basename (`[A-Za-z0-9._-]` by construction).
const QUARANTINE_ENTRY_PATTERN =
/^auto-sync-.+-\d+-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-(.+)$/i;
export interface AutoSyncCloneRoot {
root: string;
quarantineRoot: string;
quarantineRetentionDays: number;
}
export async function resolveConfiguredCloneRoot(localPath: string): Promise<AutoSyncCloneRoot> {
const root = normalizeConfiguredCloneRoot(localPath);
assertNotDangerousRoot(root);
await assertNoSymlinkPath(root);
await fs.mkdir(root, { recursive: true });
await assertDirectoryOwnerAndPermissions(root);
const realRoot = await fs.realpath(root);
assertContainedOrSame(
root,
realRoot,
'Configured clone root realpath escaped its normalized path',
);
assertNotDangerousRoot(realRoot);
assertNotGitNexusInternalRoot(realRoot);
const quarantineRoot = path.join(getAutoSyncWatchDir(), 'quarantine');
await pruneQuarantineEntries(quarantineRoot);
return {
root: realRoot,
quarantineRoot,
quarantineRetentionDays: QUARANTINE_RETENTION_DAYS,
};
}
export function normalizeConfiguredCloneRoot(localPath: string): string {
const value = localPath.trim();
if (!value) throw new Error('local_path is required');
if (!path.isAbsolute(value)) throw new Error('local_path must be an absolute path');
if (value.split(path.sep).includes('..')) {
throw new Error('local_path must be normalized and must not contain traversal segments');
}
const resolved = path.resolve(value);
if (resolved !== path.normalize(value)) {
throw new Error('local_path must be normalized and must not contain traversal segments');
}
return resolved;
}
export async function quarantineAutoSyncPartial(
targetDir: string,
quarantineRoot: string,
): Promise<string> {
await fs.mkdir(quarantineRoot, { recursive: true, mode: 0o700 });
const base = path.basename(targetDir);
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const destination = path.join(
quarantineRoot,
`auto-sync-${stamp}-${process.pid}-${randomUUID()}-${base}`,
);
try {
await fs.rename(targetDir, destination);
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code !== 'EXDEV') throw err;
await fs.cp(targetDir, destination, { recursive: true });
await fs.rm(targetDir, { recursive: true, force: true });
}
await fs.writeFile(
`${destination}.README.txt`,
[
'GitNexus auto-sync isolated a partial or unsafe clone result.',
`Created at: ${new Date().toISOString()}`,
`Original path: ${targetDir}`,
`Retention: keep for ${QUARANTINE_RETENTION_DAYS} days unless an operator reviews and removes it earlier.`,
'Cleanup: verify the original path and remote before manual deletion.',
'',
].join('\n'),
'utf-8',
);
return destination;
}
async function pruneQuarantineEntries(quarantineRoot: string): Promise<void> {
const cutoff = Date.now() - QUARANTINE_RETENTION_DAYS * 24 * 60 * 60 * 1_000;
// readdir and stat both resolve through a link, so a symlinked quarantine
// root would age-sweep and delete entries somewhere else entirely.
const rootStat = await fs.lstat(quarantineRoot).catch(() => undefined);
if (rootStat?.isSymbolicLink()) {
throw new Error(`Refusing symlinked auto-sync quarantine root: ${quarantineRoot}`);
}
let entries;
try {
entries = await fs.readdir(quarantineRoot);
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return;
throw err;
}
const survivors = (
await Promise.all(
entries
.filter((entry) => entry.startsWith('auto-sync-'))
.map(async (entry) => {
const entryPath = path.join(quarantineRoot, entry);
const stat = await fs.stat(entryPath).catch(() => undefined);
if (stat && stat.mtimeMs < cutoff) {
await fs.rm(entryPath, { recursive: true, force: true });
return undefined;
}
return entry;
}),
)
).filter((entry): entry is string => entry !== undefined);
// Age alone never bounds a repo that fails on every tick: one partial clone
// per tick stays inside the retention window forever. Keep the newest few per
// repo. Entries that do not match the generated naming scheme (operator
// notes, names from another version) are left to the age sweep alone.
const byRepo = new Map<string, string[]>();
for (const entry of survivors) {
if (entry.endsWith('.README.txt')) continue;
const repo = QUARANTINE_ENTRY_PATTERN.exec(entry)?.[1];
if (!repo) continue;
const group = byRepo.get(repo) ?? [];
group.push(entry);
byRepo.set(repo, group);
}
await Promise.all(
[...byRepo.values()].flatMap((group) =>
group
// The timestamp is the leading fixed-width field, so a descending
// string sort is newest-first.
.sort((a, b) => (a < b ? 1 : a > b ? -1 : 0))
.slice(QUARANTINE_MAX_ENTRIES_PER_REPO)
.map(async (entry) => {
await fs.rm(path.join(quarantineRoot, entry), { recursive: true, force: true });
await fs.rm(path.join(quarantineRoot, `${entry}.README.txt`), { force: true });
}),
),
);
}
function assertNotDangerousRoot(root: string): void {
if (root === path.resolve(getGlobalDir(), 'repos')) return;
if (DANGEROUS_ROOTS.has(root)) throw new Error(`Refusing unsafe auto-sync clone root: ${root}`);
for (const dangerousRoot of DANGEROUS_PARENT_ROOTS) {
const rel = path.relative(dangerousRoot, root);
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
throw new Error(`Refusing unsafe auto-sync clone root under ${dangerousRoot}: ${root}`);
}
}
if (path.parse(root).root === root)
throw new Error(`Refusing filesystem root as clone root: ${root}`);
}
function assertNotGitNexusInternalRoot(root: string): void {
const gitnexusDir = path.resolve(getGlobalDir());
const blocked = [
path.join(gitnexusDir, 'groups'),
path.join(gitnexusDir, 'indexes'),
path.join(gitnexusDir, 'quarantine'),
path.join(getAutoSyncWatchDir(gitnexusDir), 'quarantine'),
];
for (const blockedRoot of blocked) {
const rel = path.relative(blockedRoot, root);
if (!rel || (!rel.startsWith('..') && !path.isAbsolute(rel))) {
throw new Error(`Refusing GitNexus internal directory as auto-sync clone root: ${root}`);
}
}
}
async function assertNoSymlinkPath(root: string): Promise<void> {
const parsed = path.parse(root);
let current = parsed.root;
const parts = root.slice(parsed.root.length).split(path.sep).filter(Boolean);
for (const part of parts) {
current = path.join(current, part);
let stat;
try {
stat = await fs.lstat(current);
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') break;
throw err;
}
if (stat.isSymbolicLink())
throw new Error(`Refusing symlink in auto-sync clone root path: ${current}`);
}
}
export async function assertDirectoryOwnerAndPermissions(root: string): Promise<void> {
const stat = await fs.stat(root);
if (!stat.isDirectory()) throw new Error(`auto-sync clone root is not a directory: ${root}`);
// POSIX uid/mode have no meaning on Windows, and this runs on every tick for
// every project, so throwing here failed 100% of repos forever while `watch
// status` still read `running`. Skip the ownership assertions rather than the
// whole feature: the caller's other guards — dangerous-root rejection
// (including the Windows system roots), symlink refusal, realpath containment
// and the GitNexus-internal-root check — all still apply, and managed git runs
// with `core.hooksPath` pinned to the null device.
if (process.platform === 'win32') return;
if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
throw new Error(`auto-sync clone root is owned by uid ${stat.uid}, not current process uid`);
}
const mode = stat.mode & 0o777;
const groupWritable = (mode & 0o020) !== 0;
const worldWritable = (mode & 0o002) !== 0;
if (worldWritable) {
throw new Error(`Refusing world-writable auto-sync clone root: ${root}`);
}
if (groupWritable) {
throw new Error(`Refusing group-writable auto-sync clone root: ${root}`);
}
}
function assertContainedOrSame(root: string, child: string, message: string): void {
const rel = path.relative(root, child);
if (rel.startsWith('..') || path.isAbsolute(rel)) throw new Error(message);
}

View file

@ -0,0 +1,7 @@
import { extractRepoName } from '../../server/git-clone.js';
import { validateAutoSyncRemoteUrl } from './config.js';
export function extractRepoNameFromRemoteUrl(remoteUrl: string): string {
validateAutoSyncRemoteUrl(remoteUrl);
return extractRepoName(remoteUrl);
}

View file

@ -0,0 +1,561 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { createRequire } from 'node:module';
import { loadGroupConfig } from '../group/config-parser.js';
import { getDefaultGitnexusDir, getGroupDir } from '../group/storage.js';
import { syncGroup } from '../group/sync.js';
import { registerRepo, resolveBranchPlacement, type RepoMeta } from '../../storage/repo-manager.js';
import { extractRepoNameFromRemoteUrl } from './repo.js';
import { cloneOrPull, runGit } from '../../server/git-clone.js';
import { resolveConfiguredCloneRoot } from './path-security.js';
import {
buildStateKey,
loadAutoSyncState,
saveAutoSyncState,
shouldAnalyzeCommit,
writeProjectCommitInfo,
type AutoSyncAnalyzeStatus,
type AutoSyncCommitStateEntry,
type ProjectCommitInfoEntry,
} from './state.js';
import type { AutoSyncConfig, AutoSyncProjectConfig } from './config.js';
import { validateAutoSyncRemoteUrl } from './config.js';
import { runAutoSyncAnalysis, type AutoSyncAnalysisRunner } from './analysis-worker-launch.js';
export interface AutoSyncLogger {
info(message: string): void;
warn(message: string): void;
error(message: string): void;
}
export interface AutoSyncRunDeps {
cloneOrPull: typeof cloneOrPull;
getCurrentBranch: (repoPath: string, timeoutMs: number) => Promise<string | undefined>;
getCurrentCommit: (repoPath: string, timeoutMs: number) => Promise<string>;
runAnalysis: AutoSyncAnalysisRunner;
registerRepo: typeof registerRepo;
resolveBranchPlacement: typeof resolveBranchPlacement;
loadState: typeof loadAutoSyncState;
saveState: typeof saveAutoSyncState;
writeCommitInfo: typeof writeProjectCommitInfo;
addRepoToGroup: typeof addRepoToGroup;
syncGroupByName: typeof syncGroupByName;
resolveCloneRoot: typeof resolveConfiguredCloneRoot;
getAvailableMemoryGB: () => number;
}
export interface AutoSyncRunResult {
synced: number;
analyzed: number;
skippedAnalysis: number;
failed: number;
}
const _require = createRequire(import.meta.url);
const yaml = _require('js-yaml') as typeof import('js-yaml');
const DEFAULT_LOGGER: AutoSyncLogger = {
info: (message) => process.stderr.write(`${message}\n`),
warn: (message) => process.stderr.write(`${message}\n`),
error: (message) => process.stderr.write(`${message}\n`),
};
const DEFAULT_DEPS: AutoSyncRunDeps = {
cloneOrPull,
getCurrentBranch: async (repoPath, timeoutMs) => {
const branch = (await runGit(['branch', '--show-current'], repoPath, { timeoutMs })).trim();
return branch || undefined;
},
getCurrentCommit: async (repoPath, timeoutMs) =>
(await runGit(['rev-parse', 'HEAD'], repoPath, { timeoutMs })).trim(),
runAnalysis: runAutoSyncAnalysis,
registerRepo,
resolveBranchPlacement,
loadState: loadAutoSyncState,
saveState: saveAutoSyncState,
writeCommitInfo: writeProjectCommitInfo,
addRepoToGroup,
syncGroupByName,
resolveCloneRoot: resolveConfiguredCloneRoot,
getAvailableMemoryGB: () => Math.floor(process.availableMemory?.() ?? 0) / 1024 / 1024 / 1024,
};
export async function runAutoSyncOnce(
config: AutoSyncConfig,
options: {
deps?: Partial<AutoSyncRunDeps>;
logger?: AutoSyncLogger;
now?: () => Date;
signal?: AbortSignal;
onAnalysisCancellationRequested?: () => void;
} = {},
): Promise<AutoSyncRunResult> {
const deps = { ...DEFAULT_DEPS, ...options.deps };
const logger = options.logger ?? DEFAULT_LOGGER;
const now = options.now ?? (() => new Date());
throwIfAborted(options.signal);
const state = await deps.loadState();
throwIfAborted(options.signal);
const groupsToSync = new Set<string>();
const groupStateKeys = new Map<string, string[]>();
const result: AutoSyncRunResult = { synced: 0, analyzed: 0, skippedAnalysis: 0, failed: 0 };
const commitInfoEntries: ProjectCommitInfoEntry[] = [];
const actualConcurrency = resolveActualConcurrency(
config.maxConcurrency,
deps.getAvailableMemoryGB(),
);
logger.info(
`[auto-sync] Starting sync loop with max_concurrency=${actualConcurrency} analyze_failure_threshold=${config.analyzeFailureThreshold}.`,
);
const workItems = await buildWorkItems(config, deps);
// What will actually run at once. One repo means one worker, so the common
// single-project case still hands that worker the whole machine budget.
const analysisParallelism = Math.max(1, Math.min(actualConcurrency, workItems.length));
const repoResults = await mapWithConcurrency(
workItems,
actualConcurrency,
options.signal,
async (item) => {
const lastSyncTime = now().toISOString();
try {
throwIfAborted(options.signal);
if (!item.cloneRoot || !item.repoName || !item.targetDir) {
throw new Error(item.error ?? 'Invalid auto-sync work item');
}
const repoName = item.repoName;
const targetDir = item.targetDir;
const syncResult = await syncFirstAvailableBranch({
item,
repoName,
targetDir,
timeoutMs: config.repoGitTimeoutMs,
deps,
logger,
});
throwIfAborted(options.signal);
if (syncResult.ok === false) {
logger.error(
`[auto-sync] Repository sync failed for ${item.remoteUrl}; no configured branch could be pulled: ${syncResult.message}`,
);
return {
kind: 'failed' as const,
project: item.project,
remoteUrl: item.remoteUrl,
targetDir,
branch: item.project.branches[0],
status: syncResult.status,
analyzeConsecutiveFailures: 0,
lastSyncTime,
};
}
const currentBranch = syncResult.branch;
const currentCommit = await deps.getCurrentCommit(targetDir, config.repoGitTimeoutMs);
const stateKey = buildStateKey(targetDir, currentBranch);
const previous = state[stateKey];
let analyzeStatus: AutoSyncAnalyzeStatus = 'skipped';
let analyzedCommitId = previous?.analyzedCommitId;
let analyzeConsecutiveFailures = previous?.analyzeConsecutiveFailures ?? 0;
let lastAnalyzeError = previous?.lastAnalyzeError;
const groupSyncPending = previous?.groupSyncPending === true;
let stats: RepoMeta['stats'] | undefined;
if (previous && previous.codeCommitId !== currentCommit) {
analyzeConsecutiveFailures = 0;
lastAnalyzeError = undefined;
}
if (analyzeConsecutiveFailures >= config.analyzeFailureThreshold) {
analyzeStatus = 'threshold_skipped';
logger.error(
`[auto-sync] Skip analysis for ${targetDir}; analyze consecutive failures ${analyzeConsecutiveFailures}/${config.analyzeFailureThreshold} reached threshold. Fix the repository or clear auto-sync state before retrying.`,
);
} else if (
shouldAnalyzeCommit({
currentCommit,
previousAnalyzedCommit: previous?.analyzedCommitId,
previousStatus: previous?.lastAnalyzeStatus,
})
) {
try {
const analysis = await deps.runAnalysis(
targetDir,
{ branch: currentBranch, skipAgentsMd: true, skipSkills: true },
config.analyzeTimeoutMs,
options.signal,
options.onAnalysisCancellationRequested,
analysisParallelism,
);
throwIfAborted(options.signal);
stats = analysis.stats;
analyzeStatus = 'success';
analyzedCommitId = currentCommit;
analyzeConsecutiveFailures = 0;
lastAnalyzeError = undefined;
} catch (err: unknown) {
if (options.signal?.aborted) throw err;
analyzeStatus = 'failed';
analyzeConsecutiveFailures += 1;
lastAnalyzeError = shortErrorMessage(err);
logger.error(
`[auto-sync] Analysis failed for ${targetDir}; consecutive failures ${analyzeConsecutiveFailures}/${config.analyzeFailureThreshold}: ${lastAnalyzeError}`,
);
}
} else {
logger.info(`[auto-sync] Skip analysis for ${targetDir}; commit unchanged.`);
}
throwIfAborted(options.signal);
return {
kind: 'synced' as const,
project: item.project,
repoName,
remoteUrl: item.remoteUrl,
targetDir,
branch: currentBranch,
currentCommit,
analyzedCommitId,
analyzeStatus,
analyzeConsecutiveFailures,
lastAnalyzeError,
groupSyncPending,
stats,
stateKey,
lastSyncTime,
};
} catch (err: unknown) {
if (options.signal?.aborted) throw err;
logger.error(
`[auto-sync] Repository sync failed for ${item.remoteUrl}: ${(err as Error).message}`,
);
return {
kind: 'failed' as const,
project: item.project,
remoteUrl: item.remoteUrl,
targetDir: item.targetDir ?? '',
status: 'sync_failed' as const,
lastSyncTime,
};
}
},
);
for (const repoResult of repoResults) {
if (repoResult.kind === 'failed') {
result.failed += 1;
commitInfoEntries.push({
remoteUrl: repoResult.remoteUrl,
localPath: repoResult.targetDir,
branch: repoResult.branch,
status: repoResult.status,
lastSyncTime: repoResult.lastSyncTime,
});
continue;
}
result.synced += 1;
let analyzeStatus = repoResult.analyzeStatus;
let analyzeConsecutiveFailures = repoResult.analyzeConsecutiveFailures;
let lastAnalyzeError = repoResult.lastAnalyzeError;
let analyzedCommitId = repoResult.analyzedCommitId;
if (analyzeStatus === 'success') {
const meta: RepoMeta = {
repoPath: repoResult.targetDir,
lastCommit: repoResult.currentCommit,
indexedAt: repoResult.lastSyncTime,
stats: repoResult.stats!,
branch: repoResult.branch,
remoteUrl: repoResult.remoteUrl,
};
try {
// Reproduce the placement the analyze worker already made. Registering
// without a branch always takes the primary/flat arm, which relabels a
// pinned branch entry with whatever this tick happened to sync — visible
// on the documented branch-fallback path.
const placement = await deps.resolveBranchPlacement(
repoResult.targetDir,
repoResult.branch,
);
await deps.registerRepo(repoResult.targetDir, meta, {
name: getAutoSyncRepoIdentity(repoResult.remoteUrl),
// Omitted rather than passed as undefined, so a primary index is
// registered with the same option shape it had before this branch.
...(placement.branch ? { branch: placement.branch } : {}),
});
result.analyzed += 1;
} catch (err: unknown) {
analyzeStatus = 'failed';
analyzedCommitId = undefined;
analyzeConsecutiveFailures += 1;
lastAnalyzeError = `Repository registration failed: ${shortErrorMessage(err)}`;
result.failed += 1;
logger.error(`[auto-sync] ${lastAnalyzeError}`);
}
} else if (analyzeStatus === 'failed') {
result.failed += 1;
} else {
result.skippedAnalysis += 1;
}
const stateEntry: AutoSyncCommitStateEntry = {
codeCommitId: repoResult.currentCommit,
analyzedCommitId,
lastAnalyzeStatus: analyzeStatus,
analyzeConsecutiveFailures,
lastAnalyzeError,
groupSyncPending: repoResult.groupSyncPending,
lastSyncTime: repoResult.lastSyncTime,
};
state[repoResult.stateKey] = stateEntry;
commitInfoEntries.push({
remoteUrl: repoResult.remoteUrl,
localPath: repoResult.targetDir,
branch: repoResult.branch,
codeCommitId: repoResult.currentCommit,
analyzedCommitId,
status: analyzeStatus,
analyzeConsecutiveFailures,
analyzeFailureThreshold: config.analyzeFailureThreshold,
lastAnalyzeError,
lastSyncTime: repoResult.lastSyncTime,
});
if (repoResult.project.groupName) {
let groupMembershipOk = false;
let membershipAdded = false;
try {
membershipAdded = await deps.addRepoToGroup(
repoResult.project,
getAutoSyncRepoIdentity(repoResult.remoteUrl),
getAutoSyncRepoIdentity(repoResult.remoteUrl),
);
groupMembershipOk = true;
} catch (err: unknown) {
result.failed += 1;
logger.error(
`[auto-sync] Group update failed for ${repoResult.project.groupName}: ${(err as Error).message}`,
);
}
if (
groupMembershipOk &&
(analyzeStatus === 'success' ||
(membershipAdded && analyzeStatus === 'skipped') ||
(analyzeStatus === 'skipped' && repoResult.groupSyncPending))
) {
const groupName = repoResult.project.groupName;
groupsToSync.add(groupName);
const keys = groupStateKeys.get(groupName) ?? [];
keys.push(repoResult.stateKey);
groupStateKeys.set(groupName, keys);
}
}
}
await deps.saveState(state);
await deps.writeCommitInfo(commitInfoEntries);
let groupStateChanged = false;
for (const groupName of groupsToSync) {
try {
await deps.syncGroupByName(groupName);
for (const stateKey of groupStateKeys.get(groupName) ?? []) {
if (state[stateKey].groupSyncPending) {
state[stateKey].groupSyncPending = false;
groupStateChanged = true;
}
}
} catch (err: unknown) {
result.failed += 1;
for (const stateKey of groupStateKeys.get(groupName) ?? []) {
if (!state[stateKey].groupSyncPending) {
state[stateKey].groupSyncPending = true;
groupStateChanged = true;
}
}
logger.error(`[auto-sync] Group sync failed for ${groupName}: ${(err as Error).message}`);
}
}
if (groupStateChanged) await deps.saveState(state);
return result;
}
function shortErrorMessage(err: unknown): string {
const message = err instanceof Error ? err.message : String(err);
return message.replace(/\s+/g, ' ').slice(0, 240);
}
export function getConfiguredRepoPath(
project: Pick<AutoSyncProjectConfig, 'localPath'>,
repoName: string,
remoteUrl?: string,
): string {
if (!remoteUrl) return path.resolve(project.localPath, repoName);
const identity = getAutoSyncRepoIdentity(remoteUrl);
return path.resolve(project.localPath, ...identity.split('/').slice(0, -1), repoName);
}
export async function addRepoToGroup(
project: Pick<AutoSyncProjectConfig, 'groupName'>,
groupPath: string,
registryName = groupPath,
): Promise<boolean> {
if (!project.groupName) return false;
const groupDir = getGroupDir(getDefaultGitnexusDir(), project.groupName);
const config = await loadGroupConfig(groupDir);
if (config.repos[groupPath] === registryName) return false;
if (config.repos[groupPath] !== undefined) {
throw new Error(`group path ${groupPath} is already mapped to ${config.repos[groupPath]}`);
}
config.repos[groupPath] = registryName;
await writeGroupConfigAtomic(path.join(groupDir, 'group.yaml'), config);
return true;
}
export function getAutoSyncRepoIdentity(remoteUrl: string): string {
validateAutoSyncRemoteUrl(remoteUrl);
const [, host, remotePath] = /^git@([^:\s/]+):([^\s]+)$/.exec(remoteUrl.trim())!;
return `${host.toLowerCase()}/${remotePath.replace(/\.git$/i, '')}`;
}
export async function syncGroupByName(groupName: string): Promise<void> {
const groupDir = getGroupDir(getDefaultGitnexusDir(), groupName);
const config = await loadGroupConfig(groupDir);
await syncGroup(config, { groupDir });
}
async function writeGroupConfigAtomic(filePath: string, config: unknown): Promise<void> {
const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
await fs.writeFile(tmpPath, yaml.dump(config), 'utf-8');
await fs.rename(tmpPath, filePath);
}
export function resolveActualConcurrency(configured: number, availableMemoryGB: number): number {
const memoryLimit = Math.max(1, Math.floor(availableMemoryGB / 2));
return Math.max(1, Math.min(configured, memoryLimit));
}
async function buildWorkItems(
config: AutoSyncConfig,
deps: AutoSyncRunDeps,
): Promise<AutoSyncWorkItem[]> {
const items: AutoSyncWorkItem[] = [];
const targetOwners = new Map<string, string>();
for (const project of config.projects) {
let cloneRoot: AutoSyncWorkItem['cloneRoot'];
try {
cloneRoot = await deps.resolveCloneRoot(project.localPath);
} catch (err: unknown) {
for (const remoteUrl of project.remoteUrls) {
items.push({ project, remoteUrl, error: shortErrorMessage(err) });
}
continue;
}
for (const remoteUrl of project.remoteUrls) {
try {
const repoName = extractRepoNameFromRemoteUrl(remoteUrl);
const targetDir = getConfiguredRepoPath({ localPath: cloneRoot.root }, repoName, remoteUrl);
const previous = targetOwners.get(targetDir);
if (previous !== undefined) {
throw new Error(
`Duplicate auto-sync targetDir ${targetDir} for ${previous} and ${remoteUrl}`,
);
}
targetOwners.set(targetDir, remoteUrl);
items.push({ project, remoteUrl, cloneRoot, repoName, targetDir });
} catch (err: unknown) {
items.push({ project, remoteUrl, error: shortErrorMessage(err) });
}
}
}
return items;
}
async function mapWithConcurrency<T, R>(
items: T[],
concurrency: number,
signal: AbortSignal | undefined,
worker: (item: T) => Promise<R>,
): Promise<R[]> {
const results: R[] = new Array(items.length);
let nextIndex = 0;
const runners = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
while (nextIndex < items.length) {
throwIfAborted(signal);
const currentIndex = nextIndex;
nextIndex += 1;
results[currentIndex] = await worker(items[currentIndex]);
throwIfAborted(signal);
}
});
// Settle every runner before surfacing a failure. Promise.all rejects on the
// first error while siblings are still inside a clone or waiting on an
// analyze fork, and the caller treats that rejection as "the run is over" —
// it releases the watch mutex and exits, orphaning those children. Each
// runner already refuses new work at the abort check above, so waiting here
// costs nothing on the cancel path.
const settlements = await Promise.allSettled(runners);
const failure = settlements.find((s) => s.status === 'rejected');
if (failure) throw (failure as PromiseRejectedResult).reason;
return results;
}
function throwIfAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted) throw new Error('Auto-sync run cancelled.');
}
interface AutoSyncWorkItem {
project: AutoSyncProjectConfig;
remoteUrl: string;
cloneRoot?: Awaited<ReturnType<typeof resolveConfiguredCloneRoot>>;
repoName?: string;
targetDir?: string;
error?: string;
}
async function syncFirstAvailableBranch(input: {
item: AutoSyncWorkItem;
repoName: string;
targetDir: string;
timeoutMs: number;
deps: AutoSyncRunDeps;
logger: AutoSyncLogger;
}): Promise<
| { ok: true; branch: string }
| { ok: false; status: 'branch_unavailable' | 'sync_timeout'; message: string }
> {
const failures: string[] = [];
let sawTimeout = false;
for (const branch of input.item.project.branches) {
try {
await input.deps.cloneOrPull(input.item.remoteUrl, input.targetDir, undefined, {
allowedCloneRoot: input.item.cloneRoot!.root,
expectedRepoName: input.repoName,
quarantineRoot: input.item.cloneRoot!.quarantineRoot,
allowAutoSyncSsh: true,
timeoutMs: input.timeoutMs,
branch,
overwriteLocalChanges: input.item.project.overwriteLocalChanges,
});
const currentBranch = await input.deps.getCurrentBranch(input.targetDir, input.timeoutMs);
if (currentBranch === branch) return { ok: true, branch };
failures.push(`${branch}: checked out ${currentBranch ?? '<detached>'}`);
input.logger.warn(
`[auto-sync] Branch ${branch} for ${input.item.remoteUrl} synced but current branch is ${currentBranch ?? '<detached>'}; trying next branch.`,
);
} catch (err: unknown) {
const message = (err as Error).message;
if (message.includes('timed out')) sawTimeout = true;
failures.push(`${branch}: ${message}`);
input.logger.warn(
`[auto-sync] Branch ${branch} unavailable for ${input.item.remoteUrl}: ${message}`,
);
}
}
return {
ok: false,
status: sawTimeout ? 'sync_timeout' : 'branch_unavailable',
message: failures.join('; '),
};
}

View file

@ -0,0 +1,643 @@
import fs from 'node:fs/promises';
import crypto from 'node:crypto';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { acquireFileLock, FileLockBusyError } from '../../storage/file-lock.js';
import { getGlobalDir } from '../../storage/repo-manager.js';
import { isProcessAlive, readProcessStartTime } from '../../utils/process-identity.js';
import { loadAutoSyncConfig } from './config.js';
import { runAutoSyncOnce } from './runner.js';
import { getAutoSyncMutexPath, getAutoSyncWatchDir } from './state.js';
export interface AutoSyncStartHandle {
stop(): Promise<void>;
}
export type WatchStatusState =
| 'running'
| 'cancelling'
| 'stopping'
| 'stopped'
| 'stale'
| 'error';
export type AutoSyncWatchStopResult = 'stopped' | 'not_running' | 'refused' | 'timeout';
export interface WatchStatusRecord {
state: WatchStatusState;
pid?: number;
ownerId?: string;
configPath?: string;
message?: string;
updatedAt: string;
}
export interface WatchOwnerRecord {
pid: number;
ownerId: string;
processStartTime: string;
createdAt: string;
}
interface WatchStopRequestRecord {
pid: number;
ownerId: string;
processStartTime: string;
requestedAt: string;
}
const WATCH_STOP_POLL_MS = 250;
export interface AutoSyncWatchPaths {
pidPath: string;
mutexPath: string;
ownerPath: string;
statusPath: string;
}
export interface AutoSyncWatchControlDeps {
isProcessAlive(pid: number): boolean;
readProcessCommand(pid: number): string | undefined;
readProcessStartTime(pid: number): string | undefined;
sleep(ms: number): Promise<void>;
}
export function getAutoSyncWatchPaths(gitnexusDir = getGlobalDir()): AutoSyncWatchPaths {
const watchDir = getAutoSyncWatchDir(gitnexusDir);
return {
pidPath: path.join(watchDir, 'watch.pid'),
mutexPath: getAutoSyncMutexPath(gitnexusDir),
ownerPath: path.join(watchDir, 'watch.owner.json'),
statusPath: path.join(watchDir, 'watch.status.json'),
};
}
export async function startAutoSyncWatch(
options: {
setIntervalFn?: typeof setInterval;
clearIntervalFn?: typeof clearInterval;
runOnce?: typeof runAutoSyncOnce;
stderr?: Pick<NodeJS.WriteStream, 'write'>;
keepAlive?: boolean;
paths?: AutoSyncWatchPaths;
deps?: Partial<AutoSyncWatchControlDeps>;
} = {},
): Promise<AutoSyncStartHandle | null> {
const stderr = options.stderr ?? process.stderr;
const paths = options.paths ?? getAutoSyncWatchPaths();
const deps = resolveWatchDeps(options.deps);
const ownerId = crypto.randomUUID();
const processStartTime = deps.readProcessStartTime(process.pid);
if (!processStartTime) {
stderr.write('[auto-sync] Unable to verify the watch process start time.\n');
return null;
}
await fs.mkdir(path.dirname(paths.pidPath), { recursive: true });
const releaseLock = await acquireWatchLock(paths, deps, stderr, processStartTime);
if (!releaseLock) return null;
try {
await writeWatchOwner(paths, {
pid: process.pid,
ownerId,
processStartTime,
createdAt: new Date().toISOString(),
});
await writeAtomicText(paths.pidPath, `${process.pid}\n`);
const loaded = await loadAutoSyncConfig();
if (loaded.ok === false) {
stderr.write(`${loaded.message}\n`);
await writeWatchStatus(paths, {
state: 'error',
pid: process.pid,
ownerId,
message: loaded.message,
updatedAt: new Date().toISOString(),
});
await cleanupWatchFiles(paths, ownerId, releaseLock);
return null;
}
await writeWatchStatus(paths, {
state: 'running',
pid: process.pid,
ownerId,
configPath: loaded.config.configPath,
updatedAt: new Date().toISOString(),
});
const runOnce = options.runOnce ?? runAutoSyncOnce;
const setIntervalFn = options.setIntervalFn ?? setInterval;
const clearIntervalFn = options.clearIntervalFn ?? clearInterval;
let activeRun: Promise<void> | undefined;
let activeAbortController: AbortController | undefined;
let stopping = false;
let statusWrite = Promise.resolve();
const updateStatus = (state: WatchStatusState, message?: string) => {
const write = statusWrite.then(() =>
writeWatchStatus(paths, {
state,
pid: process.pid,
ownerId,
configPath: loaded.config.configPath,
message,
updatedAt: new Date().toISOString(),
}),
);
statusWrite = write.catch(() => {});
return write;
};
const reportStatusWriteFailure = (error: unknown) => {
stderr.write(`[auto-sync] Failed to publish watch status: ${(error as Error).message}\n`);
};
const runSafely = () => {
if (stopping) return;
if (activeRun) {
stderr.write('[auto-sync] Previous run is still active; skipping overlapping run.\n');
return;
}
const startedAt = new Date();
stderr.write(`[auto-sync] Watch loop started at ${startedAt.toISOString()}.\n`);
const abortController = new AbortController();
const run = runOnce(loaded.config, {
signal: abortController.signal,
onAnalysisCancellationRequested: () => {
if (!stopping) {
void updateStatus(
'cancelling',
'Analysis cancellation requested; waiting for the worker to reach a safe shutdown point.',
).catch(reportStatusWriteFailure);
}
},
})
.then((result) => {
stderr.write(
`[auto-sync] Watch loop finished: synced=${result.synced} analyzed=${result.analyzed} skipped=${result.skippedAnalysis} failed=${result.failed}.\n`,
);
})
.catch((err: unknown) => {
stderr.write(`[auto-sync] Scheduled run failed: ${(err as Error).message}\n`);
stderr.write('[auto-sync] Watch loop finished: failed.\n');
})
.finally(async () => {
if (activeRun === run) {
activeRun = undefined;
activeAbortController = undefined;
}
if (!stopping) {
await updateStatus('running').catch(reportStatusWriteFailure);
}
});
activeRun = run;
activeAbortController = abortController;
};
let stopPromise: Promise<void> | undefined;
const stop = () =>
(stopPromise ??= (async () => {
stopping = true;
clearIntervalFn(timer);
clearIntervalFn(controlTimer);
activeAbortController?.abort();
try {
await updateStatus('stopping');
await activeRun?.catch(() => {});
await updateStatus('stopped');
} finally {
await cleanupWatchFiles(paths, ownerId, releaseLock);
}
})());
const checkStopRequest = async () => {
const request = await readStopRequest(stopRequestPath(paths, ownerId));
if (
request?.pid === process.pid &&
request.ownerId === ownerId &&
request.processStartTime === processStartTime
) {
void stop().catch((error: unknown) => {
stderr.write(`[auto-sync] Failed to stop watch: ${(error as Error).message}\n`);
});
}
};
runSafely();
const controlTimer = setIntervalFn(() => void checkStopRequest(), WATCH_STOP_POLL_MS);
const timer = setIntervalFn(runSafely, loaded.config.syncIntervalMinutes * 60_000);
if (options.keepAlive === false) {
controlTimer.unref?.();
timer.unref?.();
}
return { stop };
} catch (error) {
await cleanupWatchFiles(paths, ownerId, releaseLock).catch(() => {});
throw error;
}
}
async function acquireWatchLock(
paths: AutoSyncWatchPaths,
deps: AutoSyncWatchControlDeps,
stderr: Pick<NodeJS.WriteStream, 'write'>,
processStartTime: string,
): Promise<(() => Promise<void>) | null> {
try {
return await acquireFileLock(paths.mutexPath, {
pid: process.pid,
processStartTime,
isProcessAlive: deps.isProcessAlive,
readProcessStartTime: deps.readProcessStartTime,
});
} catch (err: unknown) {
if (!(err instanceof FileLockBusyError)) throw err;
}
const owner = await readOwnerFile(paths.ownerPath);
if (!owner) {
stderr.write(
`[auto-sync] Watch mutex is held but owner metadata is not ready or invalid. Confirm no watch process is running, then remove ${paths.mutexPath}.\n`,
);
return null;
}
if (!deps.isProcessAlive(owner.pid)) {
stderr.write(
`[auto-sync] Watch mutex remains after owner pid ${owner.pid} exited. Confirm no watch process is running, then remove ${paths.mutexPath}.\n`,
);
return null;
}
const reason = getWatchProcessIdentityError(owner, deps);
if (reason) {
stderr.write(`[auto-sync] Refusing to trust existing watch pid ${owner.pid}; ${reason}.\n`);
return null;
}
stderr.write(`[auto-sync] Watch is already running with pid ${owner.pid}.\n`);
return null;
}
export async function stopAutoSyncWatch(
options: {
paths?: AutoSyncWatchPaths;
stderr?: Pick<NodeJS.WriteStream, 'write'>;
deps?: Partial<AutoSyncWatchControlDeps>;
timeoutMs?: number;
pollMs?: number;
} = {},
): Promise<AutoSyncWatchStopResult> {
const stderr = options.stderr ?? process.stderr;
const paths = options.paths ?? getAutoSyncWatchPaths();
const deps = resolveWatchDeps(options.deps);
const timeoutMs = options.timeoutMs ?? 10_000;
const pollMs = options.pollMs ?? 100;
const pid = await readPid(paths.pidPath);
if (!pid) {
const owner = await readOwnerFile(paths.ownerPath);
if (owner && deps.isProcessAlive(owner.pid)) {
stderr.write(
`[auto-sync] Watch appears to be starting with pid ${owner.pid}; pid file is not ready.\n`,
);
return 'refused';
}
if (owner || (await fileExists(paths.mutexPath))) {
stderr.write(
`[auto-sync] Watch ownership is stale or incomplete. Confirm no watch process is running, then remove ${paths.mutexPath}.\n`,
);
return 'refused';
}
stderr.write('[auto-sync] Watch is not running.\n');
return 'not_running';
}
if (!deps.isProcessAlive(pid)) {
stderr.write(
`[auto-sync] Watch pid ${pid} is stale. Confirm no watch process is running, then remove ${paths.mutexPath}.\n`,
);
return 'refused';
}
const owner = await readVerifiedWatchOwner(paths, pid, deps);
if (owner.ok === false) {
stderr.write(`[auto-sync] Refusing to stop pid ${pid}; ${owner.reason}.\n`);
return 'refused';
}
const currentPid = await readPid(paths.pidPath);
const currentOwner = await readVerifiedWatchOwner(paths, pid, deps);
if (
currentPid !== pid ||
currentOwner.ok === false ||
currentOwner.owner.ownerId !== owner.owner.ownerId
) {
stderr.write(`[auto-sync] Refusing to stop pid ${pid}; watch ownership changed.\n`);
return 'refused';
}
await writeAtomicText(
stopRequestPath(paths, owner.owner.ownerId),
`${JSON.stringify({
pid,
ownerId: owner.owner.ownerId,
processStartTime: owner.owner.processStartTime,
requestedAt: new Date().toISOString(),
} satisfies WatchStopRequestRecord)}\n`,
);
stderr.write(`[auto-sync] Stop requested for watch pid ${pid}.\n`);
const stopped = await waitForProcessExit(pid, {
deps,
timeoutMs,
pollMs,
processStartTime: owner.owner.processStartTime,
});
if (!stopped) {
stderr.write(`[auto-sync] Watch pid ${pid} did not exit within ${timeoutMs}ms.\n`);
return 'timeout';
}
return 'stopped';
}
export async function readAutoSyncWatchStatus(
paths = getAutoSyncWatchPaths(),
deps: Partial<AutoSyncWatchControlDeps> = {},
): Promise<WatchStatusRecord> {
const resolvedDeps = resolveWatchDeps(deps);
const pid = await readPid(paths.pidPath);
const stored = await readStatusFile(paths.statusPath);
const updatedAt = stored?.updatedAt ?? new Date().toISOString();
if (pid && !resolvedDeps.isProcessAlive(pid)) {
return {
...stored,
state: 'stale',
pid,
message: 'pid file exists but process is not running',
updatedAt,
};
}
if (pid) {
const owner = await readVerifiedWatchOwner(paths, pid, resolvedDeps);
if (owner.ok === false) {
return {
...stored,
state: 'error',
pid,
message: owner.reason,
updatedAt,
};
}
if (stored?.state === 'error') {
return {
...stored,
pid,
ownerId: owner.owner.ownerId,
updatedAt,
};
}
return {
...stored,
state:
stored?.state === 'cancelling' || stored?.state === 'stopping' ? stored.state : 'running',
pid,
ownerId: owner.owner.ownerId,
updatedAt,
};
}
return stored ?? { state: 'stopped', updatedAt };
}
function isSafeWatchOwnerId(ownerId: string): boolean {
return (
ownerId === path.basename(ownerId) &&
!ownerId.includes('..') &&
!ownerId.includes('/') &&
!ownerId.includes('\\')
);
}
async function readOwnerFile(ownerPath: string): Promise<WatchOwnerRecord | undefined> {
try {
const raw = await fs.readFile(ownerPath, 'utf-8');
const parsed = JSON.parse(raw) as WatchOwnerRecord;
if (
parsed &&
typeof parsed === 'object' &&
Number.isInteger(parsed.pid) &&
parsed.pid > 0 &&
typeof parsed.ownerId === 'string' &&
parsed.ownerId &&
isSafeWatchOwnerId(parsed.ownerId) &&
typeof parsed.processStartTime === 'string' &&
parsed.processStartTime
) {
return parsed;
}
return undefined;
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
return undefined;
}
}
async function readVerifiedWatchOwner(
paths: AutoSyncWatchPaths,
pid: number,
deps: AutoSyncWatchControlDeps,
): Promise<{ ok: true; owner: WatchOwnerRecord } | { ok: false; reason: string }> {
const [status, owner] = await Promise.all([
readStatusFile(paths.statusPath),
readOwnerFile(paths.ownerPath),
]);
if (!owner) return { ok: false, reason: 'watch owner is missing or invalid' };
if (!status) return { ok: false, reason: 'watch status is missing or invalid' };
if (owner.pid !== pid) return { ok: false, reason: 'watch owner pid does not match pid file' };
if (status.pid !== pid) return { ok: false, reason: 'watch status pid does not match pid file' };
if (!status.ownerId || status.ownerId !== owner.ownerId) {
return { ok: false, reason: 'watch status owner does not match watch owner' };
}
const identityError = getWatchProcessIdentityError(owner, deps);
if (identityError) return { ok: false, reason: identityError };
return { ok: true, owner };
}
function getWatchProcessIdentityError(
owner: WatchOwnerRecord,
deps: AutoSyncWatchControlDeps,
): string | undefined {
const processStartTime = deps.readProcessStartTime(owner.pid);
if (!processStartTime) return 'unable to verify process start time';
if (processStartTime !== owner.processStartTime) return 'pid belongs to a different process';
const command = deps.readProcessCommand(owner.pid);
if (!command) return 'unable to verify process command';
if (
!/(?:^|\s)(?:watch|auto-sync)(?:\s|$)/.test(command) ||
!/(?:gitnexus|[\\/]cli[\\/]index\.(?:ts|[cm]?js))/.test(command)
) {
return 'pid command is not a GitNexus auto-sync process';
}
return undefined;
}
async function waitForProcessExit(
pid: number,
options: {
deps: AutoSyncWatchControlDeps;
timeoutMs: number;
pollMs: number;
processStartTime?: string;
},
): Promise<boolean> {
// A bare liveness poll cannot tell "still running" from "exited, and the OS
// handed the pid to something else" — so a reused pid would keep us waiting
// on an unrelated process and then report the watch stopped once THAT exits.
// The start time identifies the process behind the number.
const isOriginalProcessAlive = () => {
if (!options.deps.isProcessAlive(pid)) return false;
if (!options.processStartTime) return true;
const startTime = options.deps.readProcessStartTime(pid);
return startTime === undefined || startTime === options.processStartTime;
};
const deadline = Date.now() + options.timeoutMs;
while (Date.now() < deadline) {
if (!isOriginalProcessAlive()) return true;
await options.deps.sleep(options.pollMs);
}
return !isOriginalProcessAlive();
}
async function readPid(pidPath: string): Promise<number | undefined> {
try {
const raw = await fs.readFile(pidPath, 'utf-8');
const pid = Number(raw.trim());
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
throw err;
}
}
async function readStatusFile(statusPath: string): Promise<WatchStatusRecord | undefined> {
try {
const parsed = JSON.parse(await fs.readFile(statusPath, 'utf-8')) as WatchStatusRecord;
return parsed && typeof parsed === 'object' ? parsed : undefined;
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
return {
state: 'error',
message: `unable to read status file: ${(err as Error).message}`,
updatedAt: new Date().toISOString(),
};
}
}
function stopRequestPath(paths: AutoSyncWatchPaths, ownerId: string): string {
if (!isSafeWatchOwnerId(ownerId)) {
throw new Error('watch ownerId is not a safe filename component');
}
return path.join(path.dirname(paths.pidPath), `watch.stop.${ownerId}.json`);
}
async function readStopRequest(filePath: string): Promise<WatchStopRequestRecord | undefined> {
try {
const parsed = JSON.parse(await fs.readFile(filePath, 'utf-8')) as WatchStopRequestRecord;
if (
parsed &&
typeof parsed === 'object' &&
Number.isInteger(parsed.pid) &&
parsed.pid > 0 &&
typeof parsed.ownerId === 'string' &&
parsed.ownerId &&
typeof parsed.processStartTime === 'string' &&
parsed.processStartTime &&
typeof parsed.requestedAt === 'string' &&
parsed.requestedAt
) {
return parsed;
}
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') return undefined;
}
return undefined;
}
async function writeWatchStatus(
paths: AutoSyncWatchPaths,
record: WatchStatusRecord,
): Promise<void> {
await fs.mkdir(path.dirname(paths.statusPath), { recursive: true });
const tmpPath = `${paths.statusPath}.tmp.${process.pid}.${Date.now()}`;
await fs.writeFile(tmpPath, `${JSON.stringify(record, null, 2)}\n`, 'utf-8');
await fs.rename(tmpPath, paths.statusPath);
}
async function writeWatchOwner(paths: AutoSyncWatchPaths, record: WatchOwnerRecord): Promise<void> {
await writeAtomicText(paths.ownerPath, `${JSON.stringify(record, null, 2)}\n`);
}
async function cleanupWatchFiles(
paths: AutoSyncWatchPaths,
ownerId: string,
releaseLock: () => Promise<void>,
): Promise<void> {
try {
const owner = await readOwnerFile(paths.ownerPath);
if (owner?.ownerId === ownerId) {
if ((await readPid(paths.pidPath)) === owner.pid) await removeIfExists(paths.pidPath);
if ((await readOwnerFile(paths.ownerPath))?.ownerId === ownerId) {
await removeIfExists(paths.ownerPath);
}
await removeIfExists(stopRequestPath(paths, ownerId));
}
} finally {
await releaseLock();
}
}
async function writeAtomicText(filePath: string, content: string): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
await fs.writeFile(tmpPath, content, 'utf-8');
await fs.rename(tmpPath, filePath);
}
async function removeIfExists(filePath: string): Promise<void> {
await fs.rm(filePath, { force: true });
}
async function fileExists(filePath: string): Promise<boolean> {
return fs.access(filePath).then(
() => true,
() => false,
);
}
function resolveWatchDeps(deps: Partial<AutoSyncWatchControlDeps> = {}): AutoSyncWatchControlDeps {
return {
isProcessAlive: deps.isProcessAlive ?? isProcessAlive,
readProcessCommand:
deps.readProcessCommand ??
((pid) => {
try {
const command =
process.platform === 'win32'
? execFileSync(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-Command',
`(Get-CimInstance Win32_Process -Filter \"ProcessId = ${pid}\").CommandLine`,
],
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] },
).trim()
: execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
return command || undefined;
} catch {
return undefined;
}
}),
readProcessStartTime: deps.readProcessStartTime ?? readProcessStartTime,
sleep:
deps.sleep ??
((ms) =>
new Promise<void>((resolve) => {
setTimeout(resolve, ms);
})),
};
}

View file

@ -0,0 +1,173 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { acquireFileLock, FileLockBusyError } from '../../storage/file-lock.js';
import { getGlobalDir } from '../../storage/repo-manager.js';
export type AutoSyncAnalyzeStatus = 'success' | 'failed' | 'skipped' | 'threshold_skipped';
export interface AutoSyncCommitStateEntry {
codeCommitId: string;
analyzedCommitId?: string;
lastAnalyzeStatus?: AutoSyncAnalyzeStatus;
analyzeConsecutiveFailures?: number;
lastAnalyzeError?: string;
groupSyncPending?: boolean;
lastSyncTime: string;
}
export type AutoSyncCommitState = Record<string, AutoSyncCommitStateEntry>;
export function getAutoSyncWatchDir(gitnexusDir = getGlobalDir()): string {
return path.join(gitnexusDir, 'watch');
}
export function getAutoSyncMutexPath(gitnexusDir = getGlobalDir()): string {
return path.join(getAutoSyncWatchDir(gitnexusDir), 'watch.mutex');
}
export function getAutoSyncStatePath(gitnexusDir = getGlobalDir()): string {
return path.join(getAutoSyncWatchDir(gitnexusDir), 'auto-sync-state.json');
}
export function getProjectCommitInfoPath(gitnexusDir = getGlobalDir()): string {
return path.join(getAutoSyncWatchDir(gitnexusDir), 'project_commit_info.txt');
}
export async function resetAutoSyncState(gitnexusDir = getGlobalDir()): Promise<boolean> {
let releaseLock: () => Promise<void>;
try {
releaseLock = await acquireFileLock(getAutoSyncMutexPath(gitnexusDir));
} catch (error) {
if (error instanceof FileLockBusyError) return false;
throw error;
}
try {
await Promise.all([
fs.rm(getAutoSyncStatePath(gitnexusDir), { force: true }),
fs.rm(getProjectCommitInfoPath(gitnexusDir), { force: true }),
]);
return true;
} finally {
await releaseLock();
}
}
export function buildStateKey(repoPath: string, branch: string): string {
return `${path.resolve(repoPath)}|${branch}`;
}
export function shouldAnalyzeCommit(input: {
currentCommit: string;
previousAnalyzedCommit?: string;
previousStatus?: AutoSyncAnalyzeStatus;
}): boolean {
if (!input.currentCommit) return false;
if (input.previousStatus === 'failed') return true;
return input.currentCommit !== input.previousAnalyzedCommit;
}
export async function loadAutoSyncState(
statePath = getAutoSyncStatePath(),
): Promise<AutoSyncCommitState> {
try {
const raw = await fs.readFile(statePath, 'utf-8');
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
return Object.fromEntries(
Object.entries(parsed).filter((entry): entry is [string, AutoSyncCommitStateEntry] =>
isAutoSyncCommitStateEntry(entry[1]),
),
);
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {};
// Corrupt JSON is genuinely unrecoverable, so rebuilding is the only move.
// An unreadable file (EACCES, EIO, EISDIR) is different: the state is
// probably intact, and returning {} here would make the tick overwrite it,
// losing every repo's analyzed commit and failure count.
if (!(err instanceof SyntaxError)) throw err;
process.stderr.write(
`[auto-sync] Ignoring corrupt state file: ${statePath}. State will be rebuilt.\n`,
);
return {};
}
}
function isAutoSyncCommitStateEntry(value: unknown): value is AutoSyncCommitStateEntry {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const entry = value as Record<string, unknown>;
return (
typeof entry.codeCommitId === 'string' &&
typeof entry.lastSyncTime === 'string' &&
(entry.analyzedCommitId === undefined || typeof entry.analyzedCommitId === 'string') &&
(entry.lastAnalyzeStatus === undefined ||
entry.lastAnalyzeStatus === 'success' ||
entry.lastAnalyzeStatus === 'failed' ||
entry.lastAnalyzeStatus === 'skipped' ||
entry.lastAnalyzeStatus === 'threshold_skipped') &&
(entry.analyzeConsecutiveFailures === undefined ||
(typeof entry.analyzeConsecutiveFailures === 'number' &&
Number.isInteger(entry.analyzeConsecutiveFailures) &&
entry.analyzeConsecutiveFailures >= 0)) &&
(entry.lastAnalyzeError === undefined || typeof entry.lastAnalyzeError === 'string') &&
(entry.groupSyncPending === undefined || typeof entry.groupSyncPending === 'boolean')
);
}
export async function saveAutoSyncState(
state: AutoSyncCommitState,
statePath = getAutoSyncStatePath(),
): Promise<void> {
await fs.mkdir(path.dirname(statePath), { recursive: true });
const tmpPath = `${statePath}.tmp.${process.pid}.${Date.now()}`;
await fs.writeFile(tmpPath, `${JSON.stringify(state, null, 2)}\n`, 'utf-8');
await fs.rename(tmpPath, statePath);
}
export async function writeProjectCommitInfo(
entries: ProjectCommitInfoEntry[],
infoPath = getProjectCommitInfoPath(),
): Promise<void> {
await fs.mkdir(path.dirname(infoPath), { recursive: true });
const lines = [
'# GitNexus auto-sync project commit info',
`updated_at: ${new Date().toISOString()}`,
'',
...entries.flatMap((entry) => [
`remote: ${entry.remoteUrl}`,
`local_path: ${entry.localPath}`,
`branch: ${entry.branch ?? ''}`,
`code_commit: ${entry.codeCommitId ?? ''}`,
`analyzed_commit: ${entry.analyzedCommitId ?? ''}`,
`status: ${entry.status}`,
`analyze_consecutive_failures: ${entry.analyzeConsecutiveFailures ?? 0}`,
...(entry.analyzeFailureThreshold === undefined
? []
: [`analyze_failure_threshold: ${entry.analyzeFailureThreshold}`]),
...(entry.lastAnalyzeError ? [`last_analyze_error: ${entry.lastAnalyzeError}`] : []),
`last_sync_time: ${entry.lastSyncTime}`,
'',
]),
];
const tmpPath = `${infoPath}.tmp.${process.pid}.${Date.now()}`;
await fs.writeFile(tmpPath, `${lines.join('\n')}\n`, 'utf-8');
await fs.rename(tmpPath, infoPath);
}
export interface ProjectCommitInfoEntry {
remoteUrl: string;
localPath: string;
branch?: string;
codeCommitId?: string;
analyzedCommitId?: string;
status:
| AutoSyncAnalyzeStatus
| 'sync_failed'
| 'branch_skipped'
| 'branch_unavailable'
| 'sync_timeout';
analyzeConsecutiveFailures?: number;
analyzeFailureThreshold?: number;
lastAnalyzeError?: string;
lastSyncTime: string;
}

View file

@ -11,6 +11,7 @@ import {
intersectSpringHttpMethods,
isRouteMemberKey,
findEnclosingClass,
isClassLevelMappingAnnotation,
joinPath,
type SharedSpringType,
} from '../../../ingestion/route-extractors/spring-shared.js';
@ -467,13 +468,15 @@ function annotationHasRouteMember(annotation: Parser.SyntaxNode): boolean {
}
function typeRequestMethods(typeNode: Parser.SyntaxNode): readonly string[] {
const mappings = declarationAnnotations(typeNode).filter(
(annotation) =>
simpleName(annotation.childForFieldName('name')?.text ?? '') === 'RequestMapping',
const mappings = declarationAnnotations(typeNode).filter((annotation) =>
isClassLevelMappingAnnotation(simpleName(annotation.childForFieldName('name')?.text ?? '')),
);
if (mappings.length === 0) return ['*'];
if (mappings.length !== 1) return [];
return springAnnotationHttpMethods('RequestMapping', mappings[0].text);
return springAnnotationHttpMethods(
simpleName(mappings[0].childForFieldName('name')?.text ?? 'RequestMapping'),
mappings[0].text,
);
}
function hasAnnotation(node: Parser.SyntaxNode, names: string | readonly string[]): boolean {
@ -675,7 +678,7 @@ function scanRouteAnnotations(tree: Parser.Tree): RouteAnnotationScan {
// Type-level (class or interface): a Spring `@RequestMapping` URL prefix, or
// — on an interface — an OpenFeign `@FeignClient(path = "...")` prefix.
if (ann === 'RequestMapping') {
if (isClassLevelMappingAnnotation(ann)) {
if (!isRouteMemberKey(keyNode)) continue;
if (!valueNode) {
// Constant-valued class prefix — see `typesWithUnfoldablePrefix`.

View file

@ -13,9 +13,11 @@ import type {
HttpScanInput,
} from './types.js';
import {
METHOD_ANNOTATION_TO_HTTP,
findEnclosingClass,
intersectSpringHttpMethods,
isClassLevelMappingAnnotation,
joinPath,
springAnnotationHttpMethods,
type SharedSpringType,
} from '../../../ingestion/route-extractors/spring-shared.js';
import {
@ -448,6 +450,13 @@ function inferKotlinOkHttpMethod(urlCall: Parser.SyntaxNode): string | null {
return name === null ? 'GET' : name.toUpperCase();
}
function enclosingAnnotationText(node: Parser.SyntaxNode): string {
for (let current: Parser.SyntaxNode | null = node; current; current = current.parent) {
if (current.type === 'annotation') return current.text;
}
return node.text;
}
/**
* Build the plugin only if the Kotlin grammar is available. Compiling
* the queries against a null grammar would throw at module load time
@ -485,7 +494,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
(modifiers
(annotation
(constructor_invocation
(user_type (type_identifier) @ann (#eq? @ann "RequestMapping"))
(user_type (type_identifier) @ann (#match? @ann "RequestMapping$"))
(value_arguments
(value_argument . [(string_literal) @prefix (collection_literal (string_literal) @prefix)])))))
(type_identifier) @cls) @class
@ -498,7 +507,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
(modifiers
(annotation
(constructor_invocation
(user_type (type_identifier) @ann (#eq? @ann "RequestMapping"))
(user_type (type_identifier) @ann (#match? @ann "RequestMapping$"))
(value_arguments
(value_argument
(simple_identifier) @key (#match? @key "^(path|value)$")
@ -513,7 +522,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
(modifiers
(annotation
(constructor_invocation
(user_type (type_identifier) @ann (#eq? @ann "RequestMapping"))
(user_type (type_identifier) @ann (#match? @ann "RequestMapping$"))
(value_arguments
(value_argument . ${arrayOfArg('@prefix')})))))
(type_identifier) @cls) @class
@ -526,7 +535,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
(modifiers
(annotation
(constructor_invocation
(user_type (type_identifier) @ann (#eq? @ann "RequestMapping"))
(user_type (type_identifier) @ann (#match? @ann "RequestMapping$"))
(value_arguments
(value_argument
(simple_identifier) @key (#match? @key "^(path|value)$")
@ -552,7 +561,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
(modifiers
(annotation
(constructor_invocation
(user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$"))
(user_type (type_identifier) @ann (#match? @ann "(Request|Get|Post|Put|Delete|Patch)Mapping$"))
(value_arguments
(value_argument . [(string_literal) @path (collection_literal (string_literal) @path)])))))
(simple_identifier) @method_name) @method
@ -565,7 +574,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
(modifiers
(annotation
(constructor_invocation
(user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$"))
(user_type (type_identifier) @ann (#match? @ann "(Request|Get|Post|Put|Delete|Patch)Mapping$"))
(value_arguments
(value_argument
(simple_identifier) @key (#match? @key "^(path|value)$")
@ -580,7 +589,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
(modifiers
(annotation
(constructor_invocation
(user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$"))
(user_type (type_identifier) @ann (#match? @ann "(Request|Get|Post|Put|Delete|Patch)Mapping$"))
(value_arguments
(value_argument . ${arrayOfArg('@path')})))))
(simple_identifier) @method_name) @method
@ -593,7 +602,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
(modifiers
(annotation
(constructor_invocation
(user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$"))
(user_type (type_identifier) @ann (#match? @ann "(Request|Get|Post|Put|Delete|Patch)Mapping$"))
(value_arguments
(value_argument
(simple_identifier) @key (#match? @key "^(path|value)$")
@ -629,7 +638,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
(modifiers
(annotation
(constructor_invocation
(user_type (type_identifier) @ann (#eq? @ann "RequestMapping"))
(user_type (type_identifier) @ann (#match? @ann "RequestMapping$"))
(value_arguments (value_argument) @arg))))
(type_identifier) @cls) @class
`,
@ -648,7 +657,7 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
(modifiers
(annotation
(constructor_invocation
(user_type (type_identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$"))
(user_type (type_identifier) @ann (#match? @ann "(Request|Get|Post|Put|Delete|Patch)Mapping$"))
(value_arguments (value_argument) @arg))))
(simple_identifier) @method_name) @method
`,
@ -713,7 +722,9 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
for (const match of runCompiledPatterns(SPRING_CONST_CLASS_PREFIX_PATTERNS, tree)) {
const argNode = match.captures.arg;
const classNode = match.captures.class;
const annNode = match.captures.ann;
if (!argNode || !classNode) continue;
if (annNode && !isClassLevelMappingAnnotation(annNode.text)) continue;
if ((resolvedPrefixes.get(classNode.id) ?? []).length > 0) continue;
const expr = kotlinRouteArgumentExpression(argNode);
if (!expr || classifyPathArgument(expr) !== 'unresolvable') continue;
@ -1218,13 +1229,36 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
const kotlinFunctionName = (fn: Parser.SyntaxNode): string | null =>
fn.namedChildren.find((c) => c.type === 'simple_identifier')?.text ?? null;
const kotlinTypeRequestMethods = (typeNode: Parser.SyntaxNode): readonly string[] => {
const modifiers = typeNode.namedChildren.find((child) => child.type === 'modifiers');
const mappings = (modifiers?.namedChildren ?? []).filter((annotation) => {
if (annotation.type !== 'annotation') return false;
return isClassLevelMappingAnnotation(kotlinAnnotationName(annotation) ?? '');
});
if (mappings.length === 0) return ['*'];
if (mappings.length !== 1) return [];
const mapping = mappings[0];
const mappingName = kotlinAnnotationName(mapping);
if (!mappingName) return [];
return springAnnotationHttpMethods(mappingName, mapping.text);
};
const kotlinClassHttpMethodsById = (tree: Parser.Tree) =>
new Map(
tree.rootNode
.descendantsOfType('class_declaration')
.map((typeNode) => [typeNode.id, kotlinTypeRequestMethods(typeNode)] as const),
);
const collectKotlinSpringTypes = (filePath: string, tree: Parser.Tree): SharedSpringType[] => {
// Class-level @RequestMapping prefixes (reuse the provider class-prefix query).
const prefixByClassId = new Map<number, string[]>();
for (const match of runCompiledPatterns(SPRING_CLASS_PREFIX_PATTERNS, tree)) {
const prefixNode = match.captures.prefix;
const classNode = match.captures.class;
const annNode = match.captures.ann;
if (!prefixNode || !classNode) continue;
if (annNode && !isClassLevelMappingAnnotation(annNode.text)) continue;
// An INTERPOLATED literal (`"${ApiPaths.BASE}"`) is not a path — unquoting
// its raw text would carry the source spelling into the shared type view
// as a served prefix. Refusing it here is also what lets the unfoldable
@ -1242,22 +1276,32 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
// noise into the shared type view, so it is left out — the same skip floor
// `java.ts`'s `collectSpringTypes` keeps.
const routesByMethodId = new Map<number, Array<{ method: string; path: string }>>();
const classHttpMethodsById = kotlinClassHttpMethodsById(tree);
const unfoldablePrefixClassIds = collectUnfoldablePrefixClassIds(tree, prefixByClassId);
for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) {
const annNode = match.captures.ann;
const pathNode = match.captures.path;
const methodNode = match.captures.method;
if (!annNode || !pathNode || !methodNode) continue;
const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text];
if (!httpMethod) continue;
const httpMethods = springAnnotationHttpMethods(
annNode.text,
enclosingAnnotationText(annNode),
);
if (httpMethods.length === 0) continue;
const rawPath = unquoteLiteral(pathNode.text);
if (rawPath === null) continue;
// A constant class prefix leaves no single prefix string for the
// inheritance view to carry, so this route would be published unprefixed.
const owner = findEnclosingClass(methodNode);
if (owner && unfoldablePrefixClassIds.has(owner.id)) continue;
const constrainedMethods = intersectSpringHttpMethods(
owner ? (classHttpMethodsById.get(owner.id) ?? ['*']) : ['*'],
httpMethods,
);
const arr = routesByMethodId.get(methodNode.id) ?? [];
arr.push({ method: httpMethod, path: rawPath });
for (const httpMethod of constrainedMethods) {
arr.push({ method: httpMethod, path: rawPath });
}
routesByMethodId.set(methodNode.id, arr);
}
@ -1390,7 +1434,9 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
for (const match of runCompiledPatterns(SPRING_CLASS_PREFIX_PATTERNS, tree)) {
const prefixNode = match.captures.prefix;
const classNode = match.captures.class;
const annNode = match.captures.ann;
if (!prefixNode || !classNode) continue;
if (annNode && !isClassLevelMappingAnnotation(annNode.text)) continue;
// An INTERPOLATED literal (`"${ApiPaths.BASE}"`) is not a path — see
// `isPlainStringLiteral`. Refusing it here also lets the unfoldable
// analysis below mark such a class, since that skips classes whose
@ -1438,29 +1484,38 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
nameNode: Parser.SyntaxNode | undefined;
methodNode: Parser.SyntaxNode;
}> = [];
const classHttpMethodsById = kotlinClassHttpMethodsById(tree);
for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) {
const annNode = match.captures.ann;
const pathNode = match.captures.path;
const methodNode = match.captures.method;
if (!annNode || !pathNode || !methodNode) continue;
const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text];
if (!httpMethod) continue;
const httpMethods = springAnnotationHttpMethods(
annNode.text,
enclosingAnnotationText(annNode),
);
if (httpMethods.length === 0) continue;
const rawPath = unquoteLiteral(pathNode.text);
if (rawPath === null) continue;
methodRoutes.push({
httpMethod,
rawPath,
nameNode: match.captures.method_name,
methodNode,
});
for (const httpMethod of httpMethods) {
methodRoutes.push({
httpMethod,
rawPath,
nameNode: match.captures.method_name,
methodNode,
});
}
}
for (const match of runCompiledPatterns(SPRING_CONST_METHOD_ROUTE_PATTERNS, tree)) {
const annNode = match.captures.ann;
const argNode = match.captures.arg;
const methodNode = match.captures.method;
if (!annNode || !argNode || !methodNode) continue;
const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text];
if (!httpMethod) continue;
const httpMethods = springAnnotationHttpMethods(
annNode.text,
enclosingAnnotationText(annNode),
);
if (httpMethods.length === 0) continue;
const expr = kotlinRouteArgumentExpression(argNode);
if (!expr || !FOLDABLE_PATH_EXPRESSIONS.has(expr.type)) continue;
// No repo context (context-less fallback scanning) means no constant map
@ -1481,15 +1536,26 @@ function buildKotlinPlugin(language: unknown): HttpLanguagePlugin {
index,
);
if (rawPath === null) continue;
methodRoutes.push({
httpMethod,
rawPath,
nameNode: match.captures.method_name,
methodNode,
});
for (const httpMethod of httpMethods) {
methodRoutes.push({
httpMethod,
rawPath,
nameNode: match.captures.method_name,
methodNode,
});
}
}
for (const { httpMethod, rawPath, nameNode, methodNode } of methodRoutes) {
const constrainedMethodRoutes = methodRoutes.flatMap((route) => {
const owner = findEnclosingClass(route.methodNode);
const classMethods = owner ? (classHttpMethodsById.get(owner.id) ?? ['*']) : ['*'];
return intersectSpringHttpMethods(classMethods, [route.httpMethod]).map((httpMethod) => ({
...route,
httpMethod,
}));
});
for (const { httpMethod, rawPath, nameNode, methodNode } of constrainedMethodRoutes) {
const enclosingClass = findEnclosingClass(methodNode);
// A @(Get|...)Mapping inside a @FeignClient interface is an OpenFeign
// consumer (a remote call), not a route this service serves.

View file

@ -13,6 +13,7 @@ import type { HttpDetection, HttpLanguagePlugin, RepoContext } from './types.js'
import { MAX_FOLD_LENGTH } from '../../../ingestion/route-extractors/constant-resolver.js';
import {
DATA_ROUTE_TABLE_SOURCE,
propertyName,
scanDataRouteTables,
} from '../../../ingestion/route-extractors/data-route-table.js';
import { extractNestRoutes } from '../../../ingestion/route-extractors/nest.js';
@ -152,6 +153,37 @@ const AXIOS_OBJECT_SPEC: PatternSpec<Record<string, never>> = {
`,
};
// ─── Consumer: wrapped client X.request({ url, method }) ────────────
// Enterprise wrapper shape: an axios instance (or a named request helper)
// re-exported under a local name — `httpClient.request({ url, method })`
// from `@winex-plugin/win-request`, `$http.request(...)`. Generic names
// like `api` need axios.create/import proof (`isHttpClientRef`); spelling
// alone is too common (graphql-request helpers, domain `api` objects).
// The member property is `request` (not an HTTP verb), so this cannot
// collide with the Express provider pattern (`router.get`) or the axios
// member form (`axios.get`). Option keys are resolved programmatically,
// same as the jQuery ajax / axios object forms.
//
// The query captures the receiver so scan can reject unrelated
// `.request({ url })` APIs (`cy.request`, `queue.request`).
const REQUEST_OBJECT_SPEC: PatternSpec<Record<string, never>> = {
meta: {},
query: `
(call_expression
function: (member_expression
object: (_) @obj
property: (property_identifier) @fn (#eq? @fn "request"))
arguments: (arguments . (object) @options))
`,
};
/**
* Receivers admitted as wrapped HTTP clients without axios.create proof.
* Spelling-only: the last identifier in `obj.text` (`this.$http` → `$http`).
* Keep this set small — every extra name is a false-positive surface.
*/
const WRAPPED_REQUEST_RECEIVERS = new Set(['httpClient', '$http']);
interface NodePatternBundle {
express: CompiledPatterns<Record<string, never>>;
fetchNoOptions: CompiledPatterns<Record<string, never>>;
@ -160,6 +192,7 @@ interface NodePatternBundle {
jqueryShorthand: CompiledPatterns<Record<string, never>>;
jqueryAjax: CompiledPatterns<Record<string, never>>;
axiosObject: CompiledPatterns<Record<string, never>>;
requestObject: CompiledPatterns<Record<string, never>>;
}
function compileBundle(language: unknown, name: string): NodePatternBundle {
@ -177,6 +210,7 @@ function compileBundle(language: unknown, name: string): NodePatternBundle {
jqueryShorthand: mk(JQUERY_SHORTHAND_SPEC, 'jquery-shorthand'),
jqueryAjax: mk(JQUERY_AJAX_SPEC, 'jquery-ajax'),
axiosObject: mk(AXIOS_OBJECT_SPEC, 'axios-object'),
requestObject: mk(REQUEST_OBJECT_SPEC, 'request-object'),
};
}
@ -190,6 +224,7 @@ const TSX_BUNDLE = compileBundle(TypeScript.tsx, 'tsx-http');
* of `keyNames`. Returns null when no matching pair is present or the
* value is not a string literal. Used by the jQuery ajax / axios object
* consumers to resolve `url` / `method` / `type` keys in any order.
* Keys use shared `propertyName` so quoted `"method"` matches `method`.
*/
function readStringProp(objectNode: Parser.SyntaxNode, keyNames: readonly string[]): string | null {
for (let i = 0; i < objectNode.namedChildCount; i++) {
@ -198,7 +233,8 @@ function readStringProp(objectNode: Parser.SyntaxNode, keyNames: readonly string
const keyNode = pair.childForFieldName('key');
const valueNode = pair.childForFieldName('value');
if (!keyNode || !valueNode) continue;
if (!keyNames.includes(keyNode.text)) continue;
const key = propertyName(keyNode);
if (key === null || !keyNames.includes(key)) continue;
if (valueNode.type !== 'string' && valueNode.type !== 'template_string') continue;
const lit = unquoteLiteral(valueNode.text);
if (lit !== null) return lit;
@ -206,6 +242,80 @@ function readStringProp(objectNode: Parser.SyntaxNode, keyNames: readonly string
return null;
}
/**
* Verb for wrapped `X.request({ url, method|type })`. Absent key → GET
* (same default as fetch-without-options / jQuery ajax). Present but not a
* string/template, supplied only via object spread, or later overwritten by
* a duplicate key / spread → `*` so matching can still link without pinning GET.
* Later properties win, matching JavaScript object-literal evaluation.
*/
function readRequestMethod(
objectNode: Parser.SyntaxNode,
keyNames: readonly string[] = ['method', 'type'],
): string {
type Verb = { kind: 'absent' } | { kind: 'literal'; value: string } | { kind: 'unknown' };
let last: Verb = { kind: 'absent' };
for (let i = 0; i < objectNode.namedChildCount; i++) {
const child = objectNode.namedChild(i);
if (!child) continue;
if (child.type === 'spread_element') {
last = { kind: 'unknown' };
continue;
}
if (
child.type === 'shorthand_property_identifier' ||
child.type === 'shorthand_property_identifier_pattern'
) {
if (keyNames.includes(child.text)) last = { kind: 'unknown' };
continue;
}
if (child.type !== 'pair') continue;
const keyNode = child.childForFieldName('key');
const valueNode = child.childForFieldName('value');
if (!keyNode) continue;
const key = propertyName(keyNode);
if (key === null || !keyNames.includes(key)) continue;
if (!valueNode || (valueNode.type !== 'string' && valueNode.type !== 'template_string')) {
last = { kind: 'unknown' };
continue;
}
const lit = unquoteLiteral(valueNode.text);
if (lit === null || lit.includes('${')) {
last = { kind: 'unknown' };
continue;
}
last = { kind: 'literal', value: lit };
}
if (last.kind === 'literal') return last.value.toUpperCase();
if (last.kind === 'unknown') return '*';
return 'GET';
}
function wrappedRequestReceiverName(receiver: string): string {
const parts = receiver.split('.');
return parts[parts.length - 1] ?? receiver;
}
/** Axios module / axios.create instance, or a registered wrapper identifier. */
function isAdmittedWrappedRequestReceiver(
receiver: string,
fileKey: string | undefined,
facts: JsRepoFacts | null,
): boolean {
if (WRAPPED_REQUEST_RECEIVERS.has(wrappedRequestReceiverName(receiver))) return true;
try {
const isModule =
facts === null || fileKey === undefined
? receiver === 'axios'
: isAxiosNamespace(fileKey, receiver, facts);
if (isModule) return true;
if (!facts || fileKey === undefined) return false;
return isHttpClientRef(fileKey, receiver, facts);
} catch {
return false;
}
}
/**
* Map each named import's LOCAL binding to its DECLARED export name and source
* module, by walking the file's `import { x as y } from 'm'` statements. Lets
@ -390,9 +500,10 @@ function resolveFactsFor(
*
* `/{param}` matches every one-segment provider route in the group, and
* `matching.exclude_links_param_only_paths` defaults to `false`. A path whose
* leading term is an unresolved placeholder is refused for the same reason —
* nothing pins where it starts. (`resolveJsPathExpression` already refuses those
* it folded itself; this also covers the literal fallback below.)
* leading term is an unresolved placeholder is refused unless the next
* character is `/` — that is the gateway-prefix shape
* `` `${serviceClient}/api/v1/x` `` that `stripLeadingTemplatePrefix` keeps.
* Bare `{param}` and `{param}api/x` stay rejected: nothing pins a route.
*/
function looksLikeHttpPath(path: string): boolean {
if (path === '') return false;
@ -404,7 +515,7 @@ function looksLikeHttpPath(path: string): boolean {
// unresolved term happened to contain a space.
const shape = path.replace(/\$\{[^}]+\}/g, '{param}');
if (/\s/.test(shape)) return false;
if (shape.startsWith('{param}')) return false;
if (shape.startsWith('{param}') && !shape.startsWith('{param}/')) return false;
// An all-digit string is a path only when it is written as one. A leading
// slash is that evidence: `client.get('/123')` is a route whose segment the
// consumer normalizer reads as `{param}`, while a bare `"5000"` folded out of
@ -672,8 +783,7 @@ function scanBundle(
if (!optionsNode) continue;
const path = readStringProp(optionsNode, ['url']);
if (path === null) continue;
const rawMethod = readStringProp(optionsNode, ['method']);
const method = (rawMethod ?? 'GET').toUpperCase();
const method = readRequestMethod(optionsNode, ['method']);
out.push({
role: 'consumer',
framework: 'axios',
@ -685,6 +795,37 @@ function scanBundle(
});
}
// Consumer: wrapped client `X.request({ url, method })` — the shared
// enterprise axios-instance shape (`httpClient.request` from
// win-request and friends). Emit the raw url (templates intact) so
// shared `normalizeConsumerPath` can strip a leading `${…}` gateway
// prefix and fold mid/tail interpolations to `{param}`. A plugin-side
// longest-slash-segment reducer would truncate those mid-templates
// before the shared normalizer ever saw them. This scan drops only
// static relative urls (no `${`, no leading `/`, not `https?://`).
// That filter is request-wrapper-specific: fetch/axios member forms
// already admit absolute urls and leave host stripping to
// `normalizeConsumerPath`.
for (const match of runCompiledPatterns(bundle.requestObject, tree)) {
const optionsNode = match.captures.options;
const objNode = match.captures.obj;
if (!optionsNode || !objNode) continue;
if (!isAdmittedWrappedRequestReceiver(objNode.text, fileKey, facts)) continue;
const rawUrl = readStringProp(optionsNode, ['url']);
if (rawUrl === null) continue;
const url = rawUrl.trim();
if (!url.includes('${') && !url.startsWith('/') && !/^https?:\/\//i.test(url)) continue;
out.push({
role: 'consumer',
framework: 'request',
method: readRequestMethod(optionsNode),
path: url,
name: null,
line: optionsNode.startPosition.row + 1,
confidence: 0.65,
});
}
for (const route of scanDataRouteTables(tree)) {
const imported =
route.handlerLocalName === undefined ? undefined : importMap.get(route.handlerLocalName);

View file

@ -292,13 +292,57 @@ export function normalizeHttpPath(p: string): string {
}
/**
* Consumer-side normalization is more aggressive:
* - template literals (`${x}`) → `{param}`
* - strip protocol + host if the URL is absolute
* - numeric segments → `{param}` (so `/api/orders/42` → `/api/orders/{param}`)
* Strip LEADING template interpolations from a consumer url as gateway/host
* bindings — the enterprise wrapper shape `` `${serviceClient}/api/v1/x` ``
* where `${serviceClient}` selects the gateway service, not a route segment.
* This is consumer-path framework semantics (mirrors how an absolute
* `https://host/path` url keeps only its path), so it lives here rather than
* in any one language plugin:
* - `` `${c}/api/x` `` → `/api/x` (clean prefix; `${c}${d}/api/x` → `/api/x`)
* - `` `${c}/api/x/${id}` ``→ `/api/x/${id}` (mid/tail interpolations are left for the `{param}` pass)
* Returns null when the stripped remainder is not a single-slash path:
* - remainder without `/` — relative fragment (`${c}api/x`) or scheme/host
* (`${scheme}://${host}/api/x`); whether it is a path depends on
* unverifiable runtime state, so it is dropped rather than guessed at;
* - remainder starting with `//` — protocol-relative (`${proto}//host/api/x`);
* keeping it would later collapse to `/host/api/x`.
*
* The `?` in a query string cannot leak into the brace matching: `${...}`
* spans are matched by braces here (before any `{param}` replacement), and
* `normalizeHttpPath` splits on `?` only after the whole `${...}` span —
* including any `?` inside it — has been collapsed to `{param}`. So
* `` `${c}/api/x?id=${id}` `` reduces to `/api/x` on both orderings.
*/
function normalizeConsumerPath(url: string): string {
const templated = url.replace(/\$\{[^}]+\}/g, '{param}').trim();
function stripLeadingTemplatePrefix(url: string): string | null {
if (!url.startsWith('${')) return url;
const rest = url.replace(/^(?:\$\{[^}]*\})+/, '');
return rest.startsWith('/') && !rest.startsWith('//') ? rest : null;
}
/**
* Placeholder substituted for `${...}` before WHATWG `URL` parsing so the
* parser cannot percent-encode our own `{param}` markers. A genuine encoded
* segment like `%7Bfoo%7D` then survives as a literal, instead of being
* rewritten into braces and folded into `{param}`.
*
* Private-use U+E000 cannot appear in a real URL path, so a literal
* `__gitnexus_http_param__` segment is not rewritten into `{param}`.
*/
const CONSUMER_PARAM_SENTINEL = '\uE000';
const CONSUMER_PARAM_SENTINEL_ENC = '%ee%80%80';
function restoreConsumerParamSentinel(pathOnly: string): string {
return pathOnly
.split(CONSUMER_PARAM_SENTINEL)
.join('{param}')
.replace(new RegExp(CONSUMER_PARAM_SENTINEL_ENC, 'gi'), '{param}');
}
/** Canonicalize a consumer URL after `stripLeadingTemplatePrefix`. */
function normalizeConsumerPath(url: string): string | null {
const stripped = stripLeadingTemplatePrefix(url.trim());
if (stripped === null) return null;
const templated = stripped.replace(/\$\{[^}]+\}/g, CONSUMER_PARAM_SENTINEL).trim();
let pathOnly = templated;
if (/^https?:\/\//i.test(templated)) {
try {
@ -307,6 +351,7 @@ function normalizeConsumerPath(url: string): string {
pathOnly = templated.replace(/^https?:\/\/[^/]+/i, '');
}
}
pathOnly = restoreConsumerParamSentinel(pathOnly);
const normalized = normalizeHttpPath(pathOnly || '/');
const segments = normalized
.split('/')
@ -999,6 +1044,12 @@ export class HttpRouteExtractor implements ContractExtractor {
for (const d of detections) {
if (d.role !== 'consumer') continue;
const pathNorm = normalizeConsumerPath(d.path);
// A consumer url that cannot be reduced to a routable path (e.g. a
// leading template binding that is neither a clean prefix nor a
// remainder that starts with `/`) is dropped here rather than emitted
// as a never-matching contract — same treatment the plugins give
// static relative urls at scan time.
if (pathNorm === null) continue;
// Resolve the function CONTAINING the fetch/axios call so the consumer
// contract carries a real symbolUid (was always '' — the gap that left
// cross-repo trace/impact unable to traverse HTTP links).

View file

@ -21,12 +21,11 @@
* else claims: group directories live under `~/.gitnexus/groups/<name>` (or
* `$GITNEXUS_HOME`), never under a repo's `.gitnexus[/branches/<slug>]`.
*
* WHY IT FAILS CLOSED, unlike the registry lock. `withRegistryLock` degrades to
* running UNLOCKED on timeout, and that is right for it: it guards a sub-second
* JSON read/merge/write on a latency-critical path (`augment` runs on every
* editor tool call), and running unlocked is merely the pre-lock status quo. A
* group sync is the opposite on every axis — it is long, expensive, operator-
* initiated, and its lost update destroys contracts rather than a registry field.
* WHY IT FAILS CLOSED, like the registry lock. `withRegistryLock` also
* refuses to continue unlocked on timeout: a lost registry update can drop a
* concurrent registration. A group sync still fails closed for additional
* reasons — it is long, expensive, operator-initiated, and a lost update
* destroys contracts rather than a registry field.
* A sync that cannot be protected must not run at all, and there are three
* distinct ways it can fail to be protected; all three throw
* {@link GroupSyncLockError}:

View file

@ -0,0 +1,950 @@
/**
* Read AsyncAPI 3.x documents off disk and normalize their operations into
* broker addresses.
*
* Deliberately OUTSIDE `frameworks/spring/`. An AsyncAPI document is a
* published artifact, not a Spring one: it is emitted by generators across
* Java, Kotlin, TypeScript, Go and Python toolchains, and it is written by hand
* as often as it is generated. The entry criterion here is therefore the
* DOCUMENT FORMAT — a root `asyncapi` key — and never the generator. Nothing in
* this module may branch on `x-generator`, on a vendor extension, or on the
* shape of an operation key: the moment it does, every service whose toolchain
* spells things differently stops being read, and the failure is silent.
*
* ── WHY THIS IS WORTH READING AT ALL ──────────────────────────────────────
*
* A `@KafkaListener(topics = "${app.topic.in}")` names a configuration key, not
* an address, and the address cascade correctly refuses to resolve it — two
* services that merely wrote the same placeholder have said nothing about each
* other. But the service's own published document states the address outright,
* fully resolved, because the generator ran with the configuration applied.
* That is a fact about the service that no amount of reading its source can
* recover.
*
* ── WHAT THIS MODULE IS AFRAID OF ─────────────────────────────────────────
*
* Everything it emits becomes half of a JOIN KEY. A destination minted here
* meets every other site in every other repository that names the same address
* on the same broker — that is the whole value, and it is the whole hazard. A
* missing destination is a visible gap; a wrong one is reported as a fact. So
* the refusals below are not defensive clutter: each one is a case where the
* document says something that LOOKS like an address and is not one, and where
* accepting it would connect two services that have said nothing about each
* other. The taxonomy is closed and countable for the same reason the source
* cascade's is — a feature judged on its unresolved fraction needs the fraction
* broken down by cause, or nobody can tell it what to go and fix.
*
* ── VERSION 2.x IS REFUSED, NOT MAPPED ────────────────────────────────────
*
* AsyncAPI 2.x describes a channel from the READER's point of view: `publish`
* means "you may publish here", so the documenting application RECEIVES, and
* `subscribe` means the application SENDS. Version 3.0 renamed these to the
* application's own `receive` / `send`. Mapping 2.x naively therefore reverses
* every direction in the async graph — and reverses it INVISIBLY, because both
* roles still exist, every edge is still emitted, and the graph stays
* connected. Nothing fails; the arrows simply point the wrong way.
*
* The inversion is one line to write and impossible to test against a real
* corpus we do not have, and the 2.x wording confused implementers badly enough
* that some generators emitted it backwards. So 2.x is refused under its own
* countable reason instead. A silent skip would be indistinguishable from "this
* service publishes no document", which is the one thing the count has to be
* able to tell us: if the refusal tally shows 2.x documents in the field, the
* inversion earns its way in with evidence behind it.
*/
import { createRequire } from 'node:module';
import fs from 'node:fs/promises';
import { constants as fsConstants } from 'node:fs';
import path from 'node:path';
import { brokerForBindingKey, brokerForProtocol, isNonDestinationBroker } from './protocol.js';
// `js-yaml` is CJS; the rest of this repository reaches it the same way
// (`pipeline-phases/spring-config.ts`, `import-resolvers/node-workspace-packages.ts`).
const _require = createRequire(import.meta.url);
const yaml = _require('js-yaml') as typeof import('js-yaml');
/**
* A published document is data, not code, so it is parsed under the JSON
* schema — the same choice `core/group/config-parser.ts` makes for `group.yaml`.
* No custom tags, no timestamps, no `yes`/`no` booleans: an address is whatever
* the document literally spells, and nothing may be coerced into another type
* on the way in.
*/
const DOCUMENT_SCHEMA = yaml.JSON_SCHEMA;
/** Generous for a specification, small enough that a mistake is caught. */
const MAX_DOCUMENT_BYTES = 8 * 1024 * 1024;
/** Bounded so one pathological document cannot dominate a run. Counted against
* operations EXAMINED, not accepted: a document with a hundred thousand
* refused operations costs the same walk as one with a hundred thousand good
* ones, and a cap that only counts successes does not bound the work. */
const MAX_OPERATIONS_PER_DOCUMENT = 5_000;
/** The same bound across the whole run, and counted the same way — EXAMINED,
* not accepted. Counting successes here reproduced the very defect the
* per-document cap was corrected for: a run whose every operation was refused
* never decremented the budget, so all two thousand documents were processed
* in full and the result still reported `truncated: false`. */
const MAX_TOTAL_OPERATIONS = 50_000;
/** Bounded so a mis-aimed path (a whole repository, `/`) cannot walk forever. */
const MAX_DOCUMENTS = 2_000;
/** Directory entries VISITED, not documents accepted. The document cap alone
* bounds nothing on a tree that contains no documents. */
const MAX_WALK_ENTRIES = 100_000;
const MAX_DIRECTORY_DEPTH = 8;
/** Servers per document. The channel-inherits-all-servers rule reads this map,
* and YAML aliases make a server about sixteen bytes, so an in-cap document
* can declare hundreds of thousands of them. */
const MAX_SERVERS_PER_DOCUMENT = 1_000;
/**
* A leading byte-order mark, stripped before the file is sniffed or parsed.
*
* An editor that saves UTF-8 with a BOM puts one code point in front of the
* root key, which is enough to make the sniff miss and refuse a perfectly good
* document as `not-a-document`.
*/
const BOM = '\uFEFF';
/**
* An address and an operation id both end up inside graph identifiers, and
* `generateId` CONCATENATES rather than hashes (`lib/utils.ts`), so an
* identifier is exactly as long as the text it was built from. One document
* under every other cap — a multi-megabyte address plus five thousand
* operations naming it — therefore mints five thousand multi-megabyte edge ids,
* each flattened into a string key by the graph's `Map`.
*
* The BROKER is the third such string and is bounded in `protocol.ts`; the
* count matters because an earlier version of this comment said "the two
* strings that reach an id", left the third unbounded, and a one-megabyte
* protocol was measured turning a one-megabyte document into a gigabyte of
* resident identifiers.
*/
const MAX_ADDRESS_LENGTH = 2_048;
const MAX_OPERATION_ID_LENGTH = 512;
const DOCUMENT_EXTENSIONS: ReadonlySet<string> = new Set(['.yaml', '.yml', '.json']);
/**
* Why a document, or one operation inside it, produced no address.
*
* A CLOSED, COUNTABLE set, and deliberately NOT `SpringDestinationRefusal`.
* That union is documented as the reasons a *source-level candidate* produced
* no address, and it is the denominator of the unresolved fraction the address
* work is judged on. Folding document-level failures into it would silently
* change what that number means — a repository whose specification directory
* was mistyped would report a worse SOURCE, which is the opposite of the truth.
*
* Members are split wherever two causes are different FACTS about the input.
* A tally whose member says "the document contradicts itself" when the document
* is merely multi-protocol sends an operator to fix the wrong thing, and this
* tally is the number the whole feature is judged on.
*/
export type AsyncApiRefusal =
/** The file parsed but has no root `asyncapi` key: not a document at all. */
| 'not-a-document'
/** Root `asyncapi: 2.x`. See the header — refused, never mapped. */
| 'asyncapi-2-unsupported'
/** A root `asyncapi` key naming a version this module does not read. */
| 'unsupported-version'
/** Malformed YAML/JSON, or a root that is not an object. */
| 'unparsable'
/** The file could not be read, or is not a regular file (a FIFO, a device). */
| 'unreadable'
/** A subdirectory could not be listed. Counted rather than skipped: under a
* mixed-permission cache half the documents can be invisible while the run
* otherwise reports a clean, complete read. */
| 'directory-unreadable'
/** Larger than {@link MAX_DOCUMENT_BYTES}. */
| 'oversized'
/** The document held more operations than one run will examine. */
| 'operation-cap'
/** The run as a whole reached {@link MAX_TOTAL_OPERATIONS}. */
| 'total-operation-cap'
/** The document declares more servers than the channel-inheritance rule will
* read. */
| 'server-cap'
/** The walk hit a bound before it finished, so the document set is a floor
* rather than the whole of what the configured path holds. A truncated read
* that reported nothing would be indistinguishable from a complete one. */
| 'walk-truncated'
/** `operations[].channel.$ref` is absent or not a local channel pointer. */
| 'no-channel-reference'
/** The `$ref` resolved to no channel in this document. */
| 'channel-not-found'
/** The channel entry is itself a Reference Object, which this module does not
* follow. Distinct from `no-address` on purpose: a `$ref`-ed channel HAS an
* address, somewhere this reader did not look, and filing it under
* `no-address` tells an operator their documents omit addresses when the
* real answer is that the reader stops one hop short. */
| 'unresolved-channel-reference'
/** The channel names no `address`, so there is nothing to key on. */
| 'no-address'
/**
* The address is a TEMPLATE, not an address: the channel declares non-empty
* `parameters`, or the address carries a `{…}` placeholder.
*
* This is the document-side twin of the source cascade's
* `overridable-config-default`, and it exists for the identical reason. Two
* services that both publish `{env}.orders` have named a pattern they share,
* not a queue they share — one deploys with `env=prod` and the other with
* `env=staging`, and keying on the template text merges them into a single
* node with a publisher on one side and a subscriber on the other. That is a
* false connection built entirely from conformant AsyncAPI: `parameters` and
* `{param}` are core 3.x vocabulary, not a vendor quirk.
*/
| 'templated-address'
/** Longer than {@link MAX_ADDRESS_LENGTH}. */
| 'address-too-long'
/** Longer than {@link MAX_OPERATION_ID_LENGTH}. */
| 'operation-id-too-long'
/** `action` is neither `send` nor `receive`. */
| 'unrecognized-action'
/** Neither the operation's bindings nor the servers its channel resolves to
* name a protocol. Silence about the broker is not a claim about it, but a
* `Destination` cannot be keyed without one. */
| 'protocol-unknown'
/** The operation's OWN two statements about its broker — its bindings and the
* servers its channel explicitly lists — name different brokers. The
* document contradicts itself, and a destination keyed on the wrong broker
* joins a stranger. */
| 'protocol-disagreement'
/** The channel lists no `servers`, so it inherits all of them, and they do
* not agree on one broker. The document does NOT contradict itself here —
* it is simply multi-protocol and this channel did not choose — which is why
* this is not `protocol-disagreement`. */
| 'ambiguous-server-default'
/**
* The channel inherits the document's servers, but that map was CAPPED at
* {@link MAX_SERVERS_PER_DOCUMENT}, so the brokers read are a subset.
*
* Distinct from `ambiguous-server-default`, and the distinction is the whole
* point: unanimity across a subset is not unanimity. A document whose first
* thousand servers are Kafka and whose thousand-and-first is JMS reads as
* unanimously Kafka, and every operation inheriting it would be attributed to
* a broker the complete set does not agree on.
*/
| 'capped-server-default'
/**
* A server this operation depends on is a Reference Object this reader could
* not resolve — a pointer outside `#/servers` and `#/components/servers`, a
* name that is absent, or a reference to another reference.
*
* Refused rather than skipped. Skipping one server of several silently
* narrows the evidence, and a narrowed set is what makes a mixed document
* look like it agrees with itself.
*/
| 'unresolved-server-reference'
/** The broker is HTTP or WebSocket, where the host rather than the address is
* the namespace. See `isNonDestinationBroker`. */
| 'not-a-destination-protocol';
export interface AsyncApiOperation {
/** Absolute path of the document this operation came from. */
readonly documentPath: string;
/** The `operations` map key, kept for provenance and carried into the edge
* `reason` so a reader can find the operation the edge came from. */
readonly operationId: string;
readonly action: 'send' | 'receive';
readonly address: string;
/** Normalized broker — the first half of the `Destination` key. */
readonly broker: string;
}
export interface AsyncApiReadResult {
readonly operations: readonly AsyncApiOperation[];
/** Files considered — every candidate extension under the configured path. */
readonly documentsScanned: number;
/** Files that parsed as an AsyncAPI 3.x document and yielded an operation. */
readonly documentsAccepted: number;
/** Entries skipped because they were symbolic links. Not a refusal — the skip
* is deliberate — but counted, because a cache written by other tooling is
* very often a symlink farm, and an operator whose whole cache was skipped
* would otherwise see a result identical to a wrong path. */
readonly symlinksSkipped: number;
/** True when a bound stopped the walk or the operation count, so every number
* here is a floor rather than a total. */
readonly truncated: boolean;
/** Every refusal, document-level and operation-level, by reason. */
readonly refusals: Readonly<Partial<Record<AsyncApiRefusal, number>>>;
}
interface Tally {
count(reason: AsyncApiRefusal): void;
}
function makeTally(sink: Partial<Record<AsyncApiRefusal, number>>): Tally {
return {
count: (reason) => {
sink[reason] = (sink[reason] ?? 0) + 1;
},
};
}
/**
* Own-property read that cannot be answered by the prototype chain.
*
* A document is untrusted input and its keys are attacker-chosen in the general
* case. `channels['constructor']` misses because {@link asRecord} rejects a
* function — but `channels['__proto__']` would otherwise resolve to
* `Object.prototype`, which IS an object and would sail through as an empty
* channel. This guard, not the type test, is what stops that one.
*/
function own(container: unknown, key: string): unknown {
if (typeof container !== 'object' || container === null) return undefined;
if (!Object.prototype.hasOwnProperty.call(container, key)) return undefined;
return (container as Record<string, unknown>)[key];
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
return value as Record<string, unknown>;
}
function asString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}
/** URI-fragment percent-decode. Malformed `%` sequences refuse the pointer. */
function decodeFragment(ref: string): string | undefined {
try {
return decodeURIComponent(ref);
} catch (err) {
// Malformed percent-escapes throw URIError. Anything else is a real bug.
if (!(err instanceof URIError)) throw err;
return undefined;
}
}
/** RFC 6901's own escapes, `~1` before `~0` — a literal `~1` produced by
* decoding `~01` would otherwise be mistaken for a slash. */
function unescapePointerToken(token: string): string {
return token.split('~1').join('/').split('~0').join('~');
}
/**
* Trailing name of `<prefix><token>` on an already-decoded fragment.
*
* DECODE, THEN SEGMENT. RFC 6901 percent-decodes the URI fragment first; only
* then is the result split on `/`. Testing the raw text for a separator lets
* `#/channels/orders%2Fv1` through as one segment and then decode it into two,
* inventing a channel named `orders/v1`. A real slash in a name is `~1`.
*/
function nameAfterPrefix(decoded: string, prefix: string): string | undefined {
if (!decoded.startsWith(prefix)) return undefined;
const token = decoded.slice(prefix.length);
if (token === '' || token.includes('/')) return undefined;
return unescapePointerToken(token);
}
function pointerName(ref: string, prefix: string): string | undefined {
const decoded = decodeFragment(ref);
if (decoded === undefined) return undefined;
return nameAfterPrefix(decoded, prefix);
}
/**
* Distinct brokers named by a bindings object's own keys.
*
* Routed through `brokerForBindingKey`, which answers only for AsyncAPI's
* binding vocabulary — `$ref` and `x-` extensions share this namespace
* legitimately and are not brokers. See `protocol.ts` for why this differs from
* the pass-through applied to `servers[].protocol`.
*/
function brokersFromBindings(bindings: unknown): Set<string> {
const out = new Set<string>();
const record = asRecord(bindings);
if (record === undefined) return out;
for (const key of Object.keys(record)) {
const broker = brokerForBindingKey(key);
if (broker !== undefined) out.add(broker);
}
return out;
}
/**
* The broker one Servers Object entry names, following at most one local `$ref`.
*
* The Servers Object's patterned field is `Server Object | Reference Object`,
* so an entry may legitimately be `{ $ref: '#/components/servers/prod' }`.
* Reading `protocol` off the raw value drops every one of those, and a dropped
* server is not neutral here: in a mixed set it removes the disagreeing half
* and makes partial evidence look unanimous, which is exactly how a confident
* WRONG broker gets attributed.
*
* `unresolved` is reported rather than swallowed so the caller can refuse the
* attribution instead of answering from the servers it happened to understand.
* A reference to a reference counts as unresolved too: one hop covers every
* document shape seen in practice, and chasing a chain over untrusted input
* would need a cycle guard before it were safe at all.
*/
function serverBroker(
entry: unknown,
root: Record<string, unknown>,
): { broker: string | undefined; unresolved: boolean } {
const ref = asString(own(entry, '$ref'));
if (ref === undefined) {
return { broker: brokerForProtocol(asString(own(entry, 'protocol'))), unresolved: false };
}
const target = resolveLocalServerRef(ref, root);
if (target === undefined || own(target, '$ref') !== undefined) {
return { broker: undefined, unresolved: true };
}
return { broker: brokerForProtocol(asString(own(target, 'protocol'))), unresolved: false };
}
/** `#/servers/<name>` or `#/components/servers/<name>` → that Server Object. */
function resolveLocalServerRef(
ref: string,
root: Record<string, unknown>,
): Record<string, unknown> | undefined {
const decoded = decodeFragment(ref);
if (decoded === undefined) return undefined;
const direct = nameAfterPrefix(decoded, '#/servers/');
if (direct !== undefined) return asRecord(own(asRecord(own(root, 'servers')), direct));
const inComponents = nameAfterPrefix(decoded, '#/components/servers/');
if (inComponents === undefined) return undefined;
const components = asRecord(own(asRecord(own(root, 'components')), 'servers'));
return asRecord(own(components, inComponents));
}
/**
* Brokers of the servers a channel names explicitly.
*
* An EMPTY array is not an explicit choice. The specification defines the two
* cases identically — "If `servers` is absent or empty, this channel MUST be
* available on all the servers defined in the Servers Object" — so reporting
* `explicit: true` after a zero-iteration loop blocks the inherited fallback
* and drops a perfectly valid operation as `protocol-unknown`.
*
* A channel's `servers` MUST hold Reference Objects — the specification says so
* in as many words, and forbids Server Objects there by name — so an entry that
* is not a resolvable local reference is counted `unresolved` rather than read.
*/
function brokersFromChannelRefs(
channel: Record<string, unknown>,
root: Record<string, unknown>,
): { brokers: Set<string>; explicit: boolean; unresolved: boolean } {
const out = new Set<string>();
const refs = own(channel, 'servers');
if (!Array.isArray(refs) || refs.length === 0) {
return { brokers: out, explicit: false, unresolved: false };
}
const servers = asRecord(own(root, 'servers'));
let unresolved = false;
for (const entry of refs) {
const ref = asString(own(entry, '$ref'));
const name = ref === undefined ? undefined : pointerName(ref, '#/servers/');
const target = name === undefined ? undefined : own(servers, name);
if (target === undefined) {
unresolved = true;
continue;
}
const resolved = serverBroker(target, root);
if (resolved.unresolved) {
unresolved = true;
continue;
}
if (resolved.broker !== undefined) out.add(resolved.broker);
}
return { brokers: out, explicit: true, unresolved };
}
/**
* Every broker the document's servers name, computed ONCE per document.
*
* A channel that names no `servers` is available on all of them — the
* specification's own default, not an inference. Computing it per operation was
* quadratic in `servers × operations`, which an in-cap document can drive to
* minutes.
*/
function brokersOfAllServers(root: Record<string, unknown>): {
brokers: Set<string>;
capped: boolean;
unresolved: boolean;
} {
const out = new Set<string>();
const servers = asRecord(own(root, 'servers'));
if (servers === undefined) return { brokers: out, capped: false, unresolved: false };
let seen = 0;
for (const name in servers) {
if (!Object.prototype.hasOwnProperty.call(servers, name)) continue;
seen += 1;
if (seen > MAX_SERVERS_PER_DOCUMENT) {
// Inherited resolution refuses a capped map before asking it to agree
// with itself, so the brokers of the first thousand entries are unused.
return { brokers: out, capped: true, unresolved: false };
}
}
let unresolved = false;
for (const name in servers) {
if (!Object.prototype.hasOwnProperty.call(servers, name)) continue;
const resolved = serverBroker(own(servers, name), root);
if (resolved.unresolved) unresolved = true;
else if (resolved.broker !== undefined) out.add(resolved.broker);
}
return { brokers: out, capped: false, unresolved };
}
/**
* Root `asyncapi` version → readable, refused, or not a document at all.
*
* Compared on the MAJOR component only. A 3.1 document adds fields this module
* does not read and changes none it does; refusing it would lose real
* destinations over a minor-version digit.
*/
function classifyVersion(raw: Record<string, unknown>): 'read' | AsyncApiRefusal {
const declared = asString(own(raw, 'asyncapi'))?.trim();
if (declared === undefined || declared === '') return 'not-a-document';
const major = declared.split('.')[0];
if (major === '3') return 'read';
if (major === '2') return 'asyncapi-2-unsupported';
return 'unsupported-version';
}
export interface NormalizedDocument {
operations: AsyncApiOperation[];
refusals: Partial<Record<AsyncApiRefusal, number>>;
/** Operations EXAMINED, which is what the caps count. */
examined: number;
/** A bound stopped this document short. */
truncated: boolean;
}
/**
* Normalize one parsed document. Pure — no filesystem, so the whole refusal
* surface is testable from inline document literals.
*
* `budget` is the number of operations the RUN may still examine.
*/
export function normalizeAsyncApiDocument(
parsed: unknown,
documentPath: string,
budget: number = MAX_TOTAL_OPERATIONS,
): NormalizedDocument {
const refusals: Partial<Record<AsyncApiRefusal, number>> = {};
const tally = makeTally(refusals);
const operations: AsyncApiOperation[] = [];
let examined = 0;
let truncated = false;
const raw = asRecord(parsed);
if (raw === undefined) {
tally.count('unparsable');
return { operations, refusals, examined, truncated };
}
const verdict = classifyVersion(raw);
if (verdict !== 'read') {
tally.count(verdict);
return { operations, refusals, examined, truncated };
}
const channels = asRecord(own(raw, 'channels'));
const operationsRaw = asRecord(own(raw, 'operations'));
if (operationsRaw === undefined) return { operations, refusals, examined, truncated };
const allServers = brokersOfAllServers(raw);
if (allServers.capped) {
tally.count('server-cap');
truncated = true;
}
for (const operationId of Object.keys(operationsRaw)) {
if (examined >= MAX_OPERATIONS_PER_DOCUMENT) {
tally.count('operation-cap');
truncated = true;
break;
}
if (examined >= budget) {
tally.count('total-operation-cap');
truncated = true;
break;
}
examined += 1;
const operation = asRecord(own(operationsRaw, operationId));
if (operation === undefined) {
tally.count('unparsable');
continue;
}
if (operationId.length > MAX_OPERATION_ID_LENGTH) {
tally.count('operation-id-too-long');
continue;
}
const action = asString(own(operation, 'action'))?.trim().toLowerCase();
if (action !== 'send' && action !== 'receive') {
tally.count('unrecognized-action');
continue;
}
const ref = asString(own(own(operation, 'channel'), '$ref'));
const channelName = ref === undefined ? undefined : pointerName(ref, '#/channels/');
if (channelName === undefined) {
tally.count('no-channel-reference');
continue;
}
const channel = asRecord(own(channels, channelName));
if (channel === undefined) {
tally.count('channel-not-found');
continue;
}
if (own(channel, '$ref') !== undefined && own(channel, 'address') === undefined) {
tally.count('unresolved-channel-reference');
continue;
}
// The `address` field, not the channel KEY. A generator is free to key a
// channel by anything unique; only `address` is defined as the thing the
// broker is addressed by, and keying a node on a document-local map key
// would join two services that merely organized their documents alike.
//
// NOT TRIMMED, deliberately. The source cascade keeps an address exactly as
// written — `" orders "` is its own node and does not join `"orders"` — on
// the grounds that a missing connection beats a false one. Two producers of
// one key must not hold opposite whitespace policies, and of the two
// available answers this is the one that errs away from joining.
const address = asString(own(channel, 'address'));
if (address === undefined || address.trim() === '') {
tally.count('no-address');
continue;
}
if (address.length > MAX_ADDRESS_LENGTH) {
tally.count('address-too-long');
continue;
}
// A non-empty `parameters` map is the specification's own statement that
// the address is a template. An EMPTY one states nothing — generators emit
// empty containers routinely — so it must not refuse a literal address; the
// `{` test below covers documents that template without declaring.
const parameters = asRecord(own(channel, 'parameters'));
if ((parameters !== undefined && Object.keys(parameters).length > 0) || address.includes('{')) {
tally.count('templated-address');
continue;
}
// BINDINGS FIRST. They are the operation's own statement about its broker;
// the servers are the channel's. Unioning every server before consulting
// the bindings made a document that declares both a REST server and a Kafka
// server lose every operation it states, filed under a reason that says the
// document contradicts itself — when the contradiction was manufactured
// here by asking a question the operation had already answered.
//
// The CHANNEL's bindings count as well. They are a statement about the same
// operation made one level up, and a conformant document may carry only
// those — `channels: { orders: { bindings: { kafka: {} } } }` with no
// operation binding and no usable server protocol was dropped as
// `protocol-unknown` while the document had said plainly which broker it
// meant. Where both levels speak and disagree, the document contradicts
// itself and neither answer may be used.
const fromBindings = brokersFromBindings(own(operation, 'bindings'));
for (const broker of brokersFromBindings(own(channel, 'bindings'))) {
fromBindings.add(broker);
}
if (fromBindings.size > 1) {
tally.count('protocol-disagreement');
continue;
}
const bindingBroker = [...fromBindings][0];
const explicitServers = brokersFromChannelRefs(channel, raw);
let broker: string | undefined;
if (bindingBroker !== undefined) {
// Cross-check only against servers the channel named itself, and only
// when they are unanimous. An inherited multi-protocol server set is not
// a claim about THIS operation.
const explicitBroker =
explicitServers.brokers.size === 1 ? [...explicitServers.brokers][0] : undefined;
if (explicitBroker !== undefined && explicitBroker !== bindingBroker) {
tally.count('protocol-disagreement');
continue;
}
broker = bindingBroker;
} else if (explicitServers.explicit) {
if (explicitServers.unresolved) {
tally.count('unresolved-server-reference');
continue;
}
if (explicitServers.brokers.size > 1) {
tally.count('protocol-disagreement');
continue;
}
broker = [...explicitServers.brokers][0];
} else {
// Order matters: an INCOMPLETE set must be refused before it is asked
// whether it agrees, because a subset agrees with itself for free.
if (allServers.capped) {
tally.count('capped-server-default');
continue;
}
if (allServers.unresolved) {
tally.count('unresolved-server-reference');
continue;
}
if (allServers.brokers.size > 1) {
tally.count('ambiguous-server-default');
continue;
}
broker = [...allServers.brokers][0];
}
if (broker === undefined) {
tally.count('protocol-unknown');
continue;
}
if (isNonDestinationBroker(broker)) {
tally.count('not-a-destination-protocol');
continue;
}
operations.push({ documentPath, operationId, action, address, broker });
}
return { operations, refusals, examined, truncated };
}
/**
* Cheap pre-parse gate: does this file even claim to be an AsyncAPI document?
*
* Scans the WHOLE text, which is already bounded by {@link MAX_DOCUMENT_BYTES}
* and already in memory. A fixed window is the wrong shape of bound here: it
* decides the answer by where the key happens to sit rather than by whether the
* key is there, so any window is a false negative waiting for a file with a
* longer preamble. Sixty-four kilobytes replaced four for exactly that reason
* and inherited exactly that defect — a licence header, a `$schema` block and a
* long `info.description` clear it easily. The gate exists to skip the YAML
* PARSE, which is the expensive half; a linear scan of the same bytes is not.
*/
function looksLikeDocument(text: string): boolean {
if (!text.includes('asyncapi')) return false;
return /(^|[\s{,"'])["']?asyncapi["']?\s*:/m.test(text);
}
interface WalkResult {
files: string[];
symlinksSkipped: number;
truncated: boolean;
unreadableDirectories: number;
}
async function collectCandidateFiles(root: string): Promise<WalkResult> {
const files: string[] = [];
let symlinksSkipped = 0;
let unreadableDirectories = 0;
let visited = 0;
let truncated = false;
// Distinct from `truncated`: a GLOBAL budget is exhausted and no further work
// is useful, whereas depth exhaustion in one branch says nothing about its
// siblings. Conflating them made a single over-deep subdirectory discard
// every remaining document in the walk, with the outcome decided by
// alphabetical ordering — a strictly worse failure than the one the
// truncation reporting was added to fix.
let exhausted = false;
const walk = async (dir: string, depth: number): Promise<void> => {
if (exhausted) return;
if (depth > MAX_DIRECTORY_DEPTH) {
truncated = true;
return;
}
let entries: import('node:fs').Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
unreadableDirectories += 1;
truncated = true;
return;
}
// Sorted so the operation order a run produces is a function of the tree,
// not of the order the filesystem happened to hand entries back.
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
for (const entry of entries) {
if (exhausted) return;
visited += 1;
if (visited > MAX_WALK_ENTRIES || files.length >= MAX_DOCUMENTS) {
truncated = true;
exhausted = true;
return;
}
const full = path.join(dir, entry.name);
// `withFileTypes` reports a symlink as neither file nor directory, so
// links are skipped without ever being followed — a configured directory
// must not become a route out of itself. Counted rather than dropped in
// silence: a symlinked cache would otherwise look exactly like a wrong
// path.
if (entry.isSymbolicLink()) {
symlinksSkipped += 1;
} else if (entry.isDirectory()) {
await walk(full, depth + 1);
} else if (
entry.isFile() &&
DOCUMENT_EXTENSIONS.has(path.extname(entry.name).toLowerCase())
) {
files.push(full);
}
}
};
await walk(root, 0);
return { files, symlinksSkipped, truncated, unreadableDirectories };
}
/**
* Read one candidate file under a hard byte ceiling.
*
* Modelled on `frameworks/spring/actuator-runtime.ts`'s `readPayloadFile`, and
* for the reason its comment gives: the size gate and the read share ONE handle
* so both observe the same inode. Checking `fs.stat(path)` and then re-resolving
* that path in `fs.readFile` lets whatever writes the directory swap the file
* between the two calls, which makes the cap advisory (CodeQL
* js/file-system-race). The out-of-band cache this option exists to read is
* written by other tooling by definition, so the race is the normal condition
* here rather than an exotic one.
*
* The read LOOPS, like its model. POSIX permits a short read on a regular file,
* and a single read was measured never short across seven hundred reads on
* APFS — but the deployments this option targets put the cache on NFS, SMB or a
* FUSE mount, and FUSE filesystems using `direct_io` do return short counts.
* The consequence of one short read is silent: a document truncated at a line
* boundary still parses, so operations vanish with `refusals: {}` and
* `truncated: false`, indistinguishable from a document that had fewer.
*
* The `isFile` test on the same handle is what the path-based version could not
* do at all. Without it the single-file configuration accepts anything `stat`
* follows: a character device reports size 0 and then streams until Node throws
* at two gigabytes, and a FIFO never returns at all.
*
* `O_NONBLOCK` is what makes that test reachable, and it was not obvious — it
* was found by writing the FIFO test and watching it TIME OUT rather than fail.
* Opening a FIFO for reading blocks in `open(2)` until some writer opens the
* other end, so a type check performed after the open never runs: the analyze
* hangs there, holding its repository lock, with no error to report. The flag
* makes the open return immediately for a FIFO and is a no-op for the regular
* files this actually wants (measured: identical byte count, digest and timing
* with and without it), which is why it costs nothing to keep.
*/
async function readBoundedFile(file: string): Promise<string | 'oversized' | 'unreadable'> {
let handle: import('node:fs/promises').FileHandle | undefined;
try {
// `O_NONBLOCK` is absent on some platforms; falling back to a plain
// read-only open there keeps behaviour identical for regular files.
const nonBlocking = (fsConstants.O_NONBLOCK ?? 0) | fsConstants.O_RDONLY;
handle = await fs.open(file, nonBlocking);
const stat = await handle.stat();
if (!stat.isFile()) return 'unreadable';
if (stat.size > MAX_DOCUMENT_BYTES) return 'oversized';
const buffer = Buffer.alloc(MAX_DOCUMENT_BYTES + 1);
let bytesRead = 0;
while (bytesRead < buffer.length) {
const chunk = await handle.read(buffer, bytesRead, buffer.length - bytesRead, bytesRead);
if (chunk.bytesRead === 0) break;
bytesRead += chunk.bytesRead;
}
if (bytesRead > MAX_DOCUMENT_BYTES) return 'oversized';
return buffer.subarray(0, bytesRead).toString('utf-8');
} catch {
return 'unreadable';
} finally {
await handle?.close().catch(() => {});
}
}
/**
* Read every AsyncAPI 3.x document under an explicitly configured path.
*
* `configuredPath` is resolved against the repository root, so an absolute path
* to a cache populated out of band and a repo-relative directory of committed
* documents are both natural — the same shape `springActuatorPath` offers for
* Actuator snapshots. The READ is wider than that neighbour's, and the
* difference is worth stating rather than glossed as "the same contract": the
* Actuator loader probes five fixed filenames in one directory, while this
* walks recursively under the caps above and opens every candidate it finds.
*
* There is deliberately NO glob-based auto-discovery. Scanning a repository for
* anything that parses as a document would make every existing index grow nodes
* on its next run with nobody having asked for it.
*/
export async function readAsyncApiDocuments(
repoPath: string,
configuredPath: string,
): Promise<AsyncApiReadResult> {
const refusals: Partial<Record<AsyncApiRefusal, number>> = {};
const tally = makeTally(refusals);
const operations: AsyncApiOperation[] = [];
let documentsScanned = 0;
let documentsAccepted = 0;
let symlinksSkipped = 0;
let truncated = false;
let examinedTotal = 0;
const root = path.resolve(repoPath, configuredPath);
let files: string[];
try {
const stat = await fs.stat(root);
if (stat.isDirectory()) {
const walked = await collectCandidateFiles(root);
files = walked.files;
symlinksSkipped = walked.symlinksSkipped;
truncated = walked.truncated;
for (let i = 0; i < walked.unreadableDirectories; i += 1) tally.count('directory-unreadable');
if (truncated && walked.unreadableDirectories === 0) tally.count('walk-truncated');
} else {
files = [root];
}
} catch {
tally.count('unreadable');
return {
operations,
documentsScanned,
documentsAccepted,
symlinksSkipped,
truncated,
refusals,
};
}
for (const file of files) {
documentsScanned += 1;
const content = await readBoundedFile(file);
if (content === 'oversized' || content === 'unreadable') {
tally.count(content);
continue;
}
const text = content.startsWith(BOM) ? content.slice(BOM.length) : content;
// Sniff before parsing. A configured directory may hold hundreds of
// unrelated YAML files, and parsing each one to discover it is not a
// document is the difference between a bounded cost and a per-file one.
if (!looksLikeDocument(text)) {
tally.count('not-a-document');
continue;
}
let parsed: unknown;
try {
parsed = yaml.load(text, { schema: DOCUMENT_SCHEMA });
} catch {
tally.count('unparsable');
continue;
}
const remaining = MAX_TOTAL_OPERATIONS - examinedTotal;
if (remaining <= 0) {
tally.count('total-operation-cap');
truncated = true;
break;
}
const result = normalizeAsyncApiDocument(parsed, file, remaining);
examinedTotal += result.examined;
if (result.truncated) truncated = true;
for (const [reason, count] of Object.entries(result.refusals)) {
refusals[reason as AsyncApiRefusal] = (refusals[reason as AsyncApiRefusal] ?? 0) + count;
}
if (result.operations.length > 0) documentsAccepted += 1;
operations.push(...result.operations);
}
return { operations, documentsScanned, documentsAccepted, symlinksSkipped, truncated, refusals };
}

View file

@ -0,0 +1,207 @@
/**
* AsyncAPI protocol name → broker identity, for `destinationNodeKey`.
*
* Deliberately OUTSIDE `frameworks/spring/`, like `destination-key.ts` and for
* the same reason: an AsyncAPI document is not a Spring artifact, and the
* broker it names has to be mintable by anything that reads one.
*
* ── TWO READERS, TWO RULES, AND WHY THEY ARE NOT THE SAME RULE ────────────
*
* A document states its protocol in two places, and they have opposite
* defaults:
*
* `servers[].protocol` is a FIELD DECLARED TO HOLD A PROTOCOL. Whatever it
* contains is the document's claim about its broker, including a protocol
* this codebase has never heard of. {@link brokerForProtocol} therefore
* passes an unrecognized value through as its own literal — an `mqtt` or
* `nats` channel mints `mqtt <address>` and joins any other site that says
* the same thing, instead of being dropped for the sake of a closed union it
* was never going to fit. Refusing it would lose a destination the document
* states plainly, to protect against a collision that cannot happen: an
* unmapped protocol keys on its own name, so it can only meet a site that
* named the same protocol.
*
* A `bindings` MAP KEY is not that. The map is keyed by protocol name BY
* CONVENTION, and the specification puts other things in the same namespace:
* `$ref` when the bindings are a Reference Object, and `x-` Specification
* Extensions, which generators emit routinely. Here a non-protocol key is the
* EXPECTED case, not the exotic one, so {@link brokerForBindingKey} answers
* only for names it recognizes.
*
* That asymmetry was learned the expensive way. An earlier version applied one
* syntactic test to both and excluded only `$`-prefixed tokens; a document
* carrying `bindings: { x-scs-function: … }` then minted
* `Destination(broker='x-scs-function')`, so two unrelated services sharing a
* vendor annotation and an address landed on ONE node with a broker half that
* carried no broker information at all. Worse, `{ kafka: {}, x-internal: {} }`
* read as two brokers and refused a conformant document as self-contradictory
* — which also makes any writer of a document a one-line saboteur of its own
* cross-service links. A list that must be extended when AsyncAPI adds a
* binding is the smaller cost.
*
* ── WHY THE ALIASES EARN THEIR ROWS ───────────────────────────────────────
*
* `amqp` → `rabbit` is not an identity. AMQP is a wire protocol and RabbitMQ is
* one implementation of it; a Qpid or ActiveMQ broker speaking AMQP is filed
* under `rabbit` here and the label is wrong about the product. It is mapped
* anyway because the alternative is a guaranteed MISS: Spring's own capture
* calls `@RabbitListener` `rabbit`, so an `amqp` document describing the very
* same queue would sit on a second node and the two would never meet. A label
* that is wrong about the vendor but right about the protocol family joins the
* pair; an honest `amqp` label splits it every time.
*
* The transport-security variants are that argument with the vendor doubt
* removed. AsyncAPI's SERVER vocabulary distinguishes `kafka` from
* `kafka-secure`; its BINDINGS vocabulary does not. Without these rows a
* secured cluster's own document contradicts itself, and with bindings absent
* it is worse and quieter: `kafka-secure <address>` never meets the
* `kafka <address>` Spring capture mints, and nothing reports the miss. TLS is
* a property of the connection, not of the place messages go.
*/
/**
* A protocol name long enough to be a mistake.
*
* The broker is the THIRD string that reaches a graph identifier, alongside the
* address and the operation id, and it is the one that was left unbounded:
* `destinationNodeKey` is `` `${broker} ${address}` `` and `generateId` is
* `` `${label}:${name}` `` — concatenation both, no hashing. A one-megabyte
* protocol in a document that satisfies every other cap was measured producing
* a gigabyte of resident identifier strings, because the phase mints one id per
* node and one per edge. The longest name in AsyncAPI's vocabulary is
* `googlepubsub` at twelve characters, so this bound is generous by more than a
* factor of two and can only be reached on purpose.
*/
const MAX_PROTOCOL_LENGTH = 32;
/**
* Spellings that differ between AsyncAPI's protocol vocabulary and the broker
* names this codebase already mints from source.
*
* Both AMQP versions collapse: `amqp1` is AMQP 1.0, a different wire format for
* the same family, and a service that documents one while its code speaks the
* other is describing one queue, not two. `mqtt5` collapses onto `mqtt` for the
* identical reason.
*/
const PROTOCOL_ALIASES: ReadonlyMap<string, string> = new Map([
['amqp', 'rabbit'],
['amqp1', 'rabbit'],
['kafka-secure', 'kafka'],
['secure-mqtt', 'mqtt'],
['mqtts', 'mqtt'],
['mqtt5', 'mqtt'],
['wss', 'ws'],
['stomps', 'stomp'],
['https', 'http'],
]);
/**
* AsyncAPI's binding vocabulary — the names a `bindings` map key may take.
*
* Closed on purpose; see the header. Adding a protocol here is a deliberate
* act, which is the point: the cost of a missing row is one document's
* destinations, and the cost of an open door is a node keyed on a vendor
* annotation that two unrelated services happen to share.
*/
const BINDING_PROTOCOLS: ReadonlySet<string> = new Set([
'amqp',
'amqp1',
'anypointmq',
'googlepubsub',
'http',
'https',
'ibmmq',
'jms',
'kafka',
'kafka-secure',
'mercure',
'mqtt',
'mqtt5',
'mqtts',
'nats',
'pulsar',
'redis',
'secure-mqtt',
'sns',
'solace',
'sqs',
'stomp',
'stomps',
'ws',
'wss',
]);
/**
* Protocols whose destinations this module refuses to mint, because the address
* alone is not the thing that identifies them.
*
* For a broker, the topic or queue name IS the namespace: two services naming
* `orders.v1` on Kafka are talking about one place, and dropping which cluster
* they used is a bounded, stated trade. For HTTP and WebSocket the HOST is the
* namespace and the address is only a path, so keying on the path alone makes
* every service that exposes `/events` — or `/health`, or `/api/v1/orders` —
* one node. That is unbounded, and it is a false join rather than a lost one.
*
* These are not lost information: an HTTP endpoint is a `Route`, which the
* routes phase already models with the method in its key.
*/
const NON_DESTINATION_PROTOCOLS: ReadonlySet<string> = new Set(['http', 'ws']);
/**
* Shape a protocol NAME must take, applied to both readers.
*
* The pass-through in {@link brokerForProtocol} is an argument about
* UNRECOGNIZED protocols — a name this codebase has not heard of is still the
* document's claim. It is not an argument about arbitrary text. A protocol name
* contains no whitespace, and one that did would collide in the node key, since
* `destinationNodeKey` joins broker and address with a space: `("kafka orders",
* "x")` and `("kafka", "orders x")` are then the same node.
*
* Learned twice. The check was added when that collision was first shown to be
* reachable, then dropped during a rewrite that moved the binding-key filtering
* into its own function — and the test written for the first lesson caught the
* second within the minute.
*/
function isProtocolToken(value: string): boolean {
return /^[a-z0-9][a-z0-9+._-]*$/.test(value);
}
function normalize(protocol: string | undefined): string | undefined {
if (protocol === undefined) return undefined;
const trimmed = protocol.trim().toLowerCase();
if (trimmed === '' || trimmed.length > MAX_PROTOCOL_LENGTH) return undefined;
if (!isProtocolToken(trimmed)) return undefined;
return trimmed;
}
/** True when a broker names a transport whose addresses must not be keyed. */
export function isNonDestinationBroker(broker: string): boolean {
return NON_DESTINATION_PROTOCOLS.has(broker);
}
/**
* Normalize a `servers[].protocol` value to the broker half of a `Destination`
* key. Unrecognized protocols pass through; see the header.
*
* Returns `undefined` for a blank or implausibly long value — silence is not a
* claim, and a key built from an empty string would merge every silent
* document.
*/
export function brokerForProtocol(protocol: string | undefined): string | undefined {
const normalized = normalize(protocol);
if (normalized === undefined) return undefined;
return PROTOCOL_ALIASES.get(normalized) ?? normalized;
}
/**
* Normalize a `bindings` MAP KEY to a broker, answering only for names in
* AsyncAPI's binding vocabulary.
*
* `$ref` and `x-` extensions live in this namespace legitimately, so anything
* unrecognized is silence rather than a broker.
*/
export function brokerForBindingKey(key: string | undefined): string | undefined {
const normalized = normalize(key);
if (normalized === undefined || !BINDING_PROTOCOLS.has(normalized)) return undefined;
return PROTOCOL_ALIASES.get(normalized) ?? normalized;
}

View file

@ -0,0 +1,6 @@
import { SupportedLanguages } from 'gitnexus-shared';
import type { CallExtractionConfig } from '../../call-types.js';
export const zigCallConfig: CallExtractionConfig = {
language: SupportedLanguages.Zig,
};

View file

@ -0,0 +1,417 @@
// gitnexus/src/core/ingestion/call-extractors/zig-static-gating.ts
/**
* Zig static-gating resolver.
*
* Detects calls inside `if (CONST_FALSE)` blocks (and trivial boolean
* extensions: `and`, `or`, `==`/`!=`, parentheses, and prefix `!` negation,
* which tree-sitter-zig parses as `error_union_type`) so the call edge can be
* tagged with `staticGated: true`. The flag lets impact-analysis
* consumers filter out paper-tiger callers that live in dead branches
* gated behind a comptime-known `false` constant.
*
* Conservative by design: we only tag an edge when we can prove the
* gating expression evaluates to `false`. Anything ambiguous → live.
*
* Scope of v1:
*
* (a) **File-local** consts (`pub const FOO = false;`, plus const-to-const
* aliases up to 5 hops), built once per file by `buildZigBoolConstMap`.
* (b) **Cross-file** (`const cfg = @import("./cfg.zig"); if (cfg.FOO)`) is
* NOT resolved yet. The evaluator keeps the seam for it (`importAliases`
* + `lookupBoolsForPath`, consumed by the `field_expression` case), but
* the only caller passes an empty alias map and a lookup that always
* returns `undefined`, because the capture emitter runs in the parse
* worker and sees only the current file. Tracked in #3162. Until then
* every `cfg.FOO` condition folds to unknown, i.e. live.
*
* Also out of scope: multi-hop member access (`cfg.sub.FOO`), re-exported
* consts, runtime-evaluated bools (`const FOO = computeIt();`), and
* `builtin.mode` gates.
*
* Wire-up: `stampZigStaticGating` in ../languages/zig/captures.ts calls
* `collectZigStaticGatedRanges` once per file and marks every call capture
* whose position falls inside a dead range with `@reference.static-gated`;
* the scope-resolution pipeline carries that marker to the CALLS edge.
*/
import type { SyntaxNode } from '../utils/ast-helpers.js';
/** Maximum recursion depth when evaluating a boolean condition expression. */
const MAX_COND_DEPTH = 4;
/** A definite truth value, or `undefined` for "unknown / cannot prove". */
type TriBool = boolean | undefined;
/**
* Per-file table of comptime-known booleans:
* `pub const FOO: bool = false;` → Map { 'FOO' → false }
* `pub const BAR = true;` → Map { 'BAR' → true }
*
* Constants whose RHS is anything other than a bare `boolean` literal
* (e.g. function calls, struct accesses) are intentionally absent —
* they resolve to `undefined`.
*/
export type ZigBoolConstMap = ReadonlyMap<string, boolean>;
/**
* Per-file `@import` alias map, mapping a local identifier to the
* resolved absolute file path of the imported module. Used for the
* `cfg.FOO` cross-file lookup pattern.
*
* `const cfg = @import("./cfg.zig");` → Map { 'cfg' → 'src/cfg.zig' }
*/
export type ZigImportAliasMap = ReadonlyMap<string, string>;
/**
* Cross-file lookup: given an alias-resolved file path, return that
* file's known-bool map. Implemented by the caller — the resolver
* itself stays stateless.
*/
export type ZigBoolConstLookup = (filePath: string) => ZigBoolConstMap | undefined;
// ---------------------------------------------------------------------------
// Phase 2: per-file extraction
// ---------------------------------------------------------------------------
/** Cap on alias-chain hops walked when resolving `const A = B; const B = C; ...`. */
const MAX_ALIAS_HOPS = 5;
/**
* Walk the top-level of a Zig source file and collect known-bool
* constants. Two passes:
*
* Pass 1: collect every top-level `const X = <expr>` where the RHS
* is either a `boolean` literal (recorded in `literals`) or
* a single `identifier` (recorded in `aliases`).
* Pass 2: walk each alias entry up to `MAX_ALIAS_HOPS` hops; if the
* chain terminates at a known-bool literal, record `X` with
* the literal's value. Cycles, chains exceeding the cap, and
* chains exiting file scope (unknown identifier at the root)
* bail to "unknown".
*
* Only `const` decls qualify (not `var`). The function is permissive
* about modifier tokens (`pub`, type annotation) — it reads only the
* identifier name and the RHS expression shape.
*/
export function buildZigBoolConstMap(rootNode: SyntaxNode): ZigBoolConstMap {
const literals = new Map<string, boolean>();
const aliases = new Map<string, string>();
for (const child of rootNode.namedChildren) {
if (child.type !== 'variable_declaration') continue;
const entry = extractBoolConstOrAlias(child);
if (!entry) continue;
if (entry.kind === 'literal') {
literals.set(entry.name, entry.value);
} else {
aliases.set(entry.name, entry.aliasOf);
}
}
// Pass 2: resolve alias chains.
for (const [name, target] of aliases) {
const resolved = resolveAliasChain(name, target, literals, aliases);
if (resolved !== undefined) {
literals.set(name, resolved);
}
}
return literals;
}
function resolveAliasChain(
start: string,
firstTarget: string,
literals: ReadonlyMap<string, boolean>,
aliases: ReadonlyMap<string, string>,
): boolean | undefined {
// Walk: start → firstTarget → aliases.get(firstTarget) → ... up to MAX_ALIAS_HOPS.
// Cycle protection via a visited set seeded with `start` itself.
const visited = new Set<string>();
visited.add(start);
let current: string = firstTarget;
for (let hop = 0; hop < MAX_ALIAS_HOPS; hop++) {
if (visited.has(current)) return undefined; // cycle
visited.add(current);
const lit = literals.get(current);
if (lit !== undefined) return lit;
const next = aliases.get(current);
if (next === undefined) return undefined; // chain exits file scope or hits unknown
current = next;
}
return undefined; // hop cap exceeded
}
type RawDecl =
| { kind: 'literal'; name: string; value: boolean }
| { kind: 'alias'; name: string; aliasOf: string };
function extractBoolConstOrAlias(decl: SyntaxNode): RawDecl | null {
// Require `const` and exclude `var`. tree-sitter-zig parses both with
// the same `variable_declaration` shape; the qualifier is an anonymous
// child token. A `pub var FOO = false;` is mutable global state — its
// initial value is NOT a comptime constant and must not feed gating.
let isConst = false;
let isVar = false;
for (let i = 0; i < decl.childCount; i++) {
const c = decl.child(i);
if (!c || c.isNamed) continue;
if (c.type === 'const') isConst = true;
else if (c.type === 'var') isVar = true;
}
if (!isConst || isVar) return null;
let name: string | undefined;
let value: boolean | undefined;
let aliasOf: string | undefined;
for (const c of decl.namedChildren) {
if (c.type === 'identifier' && name === undefined) {
name = c.text;
continue;
}
if (c.type === 'boolean') {
const t = c.text;
if (t === 'true') value = true;
else if (t === 'false') value = false;
} else if (c.type === 'identifier' && name !== undefined && aliasOf === undefined) {
// Second `identifier` child is the RHS alias target:
// `const B = A;` → name='B', aliasOf='A'.
aliasOf = c.text;
}
}
if (name === undefined) return null;
if (value !== undefined) return { kind: 'literal', name, value };
if (aliasOf !== undefined) return { kind: 'alias', name, aliasOf };
return null;
}
// ---------------------------------------------------------------------------
// Phase 3: dead-range collection + condition evaluation
// ---------------------------------------------------------------------------
/**
* Every source range that is statically dead in this file: the body of an
* `if` whose condition folds to `false`, and the `else` clause of an `if`
* whose condition folds to `true`. Both the statement form (`if (c) { .. }`)
* and the expression form (`const x = if (c) a else b;`) are walked; the
* arms differ only in how the grammar exposes them, see `ifExpressionArms`.
* Line/col ranges, so a capture layer that
* only keeps `Capture.range` (no node) can still stamp its call sites —
* that is how the scope-resolution provider consumes this module.
*
* One walk per file over every `if`, so a call site is classified by
* position lookup; nesting needs no special case because an inner branch
* inside a dead body is inside the dead body's range already.
*/
export interface ZigGatedRange {
readonly startLine: number;
readonly startCol: number;
readonly endLine: number;
readonly endCol: number;
}
export function collectZigStaticGatedRanges(
rootNode: SyntaxNode,
localBools: ZigBoolConstMap,
importAliases: ZigImportAliasMap,
lookupBoolsForPath: ZigBoolConstLookup,
): readonly ZigGatedRange[] {
const out: ZigGatedRange[] = [];
for (const node of rootNode.descendantsOfType(['if_statement', 'if_expression'])) {
const cond = findIfCondition(node);
const result = cond
? evalCond(cond, localBools, importAliases, lookupBoolsForPath, 0)
: undefined;
let dead: SyntaxNode | null = null;
if (node.type === 'if_statement') {
if (result === false) dead = node.childForFieldName('body');
if (result === true) dead = node.namedChildren.find((c) => c.type === 'else_clause') ?? null;
} else {
const arms = ifExpressionArms(node);
if (result === false) dead = arms.consequence;
if (result === true) dead = arms.alternative;
}
if (dead) {
out.push({
startLine: dead.startPosition.row + 1,
startCol: dead.startPosition.column,
endLine: dead.endPosition.row + 1,
endCol: dead.endPosition.column,
});
}
}
return out;
}
/**
* The two arms of an `if_expression` (`const x = if (c) a else b;`). Unlike
* `if_statement` the grammar gives them no field names and no `else_clause`
* wrapper: the consequence is the first named child after the closing `)`
* of the condition, the alternative is the first named child after the
* anonymous `else` token. Either may be absent.
*/
function ifExpressionArms(node: SyntaxNode): {
consequence: SyntaxNode | null;
alternative: SyntaxNode | null;
} {
let consequence: SyntaxNode | null = null;
let alternative: SyntaxNode | null = null;
let slot: 'none' | 'consequence' | 'alternative' = 'none';
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (!c) continue;
if (!c.isNamed) {
if (c.type === ')' && slot === 'none') slot = 'consequence';
else if (c.type === 'else') slot = 'alternative';
continue;
}
if (slot === 'consequence' && !consequence) consequence = c;
else if (slot === 'alternative' && !alternative) alternative = c;
}
return { consequence, alternative };
}
/** Is a (1-based line, 0-based col) position inside one of `ranges`? */
export function isPositionStaticGated(
line: number,
col: number,
ranges: readonly ZigGatedRange[],
): boolean {
for (const r of ranges) {
if (line < r.startLine || line > r.endLine) continue;
if (line === r.startLine && col < r.startCol) continue;
if (line === r.endLine && col >= r.endCol) continue;
return true;
}
return false;
}
/** Pick the condition node out of an `if_statement`. The condition is
* the first named child (the body / else branches come after). */
function findIfCondition(ifNode: SyntaxNode): SyntaxNode | null {
return ifNode.namedChildren[0] ?? null;
}
/**
* Evaluate a boolean condition expression to a tribool.
*
* Handles only the shapes we can resolve symbolically; everything else
* returns `undefined` ("unknown — treat as live").
*/
function evalCond(
node: SyntaxNode,
localBools: ZigBoolConstMap,
importAliases: ZigImportAliasMap,
lookupBoolsForPath: ZigBoolConstLookup,
depth: number,
): TriBool {
if (depth > MAX_COND_DEPTH) return undefined;
switch (node.type) {
case 'boolean': {
// Bare literal: `if (false)`.
if (node.text === 'true') return true;
if (node.text === 'false') return false;
return undefined;
}
case 'identifier': {
// Bare flag check: `if (FOO)`.
const v = localBools.get(node.text);
return v === undefined ? undefined : v;
}
case 'field_expression': {
// `cfg.FOO` — alias hop.
const obj = node.namedChildren[0];
const member = node.namedChildren[1];
if (obj?.type !== 'identifier' || member?.type !== 'identifier') {
return undefined;
}
const targetFile = importAliases.get(obj.text);
if (!targetFile) return undefined;
const targetBools = lookupBoolsForPath(targetFile);
if (!targetBools) return undefined;
const v = targetBools.get(member.text);
return v === undefined ? undefined : v;
}
case 'binary_expression': {
// `lhs and rhs` / `lhs or rhs` / `lhs == rhs` / `lhs != rhs`.
const op = findOperatorToken(node);
const lhs = node.namedChildren[0];
const rhs = node.namedChildren[1];
if (!lhs || !rhs) return undefined;
const l = evalCond(lhs, localBools, importAliases, lookupBoolsForPath, depth + 1);
const r = evalCond(rhs, localBools, importAliases, lookupBoolsForPath, depth + 1);
if (op === 'and') {
// `false and *` = false; `* and false` = false.
if (l === false || r === false) return false;
if (l === true && r === true) return true;
return undefined;
}
if (op === 'or') {
// Only `false or false` is provably false.
if (l === false && r === false) return false;
if (l === true || r === true) return true;
return undefined;
}
if (op === '==') {
// Equality folds when both sides are known booleans.
// `FOO == false` ↔ `!FOO`; `FOO == true` ↔ `FOO`.
if (l === undefined || r === undefined) return undefined;
return l === r;
}
if (op === '!=') {
if (l === undefined || r === undefined) return undefined;
return l !== r;
}
// Other comparison ops (<, >, …) — not booleans we can prove.
return undefined;
}
case 'parenthesized_expression': {
// `(cond)`: transparent.
const inner = node.namedChildren[0];
if (!inner) return undefined;
return evalCond(inner, localBools, importAliases, lookupBoolsForPath, depth + 1);
}
case 'error_union_type': {
// tree-sitter-zig has no unary `!` node: prefix `!cond` (boolean
// negation) parses as `error_union_type` because the same `!` token
// introduces error-union types. In condition position that reading is
// never a type, so negate whatever the operand folds to: an
// identifier, a literal, or a parenthesized compound like `!(A and B)`.
const inner = node.namedChildren[0];
if (!inner) return undefined;
const v = evalCond(inner, localBools, importAliases, lookupBoolsForPath, depth + 1);
if (v === undefined) return undefined;
return !v;
}
default:
return undefined;
}
}
/**
* Pull the textual operator (e.g. "and", "or") out of a
* `binary_expression`. In tree-sitter-zig, the operator is an
* anonymous child token whose `type` equals the operator string.
*/
function findOperatorToken(binExpr: SyntaxNode): string | undefined {
for (let i = 0; i < binExpr.childCount; i++) {
const c = binExpr.child(i);
if (!c) continue;
if (!c.isNamed) {
// Anonymous tokens for these ops carry their text as the type.
if (c.type === 'and' || c.type === 'or' || c.type === '==' || c.type === '!=') {
return c.type;
}
}
}
return undefined;
}

View file

@ -0,0 +1,42 @@
import { SupportedLanguages } from 'gitnexus-shared';
import type { ClassExtractionConfig, ClassLikeNodeLabel } from '../../class-types.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import {
isZigFileStruct,
ZIG_CONTAINER_TYPES,
zigContainerLabel,
zigContainerName,
} from '../../languages/zig/captures.js';
/**
* Zig containers (struct/enum/union/opaque) are anonymous in the grammar:
*
* const Point = struct { ... };
* pub fn List(comptime T: type) type { return struct { ... }; }
* fn build() void { const R = struct { ... }; sort(struct { fn lt … }.lt); }
*
* The identity is the binding name (first identifier of the parent
* variable_declaration), the generic type constructor's name, or — for a
* FUNCTION-LOCAL or ANONYMOUS container — a synthesized `host$Name` /
* `host$N` (F8). `zigContainerName` is the single source shared with the
* field/method extractors and the owner walk, so owner ids and node ids
* agree by construction. Which of the (up to three) ZIG_QUERIES rules that
* match one container gets to mint it is decided by the provider's
* `shouldSkipDefinitionCapture` (`isZigRedundantContainerCapture`).
*/
const extractZigContainerName = (node: SyntaxNode, filePath?: string): string | undefined =>
zigContainerName(node, filePath);
const extractZigContainerType = (node: SyntaxNode): ClassLikeNodeLabel | undefined => {
// The file itself, when it declares top-level fields (file-struct); a
// namespace-only file is not a type — `extract` then yields no symbol.
if (node.type === 'source_file') return isZigFileStruct(node) ? 'Struct' : undefined;
return zigContainerLabel(node);
};
export const zigClassConfig: ClassExtractionConfig = {
language: SupportedLanguages.Zig,
typeDeclarationNodes: [...ZIG_CONTAINER_TYPES, 'source_file'],
extractName: extractZigContainerName,
extractType: extractZigContainerType,
};

View file

@ -57,6 +57,7 @@ const CLASS_LIKE_LABELS = new Set<ClassLikeNodeLabel>([
'Interface',
'Enum',
'Record',
'Union',
]);
const extractScopeSegmentsFromNode = (
@ -129,11 +130,15 @@ export function createClassExtractor(config: ClassExtractionConfig): ClassExtrac
fallback?: {
name?: string;
type?: NodeLabel | null;
filePath?: string;
},
): ExtractedClassSymbol | null => {
if (!typeDeclarationSet.has(node.type)) return null;
const name = config.extractName?.(node) ?? extractTypeNameFromNode(node) ?? fallback?.name;
const name =
config.extractName?.(node, fallback?.filePath) ??
extractTypeNameFromNode(node) ??
fallback?.name;
const type =
config.extractType?.(node) ??
DEFAULT_LABEL_BY_NODE_TYPE[node.type] ??

View file

@ -3,7 +3,7 @@ import type { SyntaxNode } from './utils/ast-helpers.js';
export type ClassLikeNodeLabel = Extract<
NodeLabel,
'Class' | 'Struct' | 'Interface' | 'Enum' | 'Record'
'Class' | 'Struct' | 'Interface' | 'Enum' | 'Record' | 'Union'
>;
export interface ExtractedClassSymbol {
@ -41,6 +41,8 @@ export interface ClassExtractor {
fallback?: {
name?: string;
type?: NodeLabel | null;
/** Repo-relative path of the file being extracted, when known. */
filePath?: string;
},
): ExtractedClassSymbol | null;
extractQualifiedName(node: SyntaxNode, simpleName: string): string | null;
@ -72,7 +74,9 @@ export interface ClassExtractionConfig {
*/
qualifiedNodeId?: boolean;
scopeNameNodeTypes?: string[];
extractName?: (node: SyntaxNode) => string | undefined;
/** `filePath` is supplied when known (definition-phase extraction) — a
* language whose file IS a type names it from the path. */
extractName?: (node: SyntaxNode, filePath?: string) => string | undefined;
extractType?: (node: SyntaxNode) => ClassLikeNodeLabel | undefined;
extractScopeSegments?: (node: SyntaxNode) => string[] | null | undefined;
extractTemplateArguments?: (node: SyntaxNode) => string[] | undefined;

View file

@ -242,3 +242,64 @@ export const rubyExportChecker: ExportChecker = (_node, _name) => true;
/** Dart: public if no leading underscore (convention, same as Python). */
export const dartExportChecker: ExportChecker = (_node, name) => !name.startsWith('_');
/** Zig declaration node types whose `pub` / `export` keyword child marks the symbol public. */
const ZIG_DECL_TYPES = new Set(['function_declaration', 'variable_declaration']);
/**
* Zig: walk to the enclosing decl, scan its direct children for an unnamed `pub`
* or `export` keyword token (tree-sitter-zig models both as anonymous keyword
* children of function_declaration / variable_declaration). Two different
* facts share this one flag, on purpose:
* - `pub` is Zig-module visibility — reachable from another `.zig` file
* through `@import`;
* - `export` is C-ABI linkage (`export fn add(...)`) — the symbol lands in
* the object file for FFI callers, and it never carries `pub`.
* `isExported` means "visible outside this compilation unit" graph-wide (C uses
* external linkage for the same flag), so both qualify. The `visibility`
* property is the Zig-only fact and is `pub`-only — see `hasZigPubKeyword`:
* an `export fn` without `pub` is public to C and PRIVATE to other Zig files.
* Container fields (struct/enum variants) are public if their enclosing
* variable_declaration is public.
*
* The walk stops at the FIRST declaration it reaches: a `fn` inside
* `pub const T = struct { … }` carries its own `pub` (or not), independent of
* the container's. Continuing up to the wrapper marked every private method of
* a public container as exported.
*/
export const zigExportChecker: ExportChecker = (node, _name) => {
let current: SyntaxNode | null = node;
while (current) {
if (ZIG_DECL_TYPES.has(current.type)) return hasZigVisibilityKeyword(current);
current = current.parent;
}
return false;
};
/**
* Does this Zig declaration carry a `pub` or `export` keyword child? Feeds
* `isExported` (visible outside the compilation unit — to Zig importers OR to
* C callers).
*/
export function hasZigVisibilityKeyword(declNode: SyntaxNode): boolean {
for (let i = 0; i < declNode.childCount; i++) {
const child = declNode.child(i);
if (child?.type === 'pub' || child?.type === 'export') return true;
}
return false;
}
/**
* Does this Zig declaration carry a `pub` keyword child? Feeds `visibility`,
* the Zig-module fact: per the language reference, only `pub` declarations are
* accessible from another file via `@import`; `export` alone gives C linkage
* and leaves the declaration private to Zig code. So `export fn c_add` reports
* `isExported: true` (FFI surface) with `visibility: 'private'` (Zig surface)
* — two facts, two properties, deliberately not one.
*/
export function hasZigPubKeyword(declNode: SyntaxNode): boolean {
for (let i = 0; i < declNode.childCount; i++) {
if (declNode.child(i)?.type === 'pub') return true;
}
return false;
}

View file

@ -0,0 +1,58 @@
import { SupportedLanguages } from 'gitnexus-shared';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import type { FieldExtractionConfig } from '../generic.js';
import { ZIG_CONTAINER_TYPES, zigContainerName } from '../../languages/zig/captures.js';
/**
* Zig containers (struct/enum/union/opaque) are anonymous in tree-sitter-zig;
* the binding name is the first identifier child of the parent
* variable_declaration, or the enclosing generic type constructor's name —
* `zigContainerName` is the single source.
*/
const extractZigOwnerName = (node: SyntaxNode, filePath?: string): string | undefined =>
zigContainerName(node, filePath);
/**
* Container fields appear as direct children of struct_declaration /
* enum_declaration / union_declaration — there is no separate body wrapper
* in this grammar, so `bodyNodeTypes` is empty and the generic factory's
* "iterate immediate children" pass picks them up.
*/
export const zigFieldConfig: FieldExtractionConfig = {
language: SupportedLanguages.Zig,
// `source_file`: a file-struct's top-level fields belong to the file's Struct.
typeDeclarationNodes: [...ZIG_CONTAINER_TYPES, 'source_file'],
fieldNodeTypes: ['container_field'],
bodyNodeTypes: [],
defaultVisibility: 'public',
extractOwnerName: extractZigOwnerName,
extractName(node) {
const name = node.childForFieldName('name');
// An empty container body (`struct {}`, `opaque {}`) is recovered by
// tree-sitter-zig 1.1.2 as one container_field with a zero-width MISSING
// identifier. Not a field — declining here keeps it out of the field map.
if (name === null || name.text.length === 0) return undefined;
return name.text;
},
extractType(node) {
const typeNode = node.childForFieldName('type');
return typeNode?.text?.trim();
},
extractVisibility() {
// Zig has no per-field visibility — fields inherit the container's
// module-level visibility. Treat as public; the export checker decides
// what the *container* exposes.
return 'public';
},
isStatic() {
return false;
},
isReadonly() {
return false;
},
};

View file

@ -34,7 +34,7 @@ export interface FieldExtractionConfig {
/** Default visibility when no modifier is present */
defaultVisibility: FieldVisibility;
/** Extract owner type name from a type declaration node. */
extractOwnerName?: (node: SyntaxNode) => string | undefined;
extractOwnerName?: (node: SyntaxNode, filePath?: string) => string | undefined;
/** Find body nodes inside a type declaration node. */
findBodyNodes?: (node: SyntaxNode) => SyntaxNode[];
/**
@ -103,7 +103,8 @@ export function createFieldExtractor(config: FieldExtractionConfig): FieldExtrac
extract(node: SyntaxNode, context: FieldExtractorContext): ExtractedFields | null {
if (!this.isTypeDeclaration(node)) return null;
const ownerFqn = config.extractOwnerName?.(node) ?? node.childForFieldName('name')?.text;
const ownerFqn =
config.extractOwnerName?.(node, context.filePath) ?? node.childForFieldName('name')?.text;
if (!ownerFqn) return null;
const fields: FieldInfo[] = [];
@ -148,6 +149,17 @@ export function createFieldExtractor(config: FieldExtractionConfig): FieldExtrac
if (result.length === 0 && bodyField) {
result.push(bodyField);
}
// Grammars with no body wrapper at all: a config that declares NO
// `bodyNodeTypes` (tree-sitter-zig's struct_declaration holds its
// container_field children directly) uses the type-declaration node
// itself as the body. The downstream walk filters by `fieldNodeTypes`,
// so unrelated children are ignored. Deliberately NOT a fallback for
// configs that do declare body wrappers: for them a node without its
// wrapper is a bodiless declaration, and scanning it would change every
// such language for no field it could find.
if (result.length === 0 && bodyNodeSet.size === 0) {
result.push(node);
}
return result;
}

View file

@ -50,3 +50,13 @@ export const SPRING_NON_HTTP_HANDLERS_FEATURE: AnalysisFeatureDescriptor = {
version: 1,
appliesTo: (filePaths) => filePaths.some(isJvmSourceFile),
};
/**
* Route/handler binding extraction, including vendor `@Win*Mapping` aliases.
* Existing indexes keep a stale Route set until this version is stamped.
*/
export const SPRING_ROUTE_BINDINGS_FEATURE: AnalysisFeatureDescriptor = {
id: 'spring.route-bindings',
version: 2,
appliesTo: (filePaths) => filePaths.some(isJvmSourceFile),
};

View file

@ -281,6 +281,31 @@ export interface SpringDestinationResolvers {
* order is fixed now rather than renegotiated later. Nothing supplies it
* today, so step 4 is a no-op and such destinations stay unresolved with the
* reason the earlier step recorded.
*
* ── STILL UNSUPPLIED, AND NOW FOR A REASON RATHER THAN FOR WANT OF A READER ──
*
* `core/ingestion/asyncapi/document.ts` reads AsyncAPI 3.x documents, and
* `pipeline-phases/spring-destinations.ts` emits what they state as
* destinations of their own. It does NOT feed this hook, and the gap is a
* decision:
*
* A document names addresses; it does not name the method that uses one. To
* hand an address to THIS candidate, something has to choose which of the
* document's operations belongs to it. Partitioning by (broker, action) is
* the only division both sides agree on, and it is a weak one: a service with
* several listeners on one broker puts them all in one bucket. Any bucket
* holding more than one operation forces a heuristic, and a wrong heuristic
* puts a REAL address on a joining node under the wrong site — a false
* connection wearing the clothes of a resolved one, which is the exact
* outcome this module's keying rule exists to prevent. Only a bucket of size
* one is a fact rather than a guess.
*
* Two things would change that, and neither is a heuristic: a document whose
* operations carry the implementing symbol, or a configuration source that
* answers the `${key}` this candidate already recorded. The second is the
* stronger of the two — a key-to-value lookup is exact where a document match
* is a guess — and it wants its own resolver rather than this one, because
* what it needs is the placeholder key, not the candidate.
*/
readonly specification?: (candidate: SpringDestinationCandidate) => string | null;
}

View file

@ -0,0 +1,27 @@
const DEFAULT_SPRING_VENDOR_PREFIXES = 'Win';
let cachedRawValue: string | undefined;
let cachedPrefixes: ReadonlySet<string> | undefined;
/** Return the configured vendor prefixes as a canonical, duplicate-free set. */
export function springVendorPrefixes(): ReadonlySet<string> {
const rawValue = process.env.GITNEXUS_SPRING_VENDOR_PREFIXES ?? DEFAULT_SPRING_VENDOR_PREFIXES;
if (cachedPrefixes && cachedRawValue === rawValue) return cachedPrefixes;
cachedRawValue = rawValue;
cachedPrefixes = new Set(
rawValue
.split(',')
.map((prefix) => prefix.trim())
.filter(Boolean),
);
return cachedPrefixes;
}
/**
* Stable metadata value for the route semantics controlled by the prefix list.
* Sorting makes equivalent lists independent of declaration order.
*/
export function springVendorPrefixesKey(): string {
return JSON.stringify([...springVendorPrefixes()].sort());
}

View file

@ -0,0 +1,36 @@
/**
* Zig import resolution.
*
* Local-file imports (`@import("./foo.zig")`, `@import("foo.zig")`) resolve
* relative to the importer. Bare names (`@import("bar")`) resolve through
* build.zig.zon `.path` deps when a parsed ZigBuildZonConfig is available
* (see language-config.ts `loadZigBuildConfig`). Everything unresolvable —
* `std`, `builtin`, `root`, `.url`-based deps — returns an empty result so
* it doesn't produce ghost import edges.
*/
import { SupportedLanguages } from 'gitnexus-shared';
import type { ImportResolutionConfig, ImportResolverStrategy } from '../types.js';
import { resolveZigImportInternal } from '../zig.js';
const stripQuotes = (s: string): string => s.replace(/^['"]|['"]$/g, '');
export const zigImportStrategy: ImportResolverStrategy = (rawImportPath, filePath, ctx) => {
// tree-sitter-zig captures the string with surrounding quotes.
const stripped = stripQuotes(rawImportPath);
const resolved = resolveZigImportInternal(
filePath,
stripped,
ctx.allFilePaths,
ctx.configs.zigBuildZon ?? null,
);
// Unresolvable (stdlib / builtin / .url dep / missing file): stop the
// chain with an empty result rather than falling through to suffix
// matching, which could ghost-match an unrelated same-named file.
return { kind: 'files', files: resolved ? [resolved] : [] };
};
export const zigImportConfig: ImportResolutionConfig = {
language: SupportedLanguages.Zig,
strategies: [zigImportStrategy],
};

View file

@ -0,0 +1,210 @@
/**
* Zig module import resolution — internal helpers.
*
* Zig imports take three shapes:
* const std = @import("std"); → stdlib, unresolvable
* const builtin = @import("builtin"); → compiler builtin, unresolvable
* const root = @import("root"); → user's main module, unresolvable here
* const foo = @import("./foo.zig"); → relative path
* const foo = @import("foo.zig"); → also relative (Zig treats unprefixed
* paths with a `.zig` extension as
* filesystem-relative to the importer)
* const lp = @import("lightpanda"); → the repo's OWN module, declared by
* its root build.zig (`b.addModule`)
* const bar = @import("bar"); → package dep declared in build.zig.zon
*
* Bare-name resolution is handled when a parsed ZigBuildZonConfig is
* supplied (see `loadZigBuildConfig`). The import table of the build module
* the importer belongs to comes first (`buildModules` — per-module
* `addImport` aliases, see `zigModulesContaining`), then the root
* build.zig's named modules flattened repo-wide (`rootModules`, name → root
* file — a repo with no build.zig.zon still resolves them). `.url`-based deps unpack into a build cache outside
* the repo and so are returned as null; `.path`-based deps are resolved
* through the root the dep's own build.zig declares, then the conventional
* `<dep_root>/src/root.zig`, `<dep_root>/src/<name>.zig`,
* `<dep_root>/src/main.zig` layouts.
*/
import {
normalizeZigDepPath,
type ZigBuildModule,
type ZigBuildZonConfig,
} from '../language-config.js';
const ZIG_STDLIB_NAMES = new Set(['std', 'builtin', 'root']);
/**
* The build module(s) a source file belongs to: the module whose ROOT the
* file is, else the module(s) whose root's directory is the deepest prefix
* of the file's path. Membership is not declared anywhere static — a module
* is its root plus whatever that root reaches through relative imports —
* so the directory is the proxy, and several modules may share one
* (`src/main.zig` executable beside `src/root.zig` library is the `zig init`
* layout). A root file is unambiguous by construction: it is its own module.
*/
function zigModulesContaining(
currentFile: string,
modules: readonly ZigBuildModule[],
): ZigBuildModule[] {
const own = modules.filter((m) => m.root === currentFile);
if (own.length > 0) return own;
let best = -1;
let out: ZigBuildModule[] = [];
for (const mod of modules) {
const slash = mod.root.lastIndexOf('/');
const dir = slash === -1 ? '' : mod.root.slice(0, slash);
if (dir !== '' && !currentFile.startsWith(`${dir}/`)) continue;
if (dir.length > best) {
best = dir.length;
out = [mod];
} else if (dir.length === best) {
out.push(mod);
}
}
return out;
}
/**
* A bare `@import("<name>")` through the containing module(s)' own import
* tables. `undefined` when no containing module binds the name (the caller
* falls back to the repo-wide tables); `null` when the containing modules
* DISAGREE — two same-directory modules that bind one alias to different
* roots — or when ANY containing module binds the alias to a root that is
* not indexed (a generated or skipped file), including the mixed case
* where a sibling module's target *is* indexed. Both are fail-closed on purpose: the module's
* own table is the authority for the alias, and falling through to the
* repo-wide map would reintroduce the first-wins answer this table exists
* to remove, under a name the module never meant.
*/
function resolveThroughBuildModules(
currentFile: string,
importPath: string,
allFiles: ReadonlySet<string>,
modules: readonly ZigBuildModule[],
): string | null | undefined {
const targets = new Set<string>();
for (const mod of zigModulesContaining(currentFile, modules)) {
const target = mod.imports.get(importPath);
if (target === undefined) continue;
if (!allFiles.has(target)) return null;
targets.add(target);
}
if (targets.size === 0) return undefined;
if (targets.size > 1) return null;
return targets.values().next().value ?? null;
}
/** Resolve a Zig @import argument to a file path in the repository.
* Returns null when the import is a stdlib / builtin / root reference,
* an unresolvable build.zig.zon package dep, or genuinely unresolvable.
*
* `buildZon` (optional) supplies the parsed `.dependencies` map from
* build.zig.zon. */
export function resolveZigImportInternal(
currentFile: string,
importPath: string,
allFiles: ReadonlySet<string>,
buildZon?: ZigBuildZonConfig | null,
): string | null {
// Stdlib / compiler builtin / root — not resolvable from source files alone.
if (ZIG_STDLIB_NAMES.has(importPath)) return null;
// Normalize path separators for the path arithmetic below. The `.zig`
// extension is kept as written: the first candidate is the path as spelled
// and only the fallback appends `.zig` for extension-less spellings.
const trimmed = importPath.replace(/\\/g, '/');
// Absolute paths point outside the repository (Zig itself rejects
// `@import("/abs.zig")` as an import outside the module path). Splitting
// would drop the empty leading component and read `/foo.zig` as an
// importer-relative `foo.zig`, fabricating an in-repo edge.
// A drive-qualified spelling (`C:/foo.zig`, normalized from `C:\foo.zig`)
// is absolute too: it carries a `/`, so without this guard it would take
// the importer-relative branch and probe `src/C:/foo.zig`. Same test as
// `normalizeZigDepPath`.
if (trimmed.startsWith('/') || /^[A-Za-z]:\//.test(trimmed)) return null;
// Path-bearing import: resolve relative to the current file's directory.
// Zig allows both "./foo.zig" and "foo.zig" — both are filesystem-relative.
if (trimmed.endsWith('.zig') || trimmed.includes('/')) {
const currentDir = currentFile.split('/').slice(0, -1);
const parts = trimmed.split('/');
for (const part of parts) {
if (part === '' || part === '.') continue;
if (part === '..') {
// Above the repository root: the import names a file outside the
// repo, so it must not alias a same-named root file (`../bar.zig`
// from `main.zig` is NOT `bar.zig`).
if (currentDir.length === 0) return null;
currentDir.pop();
} else {
currentDir.push(part);
}
}
const candidate = currentDir.join('/');
if (allFiles.has(candidate)) return candidate;
if (allFiles.has(candidate + '.zig')) return candidate + '.zig';
return null;
}
// Bare name without extension or slashes (e.g. @import("bar")).
if (buildZon) {
// First the import table of the build module the importer belongs to:
// an alias is scoped to the module whose `addImport` declared it, so
// this is the only table that can tell `app`'s `@import("config")` from
// `tool`'s. A disagreement between same-directory modules is `null`
// here and stops the chain — the repo-wide fallbacks below would only
// reintroduce the first-wins answer.
if (buildZon.buildModules !== undefined && buildZon.buildModules.length > 0) {
const scoped = resolveThroughBuildModules(
currentFile,
importPath,
allFiles,
buildZon.buildModules,
);
if (scoped !== undefined) return scoped;
}
// The repo's own modules, as its root build.zig names them
// (`b.addModule("lightpanda", .{ .root_source_file = b.path("src/lightpanda.zig") })`),
// take precedence: that declaration is exactly what an in-repo
// `@import("lightpanda")` means, whatever the zon says. `std` / `builtin`
// / `root` were rejected above and can never be reached from here.
// Authoritative when it binds the name: a root that is not indexed (a
// generated or skipped file) is `null`, never a fall-through to a
// same-named zon dep — that would be a different declaration answering
// under the name (gitnexus-check on fe24b37f; same rule as the
// build-module tables above).
const rootModule = buildZon.rootModules?.get(importPath);
if (rootModule !== undefined) return allFiles.has(rootModule) ? rootModule : null;
// Then build.zig.zon `.path` deps.
const depPath = buildZon.pathDeps.get(importPath);
if (depPath) {
const normalized = normalizeZigDepPath(depPath);
if (normalized !== null) {
// What the dep's own build.zig declares comes first (its
// `addModule` root_source_file — see `parseZigBuildModuleRoots`), then
// the conventional layouts: `src/root.zig` (the `zig init` library
// root since 0.12), `src/<name>.zig` (older name-matched convention),
// `src/main.zig` (executables / older inits). A dep at `.path = "."`
// (the repo itself) normalizes to '' and must not grow a leading slash
// — `allFiles` keys are repo-relative.
const prefix = normalized === '' ? '' : `${normalized}/`;
const candidates = [
...(buildZon.moduleRoots?.get(importPath) ?? []),
`${prefix}src/root.zig`,
`${prefix}src/${importPath}.zig`,
`${prefix}src/main.zig`,
];
for (const c of candidates) {
if (allFiles.has(c)) return c;
}
}
}
}
// Bare name with no resolution (no build.zig / build.zig.zon, .url-based dep, generated module, or
// unconventional layout).
return null;
}

View file

@ -179,6 +179,69 @@ export interface SwiftPackageConfig {
targets: Map<string, string>;
}
/** Zig package config parsed from build.zig.zon and the root build.zig */
export interface ZigBuildZonConfig {
/**
* Map of dependency name -> the raw `.path = "..."` value, exactly as
* written in build.zig.zon (relative to the repo root, and possibly
* escaping it: `../local_dep`). Consumers normalize — see
* `normalizeZigDepPath` below, which rejects absolute
* and repo-escaping values. `.url`-based deps cannot be resolved to a
* repo-local file (they unpack into a build cache outside the repo) and so
* are not included here.
*/
pathDeps: Map<string, string>;
/**
* Per path-dep: repo-relative root source files the dep's own `build.zig`
* declares (`b.addModule("name", .{ .root_source_file = b.path("src/x.zig")
* })`), keyed by dep name, in file order. Entries whose module name matches
* the dep name come first — that is the module a consumer's
* `@import("<dep>")` maps to under the ecosystem convention that the zon key
* and the module name agree. Absent (or empty) when the dep has no readable
* `build.zig`; the resolver then falls back to the conventional layouts.
*/
moduleRoots?: Map<string, readonly string[]>;
/**
* Modules the repo's OWN root `build.zig` declares under an importable
* name, module name → repo-relative root source file
* (`b.addModule("lp", .{ .root_source_file = b.path("src/lp.zig") })`, or a
* `createModule` binding later named through `addImport("lp", binding)`).
* These are what an in-repo `@import("lp")` means — the most common shape in
* single-package repos, where every file imports the package's own root
* module by name. Independent of `build.zig.zon`: a repo with a `build.zig`
* and no zon still resolves them. See `parseZigRootModules`.
*/
rootModules?: Map<string, string>;
/**
* Every build module the root `build.zig` declares, each with ITS OWN
* import table — `addModule` / `createModule` roots and the root modules of
* `addExecutable` / `addLibrary` / `addTest` artifacts, with the aliases
* their `addImport("<alias>", …)` calls and `.imports = &.{ … }` fields
* bind. `rootModules` flattens all of those into one first-wins map, which
* is wrong as soon as two modules bind one alias to different roots (an
* `app` and a `tool` executable that each `addImport("config", …)` their
* own `config.zig`): the second module's files resolved to the first
* module's target. The resolver walks a source file to its containing
* module(s) and consults their tables first — see
* `resolveZigImportInternal` / `parseZigBuildModules`.
*/
buildModules?: readonly ZigBuildModule[];
}
/** One build module of the root `build.zig` — see `ZigBuildZonConfig.buildModules`. */
export interface ZigBuildModule {
/** The `addModule("<name>", …)` name; absent for `createModule` bindings
* and artifact root modules, which are reachable only through aliases. */
readonly name?: string;
/** Repo-relative root source file (`b.path("src/x.zig")`). */
readonly root: string;
/** Alias → repo-relative root source file, as this module's own
* `addImport` calls and `.imports` field declare it. Includes aliases to
* a path dep's module (`addImport("api", dep.module("core"))`) when the
* dep's build.zig declares that module. */
readonly imports: ReadonlyMap<string, string>;
}
// ============================================================================
// LANGUAGE-SPECIFIC CONFIG LOADERS
// ============================================================================
@ -548,6 +611,703 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise<SwiftPac
return null;
}
/**
* Load the Zig build configuration a repo's `build.zig.zon` + root `build.zig`
* declare: `.path` deps (and the roots their own build.zig names) from the
* zon, and the repo's own named modules from the root build.zig. Either file
* may be missing — a repo with a `build.zig` but no `build.zig.zon` still
* resolves `@import("<own module>")`. Null only when neither contributes.
*
* `build.zig.zon` is Zig source (an anonymous-struct literal), not JSON.
* Rather than pull in a tree-sitter parse for one file, we use a small
* regex-based extractor that handles the common shapes:
*
* .dependencies = .{
* .ziggit_pkg = .{
* .url = "https://...",
* .hash = "1220...",
* },
* .local_dep = .{
* .path = "../local_dep",
* },
* },
*
* Limitations (intentional — bail to null on anything weirder):
* - Only the top-level `.dependencies = .{ ... }` block is parsed (brace
* depth 1); a same-named field nested in another struct is ignored.
* - Each dep entry is matched by a single shape: `.<name> = .{ ... }`
* where `<name>` is a bare identifier (no `@"…"` quoted form).
* - Only `.path = "..."` is captured. `.url` deps are left unresolved
* because their unpacked location lives outside the repo
* (.zig-cache/p/<hash>/ or ~/.cache/zig/p/<hash>/) and is therefore
* not in our `allFilePaths` set.
* - `//` line comments are stripped before scanning (string-aware, so a
* `//` inside `.url = "https://…"` survives), and brace matching skips
* string literals — a commented-out `.path` or a `}` inside a comment
* or string cannot declare a dep or truncate the block.
*/
export async function loadZigBuildConfig(repoRoot: string): Promise<ZigBuildZonConfig | null> {
let config: ZigBuildZonConfig | null = null;
try {
const raw = await fs.readFile(path.join(repoRoot, 'build.zig.zon'), 'utf-8');
config = parseZigBuildZon(raw);
} catch {
// No zon (or unreadable): the root build.zig may still declare modules.
}
// The repo's own importable modules, from its root build.zig. Independent
// of the zon: `@import("<own module>")` is how single-package repos refer
// to their root file from every other file.
let rootModules: Map<string, string> | undefined;
let rootBuildZig: string | null = null;
try {
rootBuildZig = await fs.readFile(path.join(repoRoot, 'build.zig'), 'utf-8');
const parsed = parseZigRootModules(rootBuildZig);
if (parsed.size > 0) rootModules = parsed;
} catch {
// No root build.zig — nothing to declare.
}
if (config === null) {
if (rootBuildZig === null) return null;
// No zon: no path deps, so `dep.module(…)` operands resolve to nothing.
const buildModules = parseZigBuildModules(rootBuildZig);
if (!rootModules && buildModules.length === 0) return null;
return {
pathDeps: new Map(),
...(rootModules ? { rootModules } : {}),
...(buildModules.length > 0 ? { buildModules } : {}),
};
}
// A path dep's importable root is whatever ITS build.zig declares, not a
// fixed layout: read `root_source_file` per `addModule` and remember it
// repo-relative. Best effort — an unreadable build.zig just leaves the
// conventional-layout fallback in place.
const moduleRoots = new Map<string, readonly string[]>();
// Per path dep: the modules its build.zig NAMES (`addModule("core", …)`),
// repo-relative — what a root-build.zig `dep.module("core")` operand means.
const depModules = new Map<string, ReadonlyMap<string, string>>();
for (const [depName, depPath] of config.pathDeps) {
const rel = normalizeZigDepPath(depPath);
if (rel === null) continue;
let buildZig: string;
try {
buildZig = await fs.readFile(path.join(repoRoot, rel, 'build.zig'), 'utf-8');
} catch {
continue;
}
const prefixed = (r: string): string => (rel === '' ? r : `${rel}/${r}`);
const roots = parseZigBuildModuleRoots(buildZig, depName).map(prefixed);
if (roots.length > 0) moduleRoots.set(depName, roots);
const named = new Map<string, string>();
for (const mod of parseZigBuildModules(buildZig)) {
if (mod.name !== undefined && !named.has(mod.name)) named.set(mod.name, prefixed(mod.root));
}
if (named.size > 0) depModules.set(depName, named);
}
const buildModules = rootBuildZig === null ? [] : parseZigBuildModules(rootBuildZig, depModules);
return {
...config,
...(moduleRoots.size > 0 ? { moduleRoots } : {}),
...(rootModules ? { rootModules } : {}),
...(buildModules.length > 0 ? { buildModules } : {}),
};
}
/**
* Normalize a `.path` value from build.zig.zon into a repo-relative form.
* Returns null for paths that escape the repo root (start with `..`) or
* are absolute — those point to files we don't index. `.` / `./` normalize
* to the empty string (the repo root itself). Shared with the import
* resolver so both sides agree on which deps are in-repo.
*/
export function normalizeZigDepPath(depPath: string): string | null {
// Normalize separators BEFORE the absolute check so every Windows spelling
// is visible to it: POSIX (`/x`), drive (`C:\x`, `C:/x`), root-relative
// (`\x` → `/x`) and UNC (`\\server\share` → `//server/share`) paths all
// point outside the repository.
const normalized = depPath.replace(/\\/g, '/');
if (normalized.startsWith('/') || /^[A-Za-z]:\//.test(normalized)) return null;
const parts: string[] = [];
for (const part of normalized.split('/')) {
if (part === '' || part === '.') continue;
if (part === '..') {
if (parts.length === 0) return null;
parts.pop();
} else {
parts.push(part);
}
}
return parts.join('/');
}
/**
* The `root_source_file` paths a `build.zig` declares, dep-relative, with the
* module whose `addModule("<name>", …)` name equals `preferredName` first.
*
* Reads two shapes, which between them cover `zig init` output and the
* common hand-written build scripts:
* - `b.addModule("name", .{ .root_source_file = b.path("src/root.zig") })`
* - any other `.root_source_file = b.path("…")` (exe/lib/test artifacts),
* kept as unnamed fallbacks in file order.
* A `.zig` under `b.path` is required — `.{ .cwd_relative = … }` and
* `LazyPath` values computed at build time are not resolvable statically and
* are skipped. Duplicates collapse to the first occurrence.
*/
export function parseZigBuildModuleRoots(buildZig: string, preferredName: string): string[] {
const named: string[] = [];
const unnamed: string[] = [];
const seen = new Set<string>();
const add = (into: string[], p: string): void => {
const norm = normalizeZigDepPath(p);
if (norm === null || norm === '' || !norm.endsWith('.zig') || seen.has(norm)) return;
seen.add(norm);
into.push(norm);
};
const rootRe = /\.root_source_file\s*=\s*b\.path\(\s*"([^"\n]+)"\s*\)/;
// The named module: scan the whole `addModule(…)` argument list, balanced
// on parentheses, so a nested field before `.root_source_file` (`.imports =
// &.{ .{ … } }`) does not end the match early — a `[^}]*` regex stopped at
// that inner `}` and silently demoted the module to an unnamed fallback.
const text = stripZonComments(buildZig);
const mask = zonStringMask(text);
const callRe = /\baddModule\s*\(/g;
let m: RegExpExecArray | null;
while ((m = callRe.exec(text)) !== null) {
if (mask[m.index] !== 0) continue;
const argsStart = m.index + m[0].length;
const argsEnd = findZigParenEnd(text, argsStart);
if (argsEnd < 0) break;
const args = text.slice(argsStart, argsEnd);
const nameMatch = /^\s*"([^"\n]+)"\s*,/.exec(args);
if (nameMatch?.[1] !== preferredName) continue;
const root = zigTopLevelStaticRoot(args);
if (root !== null) add(named, root);
}
const anyRe = new RegExp(rootRe.source, 'g');
while ((m = anyRe.exec(text)) !== null) add(unnamed, m[1]!);
return [...named, ...unnamed];
}
/**
* The importable modules a repo's ROOT `build.zig` declares, module name →
* repo-relative root source file. Static scan (no execution) of two shapes:
*
* - `b.addModule("<name>", .{ .root_source_file = b.path("<p>.zig"), … })`
* names the module directly;
* - `const m = b.createModule(.{ .root_source_file = b.path("<p>.zig"), … })`
* (or `const m = b.addModule(…)`) bound to an identifier and later named
* by `x.addImport("<name>", m)` or `.imports = &.{ .{ .name = "<name>",
* .module = m } }`.
*
* Deliberately NOT resolved — they are not in-repo source files: modules whose
* root is not a static `b.path("….zig")` (generated `opts.createModule()` from
* `addOptions`, `translate_c.createModule()`, `.cwd_relative` / computed
* LazyPaths), `addImport("<name>", dep.module("…"))` (a `.url` / path dep,
* handled through the zon), and aliases whose module operand is anything but a
* bare identifier bound above (`config.lp_module`). Comments are stripped and
* string literals skipped; the first declaration of a name wins.
*/
export function parseZigRootModules(buildZig: string): Map<string, string> {
const text = stripZonComments(buildZig);
const mask = zonStringMask(text);
const modules = new Map<string, string>();
// identifier → repo-relative root, for `const m = b.createModule(…)` /
// `const m = b.addModule(…)` bindings later named via addImport.
const bindings = new Map<string, string>();
const callRe = /\b(addModule|createModule)\s*\(/g;
let m: RegExpExecArray | null;
while ((m = callRe.exec(text)) !== null) {
if (mask[m.index] !== 0) continue;
const argsStart = m.index + m[0].length;
const argsEnd = findZigParenEnd(text, argsStart);
if (argsEnd < 0) break;
const args = text.slice(argsStart, argsEnd);
const root = zigTopLevelStaticRoot(args);
if (root === null) continue;
if (m[1] === 'addModule') {
const nameMatch = /^\s*"([^"\n]+)"\s*,/.exec(args);
if (nameMatch && !modules.has(nameMatch[1]!)) modules.set(nameMatch[1]!, root);
}
const binding = ZIG_MODULE_BINDING_RE.exec(text.slice(0, m.index));
if (binding && !bindings.has(binding[1]!)) bindings.set(binding[1]!, root);
}
if (bindings.size === 0) return modules;
const aliasRes = [
/\.addImport\(\s*"([^"\n]+)"\s*,\s*([A-Za-z_]\w*)\s*\)/g,
/\.name\s*=\s*"([^"\n]+)"\s*,\s*\.module\s*=\s*([A-Za-z_]\w*)\s*[,}]/g,
];
for (const re of aliasRes) {
while ((m = re.exec(text)) !== null) {
if (mask[m.index] !== 0) continue;
const root = bindings.get(m[2]!);
if (root !== undefined && !modules.has(m[1]!)) modules.set(m[1]!, root);
}
}
return modules;
}
/**
* Every build module the ROOT `build.zig` declares, each with its OWN import
* table (`ZigBuildModule`). Static scan (no execution) of:
*
* - `b.addModule("<name>", .{ .root_source_file = b.path("<p>.zig"), … })`
* and `const m = b.createModule(.{ .root_source_file = … })` — a module,
* bound to the identifier a preceding `const m =` names;
* - `b.addExecutable` / `addLibrary` / `addStaticLibrary` /
* `addSharedLibrary` / `addTest` / `addObject(.{ .root_source_file =
* b.path("<p>.zig"), … })` — an artifact whose ROOT MODULE is a module of
* its own (reached as `exe.root_module.addImport(…)`), or `.root_module =
* m` / `.root_module = b.createModule(…)` naming one declared inline;
* - `<m>.addImport("<alias>", <operand>)`, `<exe>.root_module.addImport(…)`
* and the `.imports = &.{ .{ .name = "<alias>", .module = <operand> } }`
* field of a module's own arguments — an entry in THAT module's table.
* The operand is a module binding (`m`) or a path dep's named module,
* `dep.module("<name>")` with `const dep = b.dependency("<zon name>", …)`,
* looked up in `depModules` (zon dep name → module name → repo-relative
* root, from the dep's own build.zig).
*
* Why per module rather than one map (`parseZigRootModules`): an alias is
* scoped to the module that declares it. Two executables that each
* `addImport("config", …)` their own `config.zig` are the ordinary
* multi-target layout, and a single first-wins map sent the second module's
* `@import("config")` to the first module's file — a confident wrong
* `IMPORTS` edge and every `config.*` call behind it. Deliberately NOT
* resolved, as in `parseZigRootModules`: generated roots
* (`addOptions().createModule()`, `translate_c.createModule()`, computed
* LazyPaths), `.url` deps, and operands that are not a bare identifier or a
* `dep.module("…")` on a `b.dependency` binding. Comments stripped, string
* literals masked; the first binding of an identifier wins.
*/
export function parseZigBuildModules(
buildZig: string,
depModules?: ReadonlyMap<string, ReadonlyMap<string, string>>,
): ZigBuildModule[] {
const text = stripZonComments(buildZig);
const mask = zonStringMask(text);
// Pass 1 — modules and the identifiers bound to them. `at` is the offset
// of the call's name token, so an inline `.root_module = b.createModule(…)`
// can be matched back to the module it minted.
interface Draft {
readonly name?: string;
readonly root: string;
readonly at: number;
readonly argsStart: number;
readonly argsEnd: number;
readonly imports: Map<string, string>;
}
const drafts: Draft[] = [];
const bindings = new Map<string, number>(); // identifier → drafts index
const bind = (prefixEnd: number, idx: number): void => {
const binding = ZIG_MODULE_BINDING_RE.exec(text.slice(0, prefixEnd));
if (binding && !bindings.has(binding[1]!)) bindings.set(binding[1]!, idx);
};
// Artifact bindings whose `.root_module = <ident>` names a module declared
// by another call; resolved once every binding is known.
const pendingArtifactAliases: { readonly ident: string; readonly module: string }[] = [];
const callRe =
/\b(addModule|createModule|addExecutable|addLibrary|addStaticLibrary|addSharedLibrary|addTest|addObject)\s*\(/g;
let m: RegExpExecArray | null;
while ((m = callRe.exec(text)) !== null) {
if (mask[m.index] !== 0) continue;
const argsStart = m.index + m[0].length;
const argsEnd = findZigParenEnd(text, argsStart);
if (argsEnd < 0) break;
const args = text.slice(argsStart, argsEnd);
const kind = m[1]!;
if (kind === 'addModule' || kind === 'createModule') {
const root = zigTopLevelStaticRoot(args);
if (root === null) continue;
const nameMatch = kind === 'addModule' ? /^\s*"([^"\n]+)"\s*,/.exec(args) : null;
drafts.push({
...(nameMatch ? { name: nameMatch[1]! } : {}),
root,
at: m.index,
argsStart,
argsEnd,
imports: new Map(),
});
bind(m.index, drafts.length - 1);
continue;
}
// An artifact. Its root module is either declared inline by
// `.root_source_file`, or handed over through `.root_module = …`.
const rootModule = /\.root_module\s*=\s*((?:[A-Za-z_]\w*\.)*)([A-Za-z_]\w*)\s*(\()?/.exec(args);
if (rootModule) {
if (rootModule[3] === '(' && rootModule[2] === 'createModule') {
// Inline `.root_module = b.createModule(.{ … })`: the module is minted
// by the createModule call inside these args (a later iteration of
// this loop); remember the artifact's binding for it.
const nameOffset = rootModule.index + rootModule[0].lastIndexOf('createModule');
const binding = ZIG_MODULE_BINDING_RE.exec(text.slice(0, m.index));
if (binding) {
pendingArtifactAliases.push({
ident: binding[1]!,
module: `@${argsStart + nameOffset}`,
});
}
} else if (rootModule[1] === '' && rootModule[3] === undefined) {
const binding = ZIG_MODULE_BINDING_RE.exec(text.slice(0, m.index));
if (binding) pendingArtifactAliases.push({ ident: binding[1]!, module: rootModule[2]! });
}
continue;
}
const root = zigTopLevelStaticRoot(args);
if (root === null) continue;
drafts.push({ root, at: m.index, argsStart, argsEnd, imports: new Map() });
bind(m.index, drafts.length - 1);
}
for (const alias of pendingArtifactAliases) {
if (bindings.has(alias.ident)) continue;
const idx = alias.module.startsWith('@')
? drafts.findIndex((d) => d.at === Number(alias.module.slice(1)))
: (bindings.get(alias.module) ?? -1);
if (idx >= 0) bindings.set(alias.ident, idx);
}
if (drafts.length === 0) return [];
// `const dep = b.dependency("<zon name>", …)` bindings, for `dep.module("…")`.
const dependencyBindings = new Map<string, string>();
const depRe =
/(?:const|var)\s+([A-Za-z_]\w*)\s*=\s*(?:[A-Za-z_]\w*\.)*dependency\(\s*"([^"\n]+)"/g;
while ((m = depRe.exec(text)) !== null) {
if (mask[m.index] !== 0) continue;
if (!dependencyBindings.has(m[1]!)) dependencyBindings.set(m[1]!, m[2]!);
}
// An import operand → the repo-relative root it names, or null.
const operandRoot = (operand: string): string | null => {
const bare = /^([A-Za-z_]\w*)$/.exec(operand);
if (bare) {
const idx = bindings.get(bare[1]!);
return idx === undefined ? null : drafts[idx]!.root;
}
const viaDep = /^([A-Za-z_]\w*)\.module\(\s*"([^"\n]+)"\s*\)$/.exec(operand);
if (viaDep) {
const zonName = dependencyBindings.get(viaDep[1]!);
return zonName === undefined ? null : (depModules?.get(zonName)?.get(viaDep[2]!) ?? null);
}
return null;
};
const addImport = (idx: number, alias: string, operand: string): void => {
const root = operandRoot(operand.trim());
const table = drafts[idx]!.imports;
if (root !== null && !table.has(alias)) table.set(alias, root);
};
// Pass 2a — `<m>.addImport("<alias>", <operand>)` / `<exe>.root_module.addImport(…)`.
const addImportRe = /\b([A-Za-z_]\w*)(?:\.root_module)?\.addImport\s*\(/g;
while ((m = addImportRe.exec(text)) !== null) {
if (mask[m.index] !== 0) continue;
const idx = bindings.get(m[1]!);
if (idx === undefined) continue;
const argsStart = m.index + m[0].length;
const argsEnd = findZigParenEnd(text, argsStart);
if (argsEnd < 0) break;
const args = text.slice(argsStart, argsEnd);
const aliasMatch = /^\s*"([^"\n]+)"\s*,/.exec(args);
if (!aliasMatch) continue;
addImport(idx, aliasMatch[1]!, args.slice(aliasMatch[0].length));
}
// Pass 2b — `.imports = &.{ .{ .name = "<alias>", .module = <operand> }, … }`
// inside a module's own argument list. The operand runs to the next `,` or
// `}` at paren depth 0 (`dep.module("core")` carries parentheses).
const entryRe = /\.name\s*=\s*"([^"\n]+)"\s*,\s*\.module\s*=\s*/g;
drafts.forEach((draft, idx) => {
const args = text.slice(draft.argsStart, draft.argsEnd);
let e: RegExpExecArray | null;
while ((e = entryRe.exec(args)) !== null) {
if (mask[draft.argsStart + e.index] !== 0) continue;
let depth = 0;
let end = e.index + e[0].length;
for (; end < args.length; end++) {
const ch = args[end];
if (ch === '(') depth++;
else if (ch === ')') {
if (depth === 0) break;
depth--;
} else if (depth === 0 && (ch === ',' || ch === '}')) break;
}
addImport(idx, e[1]!, args.slice(e.index + e[0].length, end));
}
});
return drafts.map(({ name, root, imports }) => ({
...(name !== undefined ? { name } : {}),
root,
imports,
}));
}
/** First `.root_source_file = b.path("….zig")` at the TOP level of a
* module-options `.{ … }` — not a nested `.imports = &.{ .{ … } }` entry. */
function zigTopLevelStaticRoot(args: string): string | null {
const mask = zonStringMask(args);
let structAt = -1;
for (let i = 0; i < args.length - 1; i++) {
if (mask[i] !== 0) continue;
if (args[i] === '.' && args[i + 1] === '{') {
structAt = i;
break;
}
}
if (structAt < 0) return null;
const bodyStart = structAt + 2;
const bodyEnd = findZonBlockEnd(args, bodyStart);
if (bodyEnd < 0) return null;
const match = /\.root_source_file\s*=\s*b\.path\(\s*"([^"\n]+)"\s*\)/.exec(
zonBlankNestedBlocks(args.slice(bodyStart, bodyEnd)),
);
if (match === null) return null;
const root = normalizeZigDepPath(match[1]!);
return root === null || root === '' || !root.endsWith('.zig') ? null : root;
}
/** `const m = b.createModule` / `const m = b.addModule` — not `config.createModule`. */
const ZIG_MODULE_BINDING_RE = /(?:const|var)\s+([A-Za-z_]\w*)\s*=\s*b\.$/;
/**
* Index of the `)` matching the `(` that precedes `start`, skipping parens
* inside `"…"` literals. -1 when unbalanced. Call on comment-stripped text.
*/
function findZigParenEnd(text: string, start: number): number {
let depth = 1;
let inString = false;
for (let i = start; i < text.length; i++) {
const ch = text[i];
if (inString) {
if (ch === '\\') i++;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') inString = true;
else if (ch === '(') depth++;
else if (ch === ')' && --depth === 0) return i;
}
return -1;
}
/**
* Blank out `//` line comments (and `\\` multiline-string-literal lines) in
* ZON source, string-aware: a `//` inside a `"…"` literal (`.url =
* "https://…"`) is content, not a comment. Comment bytes are replaced with
* spaces so every surviving character keeps its offset.
*/
function stripZonComments(raw: string): string {
const out = raw.split('');
let inString = false;
for (let i = 0; i < raw.length; i++) {
const ch = raw[i];
if (inString) {
if (ch === '\\')
i++; // skip the escaped char
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') {
inString = true;
continue;
}
const isLineComment = ch === '/' && raw[i + 1] === '/';
const isMultilineLiteral =
ch === '\\' &&
raw[i + 1] === '\\' &&
/^[ \t]*$/.test(raw.slice(raw.lastIndexOf('\n', i) + 1, i));
if (isLineComment || isMultilineLiteral) {
while (i < raw.length && raw[i] !== '\n') out[i++] = ' ';
}
}
return out.join('');
}
/**
* Index of the `}` matching the `{` that precedes `start`, skipping braces
* inside `"…"` literals. -1 when unbalanced. Call on comment-stripped text.
*/
function findZonBlockEnd(text: string, start: number): number {
let depth = 1;
let inString = false;
for (let i = start; i < text.length; i++) {
const ch = text[i];
if (inString) {
if (ch === '\\') i++;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') inString = true;
else if (ch === '{') depth++;
else if (ch === '}' && --depth === 0) return i;
}
return -1;
}
/**
* `body` with every nested `{ … }` block (string-aware) replaced by spaces of
* equal length, so a regex over the result only sees the block's DIRECT
* fields and offsets still line up with the original text.
*/
function zonBlankNestedBlocks(body: string): string {
const out = body.split('');
let depth = 0;
let inString = false;
for (let i = 0; i < body.length; i++) {
const ch = body[i];
if (inString) {
if (ch === '\\') {
if (depth > 0 && i + 1 < body.length) out[i + 1] = ' ';
i++;
} else if (ch === '"') inString = false;
if (depth > 0) out[i] = ' ';
continue;
}
if (ch === '"') inString = true;
else if (ch === '{') depth++;
else if (ch === '}' && depth > 0) {
depth--;
out[i] = ' ';
continue;
}
if (depth > 0) out[i] = ' ';
}
return out.join('');
}
/**
* Per-offset "is inside a `"…"` literal" mask for comment-stripped ZON text,
* so header regexes can reject a match that merely LOOKS like a field
* (`.name = ".dependencies = .{ … }"` is a string, not the dependencies
* block). Escaped quotes (`\"`) do not end the literal.
*/
function zonStringMask(text: string): Uint8Array {
const mask = new Uint8Array(text.length);
let inString = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (inString) {
mask[i] = 1;
if (ch === '\\' && i + 1 < text.length) mask[++i] = 1;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') {
inString = true;
mask[i] = 1;
}
}
return mask;
}
/**
* Per-offset brace depth for comment-stripped ZON text, string-aware: the
* depth AT an offset is the number of unclosed `{` before it. The file's
* top-level `.{` puts every direct field at depth 1.
*/
function zonDepthMask(text: string): Uint8Array {
const depth = new Uint8Array(text.length);
let d = 0;
let inString = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
depth[i] = d;
if (inString) {
if (ch === '\\' && i + 1 < text.length) depth[++i] = d;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') inString = true;
else if (ch === '{') d++;
else if (ch === '}' && d > 0) d--;
}
return depth;
}
/**
* First match of a sticky-free global `re` in `text[from, to)` whose start
* lies outside a string literal (per `mask`) and, when `depthAt` is given, at
* exactly that brace depth (per `depth`). Null when none.
*/
function matchZonHeader(
text: string,
re: RegExp,
mask: Uint8Array,
from: number,
to: number,
depth?: Uint8Array,
depthAt?: number,
): RegExpExecArray | null {
re.lastIndex = from;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null && m.index < to) {
if (mask[m.index] !== 0) continue;
if (depth !== undefined && depthAt !== undefined && depth[m.index] !== depthAt) continue;
return m;
}
return null;
}
/** Pure parser split out for testability. Returns null when no path-deps found. */
export function parseZigBuildZon(raw: string): ZigBuildZonConfig | null {
const text = stripZonComments(raw);
const mask = zonStringMask(text);
const depth = zonDepthMask(text);
// Locate the `.dependencies = .{ ... }` block. Use brace counting because
// dep entries are nested anonymous structs and a naive `}` match would stop
// early — and only accept a header outside string literals AND at brace
// depth 1 (a direct field of the file's top-level `.{`), so neither a
// `.name` value spelling `.dependencies = .{` nor a `.dependencies` field
// nested in some earlier anonymous struct can hijack it.
const depsHeader = matchZonHeader(
text,
/\.dependencies\s*=\s*\.\{/g,
mask,
0,
text.length,
depth,
1,
);
if (!depsHeader) return null;
const start = depsHeader.index + depsHeader[0].length;
const end = findZonBlockEnd(text, start);
if (end < 0) return null;
const pathDeps = new Map<string, string>();
// Walk each `.<name> = .{ ... }` entry inside [start, end); the body ends
// at the matching brace (string-aware), not at the first `}` in the text,
// and an entry header inside a string (`.url = "…/.x = .{"`) is not an entry.
const entryHeaderRe = /\.([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\.\{/g;
let cursor = start;
let m: RegExpExecArray | null;
while ((m = matchZonHeader(text, entryHeaderRe, mask, cursor, end)) !== null) {
const depName = m[1];
const bodyStart = m.index + m[0].length;
const bodyEnd = findZonBlockEnd(text, bodyStart);
if (bodyEnd < 0 || bodyEnd > end) break;
cursor = bodyEnd + 1;
// Only a `.path` that is a DIRECT field of the entry counts: a nested
// object inside the entry (`.foo = .{ .url = "…", .x = .{ .path = "…" } }`)
// must not turn a URL dep into a path dep. Blank nested blocks first and
// reject a match that starts inside a string literal.
const body = zonBlankNestedBlocks(text.slice(bodyStart, bodyEnd));
const pathMatch = matchZonHeader(
body,
/\.path\s*=\s*"([^"\n]+)"/g,
mask.subarray(bodyStart, bodyEnd),
0,
body.length,
);
if (pathMatch) {
pathDeps.set(depName, pathMatch[1]);
}
}
if (pathDeps.size === 0) return null;
if (isDev) {
logger.info(`📦 Loaded ${pathDeps.size} Zig path-dep(s) from build.zig.zon`);
}
return { pathDeps };
}
// ============================================================================
// BUNDLED CONFIG LOADER
// ============================================================================
@ -571,6 +1331,9 @@ export interface ImportConfigs {
csharpConfigs: CSharpProjectConfig[];
/** In-repo namespace evidence gating C# suffix-fallback resolution (#1881). */
csharpNamespaces?: CSharpNamespaceEvidence;
/** Zig `.path` deps from build.zig.zon. Optional so call sites that
* hand-build ImportConfigs (tests) don't have to supply it. */
zigBuildZon?: ZigBuildZonConfig | null;
}
/** Load all language-specific configs once for an ingestion run. */
@ -583,5 +1346,6 @@ export async function loadImportConfigs(repoRoot: string): Promise<ImportConfigs
swiftPackageConfig: await loadSwiftPackageConfig(repoRoot),
csharpConfigs: csharpScan.configs,
csharpNamespaces: csharpScanToEvidence(csharpScan),
zigBuildZon: await loadZigBuildConfig(repoRoot),
};
}

View file

@ -257,6 +257,43 @@ interface LanguageProviderConfig {
* Default: undefined (no remapping). */
readonly resolveEnclosingOwner?: (node: SyntaxNode) => SyntaxNode | null;
/**
* The type a whole FILE declares, when the language makes the file itself
* a type (Zig: a `.zig` file with top-level fields is a struct whose name
* is the file stem — `Page.zig` declares `Page`, and `page.getArena()`
* dispatches onto the file's top-level `fn getArena(self: *Page)`).
*
* Consulted by the enclosing-owner walk when it reaches the tree root
* without meeting a container, and by the class/method/field extractors
* for the owner name. Return `null` for a file that is only a namespace.
* The name is the class-like node's name (`Struct:<file>:<name>`), so the
* owner id and the node id agree by construction.
* Default: undefined (a file never owns members). */
readonly resolveFileTypeOwner?: (
root: SyntaxNode,
filePath: string,
) => { readonly name: string; readonly label: NodeLabel } | null;
/**
* The type a CONTAINER node declares, when the language names it from its
* context rather than from a name child of the node — a binding wrapper,
* an enclosing callable, an ordinal among anonymous siblings (Zig:
* `const T = struct {…}` is `T`; a function-local `const R = struct {…}`
* inside `fn string` is `string$R`; `struct { fn lessThan … }.lessThan`
* passed to a sort is `<fn>$1`).
*
* Consulted by the enclosing-owner walk for every `CLASS_CONTAINER_TYPES`
* node it meets (after `resolveEnclosingOwner` remapping), BEFORE the
* generic name-child derivation; return `null` to fall back to it. The name
* must be the one the class-like node is minted under
* (`<label>:<file>:<name>`), so a member's owner id and the node id agree
* by construction.
* Default: undefined (containers are named by the generic derivation). */
readonly resolveContainerTypeOwner?: (
container: SyntaxNode,
filePath: string,
) => { readonly name: string; readonly label: NodeLabel } | null;
// ── Enclosing function resolution ───────────────────────────────
/** Resolve the enclosing function name + label from an AST ancestor node
* that is NOT a standard FUNCTION_NODE_TYPE. For languages where the

View file

@ -25,6 +25,7 @@ import { swiftProvider } from './swift.js';
import { dartProvider } from './dart.js';
import { vueProvider } from './vue.js';
import { cobolProvider } from './cobol.js';
import { zigProvider } from './zig.js';
export const providers = {
[SupportedLanguages.JavaScript]: javascriptProvider,
@ -43,6 +44,7 @@ export const providers = {
[SupportedLanguages.Dart]: dartProvider,
[SupportedLanguages.Vue]: vueProvider,
[SupportedLanguages.Cobol]: cobolProvider,
[SupportedLanguages.Zig]: zigProvider,
} satisfies Record<SupportedLanguages, LanguageProvider>;
/** Get provider by language enum (always succeeds for SupportedLanguages). */

View file

@ -36,14 +36,14 @@ const RUST_SCOPE_QUERY = `
type_parameters: (type_parameters)? @declaration.type-parameters) @declaration.enum
;; Declarations — union
;; Deliberately tagged @declaration.struct (→ Struct label), NOT a
;; @declaration.union: every registry-primary resolution gate —
;; isLinkableLabel (node-lookup.ts), CALLABLE_OR_TYPE_LIKE
;; (finalize-algorithm.ts), ClassLikeNodeLabel (class-types.ts) — includes
;; Struct but EXCLUDES Union, so a Union-labeled node would be an
;; unresolvable orphan. A Rust union is a type whose literal is a real
;; constructor, so Struct is both the resolvable and the semantically
;; honest label here. #1934 F71.
;; Tagged @declaration.struct (→ Struct label), NOT @declaration.union.
;; Historically forced: the registry-primary gates (isLinkableLabel,
;; CALLABLE_OR_TYPE_LIKE, ClassLikeNodeLabel) excluded Union, so a
;; Union-labeled node was an unresolvable orphan (#1934 F71). Zig support
;; widened all three for its union(enum) containers, so the label is now
;; resolvable — Struct is kept here on the semantic argument alone (a Rust
;; union is a type whose literal is a real constructor) and to leave the
;; graph node ids of existing Rust indexes unchanged.
(union_item
name: (type_identifier) @declaration.name
type_parameters: (type_parameters)? @declaration.type-parameters) @declaration.struct

View file

@ -0,0 +1,145 @@
/**
* Zig Language Provider.
*
* Key Zig traits:
* - mroStrategy: default 'first-wins' is irrelevant — Zig has no inheritance,
* and no heritage hooks are provided (Zig queries never produce
* `@heritage.*` captures).
* - exportChecker: walks to the enclosing variable_declaration /
* function_declaration and looks for a `pub` or `export` keyword child.
* - importResolver: resolves local `@import("./foo.zig")` paths and
* build.zig.zon `.path` deps (root the dep's build.zig declares, then
* src/root.zig, src/<name>.zig, src/main.zig); `@import("std")` and
* `.url` packages are deliberately external.
* - namedBindingExtractor: omitted — the scope side handles
* `const Foo = @import("x").Foo` and `const Foo = ns.Foo` (ns an
* @import binding) as NAMED imports instead (`zig/captures.ts`).
* - scope-resolution hooks (Ring 3): `emitScopeCaptures` walks the file via
* `zig/query.ts` (containers as Class scopes — including the container a
* generic type constructor `fn List(comptime T: type) type` returns,
* named after the fn; container-nested fns relabeled @declaration.method;
* plain-variable groups filtered for container/import bindings and for
* the keyword-less `variable_declaration`s tree-sitter-zig uses for
* statement assignments); `interpretImport` maps `const x = @import("…")`
* to a namespace import, member forms to named/alias imports and
* `usingnamespace` to a wildcard; receiver types come from `self`
* parameters, `T{…}` / `mod.T{…}` / `List(u8){…}` literals, `T.init()`
* call returns, `x: T` annotations (incl. decl literals), container
* FIELD types on the container's class scope (`self.session.name()`)
* and one-level field aliases (`const s = self.session; s.name()`). The
* emit-side wiring lives in `zig/scope-resolver.ts` (SCOPE_RESOLVERS).
*/
import { SupportedLanguages } from 'gitnexus-shared';
import { defineLanguage } from '../language-provider.js';
import { ZIG_QUERIES } from '../tree-sitter-queries.js';
import { zigExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
import { zigImportConfig } from '../import-resolvers/configs/zig.js';
import { createCallExtractor } from '../call-extractors/generic.js';
import { zigCallConfig } from '../call-extractors/configs/zig.js';
import { createClassExtractor } from '../class-extractors/generic.js';
import { zigClassConfig } from '../class-extractors/configs/zig.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
import { zigFieldConfig } from '../field-extractors/configs/zig.js';
import { createMethodExtractor } from '../method-extractors/generic.js';
import { zigMethodConfig } from '../method-extractors/configs/zig.js';
import { createVariableExtractor } from '../variable-extractors/generic.js';
import { zigVariableConfig } from '../variable-extractors/configs/zig.js';
import { zigTypeConfig } from '../type-extractors/zig.js';
import {
emitZigScopeCaptures,
interpretZigImport,
interpretZigTypeBinding,
isZigContainerMethod,
isZigFileStruct,
isZigRedundantContainerCapture,
isZigTypeShadowingBinding,
zigArityCompatibility,
zigContainerLabel,
zigContainerName,
zigFileStructName,
zigBindingScopeFor,
zigReceiverBinding,
ZIG_CONTAINER_TYPES,
} from './zig/index.js';
export const zigProvider = defineLanguage({
id: SupportedLanguages.Zig,
extensions: ['.zig'],
entryPointPatterns: [
/^main$/, // standard executable entry point
/^build$/, // build.zig entry point
],
astFrameworkPatterns: [],
treeSitterQueries: ZIG_QUERIES,
typeConfig: zigTypeConfig,
exportChecker: zigExportChecker,
importResolver: createImportResolver(zigImportConfig),
callExtractor: createCallExtractor(zigCallConfig),
classExtractor: createClassExtractor(zigClassConfig),
fieldExtractor: createFieldExtractor(zigFieldConfig),
methodExtractor: createMethodExtractor(zigMethodConfig),
variableExtractor: createVariableExtractor(zigVariableConfig),
// A `const`/`var` whose value is a container or an `@import` is the
// Struct/Enum/Union node or the import binding, not a Const beside it.
// Up to three ZIG_QUERIES rules match one container (wrapper, type
// constructor, bare container — F8); `zigContainerAnchor` names the one
// that mints it and the others are dropped here.
shouldSkipDefinitionCapture: (captureMap, defaultLabel) => {
if (defaultLabel === 'Const' || defaultLabel === 'Variable') {
const decl = captureMap['definition.const'] ?? captureMap['definition.variable'];
return decl !== undefined && isZigTypeShadowingBinding(decl);
}
if (defaultLabel === 'Struct' || defaultLabel === 'Enum' || defaultLabel === 'Union') {
const decl =
captureMap['definition.struct'] ??
captureMap['definition.enum'] ??
captureMap['definition.union'];
if (decl === undefined) return false;
// The file-struct rules over-match (a `@This` first parameter, a
// top-level `@This()` alias — see ZIG_QUERIES); the one predicate decides.
if (decl.type === 'source_file') return !isZigFileStruct(decl);
return isZigRedundantContainerCapture(decl, captureMap['name']);
}
return false;
},
// A file whose top level declares fields IS a struct named after the file
// (`Page.zig` → `Page`): its top-level fns/fields are members of that
// Struct. Files without fields are namespaces and own nothing.
resolveFileTypeOwner: (root, filePath) =>
isZigFileStruct(root) ? { name: zigFileStructName(filePath), label: 'Struct' } : null,
// Every container's identity comes from `zigContainerName` — the binding
// name for `const T = struct {…}` at file/container level, the fn name for
// a generic type constructor, and (F8) `string$R` / `build$1` for
// function-local and anonymous containers, which no name child spells. The
// class extractor names the node from the same function, so a member's
// owner id (`Method:<file>:string$R.get`) and the node id agree.
resolveContainerTypeOwner: (container, filePath) => {
if (!ZIG_CONTAINER_TYPES.has(container.type)) return null;
const name = zigContainerName(container, filePath);
const label = zigContainerLabel(container);
return name !== undefined && label !== undefined ? { name, label } : null;
},
labelOverride: (functionNode, defaultLabel) => {
if (defaultLabel !== 'Function') return defaultLabel;
if (isZigContainerMethod(functionNode)) return 'Method';
return defaultLabel;
},
// ── RFC #909 Ring 3: scope-based resolution hooks ──
emitScopeCaptures: emitZigScopeCaptures,
interpretImport: interpretZigImport,
// `@import` is compile-time name lookup, not an executed statement: one
// written inside a function body is resolved exactly as one at file scope
// (same answer as C `#include` and Rust `use`). Without this, a
// function-scoped `@import` would be marked `runsOnlyWhenCalled` and a
// real import cycle through it would be hidden from `check --cycles`.
importsExecuteWhereWritten: false,
interpretTypeBinding: interpretZigTypeBinding,
bindingScopeFor: zigBindingScopeFor,
receiverBinding: zigReceiverBinding,
// Provider contract is (def, callsite); the ScopeResolver contract is
// (callsite, def) — same function, adapted argument order.
arityCompatibility: (def, callsite) => zigArityCompatibility(callsite, def),
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,36 @@
export {
emitZigScopeCaptures,
isZigContainerMethod,
isZigContainerOrImportBinding,
isZigFileStruct,
isZigFileThisAlias,
isZigKeywordDeclaration,
isZigRedundantContainerCapture,
isZigTypeShadowingBinding,
zigCallableQualifiedName,
zigContainerAnchor,
zigContainerBindingName,
zigContainerLabel,
zigContainerName,
zigFileStructName,
zigImportRootOf,
zigCallReturnTypeOf,
zigReturnTypeIsNominal,
zigTypeConstructorOf,
zigUnwrapValue,
ZIG_CONTAINER_TYPES,
} from './captures.js';
export {
populateZigRangeBindings,
zigElementSpelling,
zigOptionalPayloadSpelling,
zigPointeeSpelling,
} from './range-binding.js';
export { interpretZigImport, interpretZigTypeBinding, normalizeZigTypeName } from './interpret.js';
export {
expandZigWildcardNames,
zigArityCompatibility,
zigBindingScopeFor,
zigMergeBindings,
zigReceiverBinding,
} from './simple-hooks.js';

View file

@ -0,0 +1,176 @@
import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
const stripQuotes = (s: string): string => s.replace(/^["']|["']$/g, '');
/**
* The module a `@import` target names, for the `importedName` of a namespace
* import (the shared contract wants the MODULE there — Go's `import foo
* "pkg/bar"` records `bar` — not the local handle): the last path segment
* without its `.zig` extension. `"std"` → `std`, `"./net/socket.zig"` →
* `socket`, `"mylib"` (a build.zig.zon dep) → `mylib`.
*/
export function zigModuleNameOf(targetRaw: string): string {
const last = targetRaw.replace(/\\/g, '/').split('/').filter(Boolean).pop() ?? targetRaw;
return last.endsWith('.zig') ? last.slice(0, -'.zig'.length) : last;
}
/**
* `const std = @import("std");` binds the imported module to a const handle
* accessed via qualified syntax — a namespace import (closest peers: Python
* `import numpy`, Go `import "pkg/bar"`). `localName` is the handle the
* author chose, `importedName` the module (`zigModuleNameOf`).
*
* `_ = @import("x.zig");` (and any keyword-less `<ident> = @import(…)`, a
* statement rather than a declaration in this grammar) references the file
* without binding a name — the `refAllDecls` / test-aggregation idiom. That
* is a `side-effect` import: file edge, no binding (TS `import './x'`). So is
* an `@import` in any expression position (a tuple element, a call argument,
* a comparison operand — `emitZigScopeCaptures`'s `@import.inline` rule).
*
* The receiver of a member call, `@import("dump.zig").root(...)`, arrives as
* a namespace import whose `@import.name` is the builtin's own text: the
* shared namespace-receiver lookup keys on the receiver text, and that is
* how the call resolves into `dump.zig` without a `const` handle.
*/
export function interpretZigImport(captures: CaptureMatch): ParsedImport | null {
const source = captures['@import.source']?.text;
if (source === undefined) return null;
const targetRaw = stripQuotes(source);
if (targetRaw.length === 0) return null;
// `pub usingnamespace @import("x.zig");` — every pub decl of the target
// becomes a decl of this container. A wildcard, expanded by
// `expandZigWildcardNames` in the scope resolver.
if (captures['@import.wildcard'] !== undefined) {
return { kind: 'wildcard', targetRaw };
}
if (captures['@import.side-effect'] !== undefined) {
return { kind: 'side-effect', targetRaw };
}
const name = captures['@import.name']?.text;
if (name === undefined) return null;
// `const Foo = @import("x.zig").Foo;` — one member, under a name of the
// importer's choosing (a rename when it differs: `const Alloc =
// @import("std").mem;`). Same fact as TS `import { Foo } from './x'` /
// `import { Foo as Bar }`.
const imported = captures['@import.imported']?.text;
if (imported !== undefined) {
// `pub const X = …` at file scope republishes the name (Python
// `__init__.py` shape): a third file reads it as `thisModule.X`. The
// marker is set by `emitZigScopeCaptures` where the syntax node is
// still available (`isZigPublishingImport`).
const republish = captures['@import.reexports'] !== undefined ? { reexportsName: true } : {};
return imported === name
? { kind: 'named', localName: name, importedName: imported, targetRaw, ...republish }
: {
kind: 'alias',
localName: name,
importedName: imported,
alias: name,
targetRaw,
...republish,
};
}
return {
kind: 'namespace',
localName: name,
importedName: zigModuleNameOf(targetRaw),
targetRaw,
};
}
/**
* Strip Zig type sigils that wrap the nominal type: pointers (`*T`, `[*]T`),
* optionals (`?T`), error unions (`!T` / `E!T`), slices (`[]T`), arrays
* (`[N]T`), and `const` qualifiers. Keeps the bare type name so registry
* lookup matches the container declaration.
*/
export function normalizeZigTypeName(text: string): string {
let t = text.trim();
// Error union first: the payload of `E!*T` / `!?T` carries its own sigils
// (F6: `Allocator.Error!*Page` is the shape of every fallible constructor).
const bang = t.lastIndexOf('!');
if (bang !== -1) t = t.slice(bang + 1).trim();
let previous: string;
do {
previous = t;
t = t.replace(/^(\*|\?|\[\*?c?\]|\[[^\]]*\])\s*/, '');
t = t.replace(/^const\s+/, '');
} while (t !== previous);
// Generic instantiation `List(u8)` / `std.ArrayList(u8)` → the type
// constructor `List` / `std.ArrayList`: Zig spells a generic type as a
// call, and the container def is registered under the function's name.
// Builtins (`@This()`, `@TypeOf(x)`) keep their parentheses — they are
// not constructor names and must not turn into `@This`.
if (!t.startsWith('@')) {
const paren = t.indexOf('(');
if (paren > 0 && t.endsWith(')')) t = t.slice(0, paren).trim();
}
return t;
}
export function interpretZigTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
const name = captures['@type-binding.name']?.text;
const type = captures['@type-binding.type']?.text;
if (name === undefined || type === undefined) return null;
let source: TypeRef['source'] = 'annotation';
if (captures['@type-binding.parameter'] !== undefined) {
// Zig has no implicit receiver keyword. `emitZigScopeCaptures` tags the
// receiver parameter (`@type-binding.receiver`, see `zigReceiverParameter`):
// the FIRST parameter when it is named `self` OR typed as the enclosing
// container (`counter: *Counter`, `pool: *@This()`, `prng: *PRNG` with
// `const PRNG = @This();`). Position matters — `fn f(a: u32, self: T)` is
// legal and `self` there is an ordinary parameter.
const isReceiver = captures['@type-binding.receiver'] !== undefined;
source = isReceiver ? 'self' : 'parameter-annotation';
} else if (captures['@type-binding.constructor'] !== undefined) {
source = 'constructor-inferred';
} else if (captures['@type-binding.call-return'] !== undefined) {
// `var c = Counter.init();` — the call's receiver names the type when it
// is a container (`Counter`, `mod.Counter`, `List(u8)`); a value receiver
// (`std.mem`, `self.items`) simply finds no container and declines.
// `const t = makeThing();` — the callee name, chained to its return
// binding by the shared resolver.
source = 'constructor-inferred';
// `const el = node.asElement();` on a fn-LOCAL receiver (F6): the type is
// the METHOD's return type, spelled as the compound `node.asElement()` the
// shared resolver walks (receiver binding → class scope → the method's
// `@type-binding.return`). Kept verbatim: `normalizeZigTypeName` would
// read the `()` as a generic instantiation and strip it.
if (captures['@type-binding.member-call-return'] !== undefined) {
return { boundName: name, rawTypeName: type.trim(), source };
}
} else if (captures['@type-binding.return'] !== undefined) {
// `fn make() !*Thing` — `make ↦ Thing`, in the enclosing scope, so a
// call site chains through it. Error unions / pointers / optionals are
// stripped by `normalizeZigTypeName` (`Allocator.Error!*Page` → `Page`).
source = 'return-annotation';
} else if (captures['@type-binding.annotation'] !== undefined) {
// `var x: T = undefined;` / `const x: T = .init(…);` — the declared type.
source = 'annotation';
} else if (captures['@type-binding.field'] !== undefined) {
// `session: *Session,` — a container field's declared type, hosted in
// the container's Class scope so `self.session.name()` walks it
// (synthesized by `emitZigScopeCaptures`, F5). A declaration, hence
// 'annotation': it must outrank nothing and be outranked by nothing —
// a field has exactly one type source.
source = 'annotation';
} else if (captures['@type-binding.alias'] !== undefined) {
// Two alias shapes share the group: `const page = self.page;` — the RHS
// member path IS the "type"; the compound resolver's member-alias branch
// re-resolves `self.page` as a receiver chain (F5) — and
// `const LocalAlias = Local;` / `const B = util.List(u8);` — the alias
// name is bound to the value's type text (`util.List` after the comptime
// arguments are dropped) and chained to the target by the shared
// `followChainedRef` (F7). Rust's `let x = y` source: it must rank
// BELOW an annotation on the same name (`const x: T = y;` is typed by
// `T`), and the default here is 'annotation'.
source = 'assignment-inferred';
}
return { boundName: name, rawTypeName: normalizeZigTypeName(type), source };
}

View file

@ -0,0 +1,432 @@
import Parser from 'tree-sitter';
import { createRequire } from 'node:module';
const _require = createRequire(import.meta.url);
/**
* Zig scope-resolution query (RFC #909 Ring 3).
*
* The grammar is an optionalDependency (`@tree-sitter-grammars/tree-sitter-zig`),
* so the language module is required lazily and `getZigParser` /
* `getZigScopeQuery` throw only when actually invoked without the grammar
* installed. That is safe: the parse pipeline filters `.zig` files through
* `parser-loader.isLanguageAvailable` before any scope extraction runs.
*
* Zig specifics encoded here:
* - Containers (struct/enum/union/opaque) are anonymous nodes bound by the
* enclosing `variable_declaration`; declarations capture the binding
* identifier from the wrapper.
* - `@import` is a builtin call, not import-statement syntax; the
* `#eq?` predicate keeps other builtins (@sizeOf, @as, …) out.
* - A plain `(variable_declaration (identifier))` rule would also match
* container and import bindings — `emitZigScopeCaptures` filters those
* groups out so a name binds exactly once.
*/
const ZIG_SCOPE_QUERY = `
;; Scopes
(source_file) @scope.module
(struct_declaration) @scope.class
(enum_declaration) @scope.class
(union_declaration) @scope.class
(opaque_declaration) @scope.class
(function_declaration) @scope.function
(test_declaration) @scope.function
(block) @scope.block
;; Declarations — functions (relabeled @declaration.method inside containers
;; by emitZigScopeCaptures, mirroring the provider's labelOverride)
(function_declaration
name: (identifier) @declaration.name) @declaration.function
;; Declarations — named tests. Same naming rule as ZIG_QUERIES: the string
;; node WITH quotes, so the def joins the graph node and never collides with
;; a same-named fn. Anonymous / decl-form tests are scopes without a def.
(test_declaration
(string) @declaration.name) @declaration.function
;; Declarations — containers. The binding name lives on the wrapper
;; variable_declaration, but the ANCHOR is the container node itself so its
;; range equals the @scope.class range: the extractor then attaches the def
;; to the class scope (walkers.populateClassOwnedMembers expects the
;; class-like def among the class scope's ownedDefs) and auto-hoists the
;; name binding to the parent scope. Keyword-gated like the ordinary
;; binding rules: a keyword-less \`x = struct {…};\` is an assignment
;; (tree-sitter-zig 1.1.2 reuses variable_declaration), not a container def.
(variable_declaration
"const" . (identifier) @declaration.name
(struct_declaration) @declaration.struct)
(variable_declaration
"var" . (identifier) @declaration.name
(struct_declaration) @declaration.struct)
(variable_declaration
"const" . (identifier) @declaration.name
(enum_declaration) @declaration.enum)
(variable_declaration
"var" . (identifier) @declaration.name
(enum_declaration) @declaration.enum)
(variable_declaration
"const" . (identifier) @declaration.name
(union_declaration) @declaration.union)
(variable_declaration
"var" . (identifier) @declaration.name
(union_declaration) @declaration.union)
;; opaque {} is a fieldless container that may own methods — Struct, as in
;; ZIG_QUERIES (see the rationale there).
(variable_declaration
"const" . (identifier) @declaration.name
(opaque_declaration) @declaration.struct)
(variable_declaration
"var" . (identifier) @declaration.name
(opaque_declaration) @declaration.struct)
;; Declarations — generic type constructors. \`fn List(comptime T: type) type
;; { return struct {…}; }\` is Zig's only spelling of a generic type; the
;; returned container is anonymous in the grammar but every reader (and every
;; caller: \`List(u8)\`) names it after the function. Anchor on the container
;; so the def sits in its own class scope; the name binding is hoisted to the
;; MODULE scope by \`zigBindingScopeFor\` (not the fn body, where the name
;; would be invisible to callers) and coexists with the Function def of the
;; same name — \`List\` really is both a callable and a type.
((function_declaration
name: (identifier) @declaration.name
type: (builtin_type) @_ret
body: (block (expression_statement (return_expression
(struct_declaration) @declaration.struct))))
(#eq? @_ret "type"))
((function_declaration
name: (identifier) @declaration.name
type: (builtin_type) @_ret
body: (block (expression_statement (return_expression
(union_declaration) @declaration.union))))
(#eq? @_ret "type"))
((function_declaration
name: (identifier) @declaration.name
type: (builtin_type) @_ret
body: (block (expression_statement (return_expression
(enum_declaration) @declaration.enum))))
(#eq? @_ret "type"))
;; Declarations — container fields (struct fields, enum/union variants).
;; The #not-eq? guard drops the MISSING placeholder identifier tree-sitter-zig
;; recovers for an empty container body (see ZIG_QUERIES). The optional
;; \`type:\` (absent on enum variants) is captured as @declaration.field-type:
;; \`emitZigScopeCaptures\` turns it into a @type-binding.field on the
;; container's Class scope so \`self.session.name()\` can walk the field's
;; type (Rust/Go parity — see the F5 block in captures.ts).
((container_field
name: (identifier) @declaration.name
type: (_)? @declaration.field-type) @declaration.field
(#not-eq? @declaration.name ""))
;; Declarations — const/var bindings (import/container groups filtered in TS).
;; The \`.\` anchor pins the FIRST named child: without it the pattern also
;; matched the initializer of \`const first = target;\`, minting a phantom
;; local named \`target\` that shadowed the real callee for every later
;; reference in the block. The literal keyword is load-bearing too:
;; tree-sitter-zig 1.1.2 parses statement assignments (\`x = 5;\`, \`x += 1;\`,
;; \`_ = expr;\`) as \`variable_declaration\` WITHOUT a keyword child, and
;; without the keyword every assignment and every discard minted a phantom
;; local (one \`_\` per statement).
(variable_declaration
"const" . (identifier) @declaration.name) @declaration.variable
(variable_declaration
"var" . (identifier) @declaration.name) @declaration.variable
;; Imports — const x = @import("...") / var x = @import("..."). Keyword-gated
;; like every binding rule: a keyword-less \`x = @import("...")\` is a
;; statement (see the side-effect rule below), not a binding.
(variable_declaration
"const" . (identifier) @import.name
(builtin_function
(builtin_identifier) @_builtin
(arguments (string) @import.source))
(#eq? @_builtin "@import")) @import.statement
(variable_declaration
"var" . (identifier) @import.name
(builtin_function
(builtin_identifier) @_builtin
(arguments (string) @import.source))
(#eq? @_builtin "@import")) @import.statement
;; Imports — const X = @import("...").X : a NAMED import of one member. The
;; local name is whatever the user chose (\`const Alloc = @import("std").mem;\`
;; is a rename), the imported name is the member. A deeper chain
;; (\`@import("std").mem.Allocator\`, \`@import("lib.zig").B.work\`) is matched
;; by the second rule below but is NOT bound as a named import of the
;; innermost member: that discarded the written owner (\`B\`) and let a
;; same-named \`A.work\` answer first. \`emitZigScopeCaptures\` binds the module
;; under the builtin's text instead and rewrites the alias's use sites to the
;; full path (\`collectZigDeepAliases\`, PR #1432 review 8.4).
(variable_declaration
"const" . (identifier) @import.name
(field_expression
object: (builtin_function
(builtin_identifier) @_builtin
(arguments (string) @import.source))
member: (identifier) @import.imported)
(#eq? @_builtin "@import")) @import.statement
(variable_declaration
"const" . (identifier) @import.name
(field_expression
object: (field_expression
object: (builtin_function
(builtin_identifier) @_builtin
(arguments (string) @import.source)))
member: (identifier) @import.imported)
(#eq? @_builtin "@import")) @import.statement
;; Imports — a keyword-less \`<ident> = @import("...");\` statement
;; (\`_ = @import("all_tests.zig");\` in a test block, the refAllDecls
;; idiom): tree-sitter-zig reuses \`variable_declaration\` for assignments, so
;; the shape is a declaration minus the keyword. It references the file
;; without binding a name — a side-effect import. Tree-sitter queries cannot
;; say "no keyword child", so this rule matches the keyword-bearing shapes
;; too; \`emitZigScopeCaptures\` keeps it only when \`isZigKeywordDeclaration\`
;; is false (the keyword shapes are the binding rules above).
(variable_declaration
. (identifier)
(builtin_function
(builtin_identifier) @_builtin
(arguments (string) @import.source))
(#eq? @_builtin "@import")) @import.side-effect
;; Aliases of a namespace member — const Counter = counter.Counter; where
;; \`counter\` is an @import binding of THIS file. The query cannot know which
;; identifiers are import bindings, so it captures every one-level member
;; alias and \`emitZigScopeCaptures\` promotes the ones whose object is a
;; known @import to a named import (same fact as \`const Counter =
;; @import("counter.zig").Counter;\`); the rest stay ordinary variables.
;; Only the ONE-level shape is promoted; a deeper chain (\`lib.B.work\`,
;; \`std.mem.Allocator\`) is a deep alias — a Const whose use sites are
;; rewritten to the written owner path (see the import rule above, 8.4).
(variable_declaration
"const" . (identifier) @alias.name
(field_expression
object: (identifier) @alias.namespace
member: (identifier) @alias.member) .) @alias.statement
(variable_declaration
"const" . (identifier) @alias.name
(field_expression
object: (field_expression
object: (identifier) @alias.namespace)
member: (identifier) @alias.member) .) @alias.statement
;; Imports — pub usingnamespace @import("..."); : every pub decl of the target
;; becomes a decl of this container (removed from the language in 0.15, still
;; everywhere in 0.11–0.14 code). Modelled as a wildcard import.
(using_namespace_declaration
(builtin_function
(builtin_identifier) @_builtin
(arguments (string) @import.source))
(#eq? @_builtin "@import")) @import.wildcard
;; Imports — \`@import("...")\` in ANY other position: a tuple element
;; (\`pub const Interfaces = .{ @import("a.zig"), @import("b.zig") }\`, the
;; JS-API registration table), a call argument (\`event.is(@import("x.zig"))\`),
;; a comparison operand (\`T == @import("x.zig").T\`), the receiver of a
;; member call (\`try @import("dump.zig").root(...)\`), a 3-deep member chain…
;; Every one of them is a file dependency; only the const/var/usingnamespace
;; shapes above bind a name. This rule matches EVERY \`@import\` builtin, the
;; bound shapes included — \`emitZigScopeCaptures\` drops the matches whose
;; string node a binding rule (or the keyword-less side-effect rule) already
;; claimed, so a bound import is never doubled, and emits the rest as
;; side-effect imports (file edge, no binding) — except the member-call
;; receiver, which becomes a namespace import keyed by its own source text so
;; the call resolves into the imported module (see the emitter).
((builtin_function
(builtin_identifier) @_builtin
(arguments (string) @import.source))
(#eq? @_builtin "@import")) @import.inline
;; Type bindings — parameter annotations (incl. self: *T receivers)
(parameter
name: (identifier) @type-binding.name
type: (_) @type-binding.type) @type-binding.parameter
;; Type bindings — constructor inference: const p = T{ ... }. Keyword-gated
;; like every binding rule: a keyword-less \`p = T{ ... };\` is a
;; re-assignment (same node type in tree-sitter-zig 1.1.2), and Zig's static
;; typing means \`p\` already carries its type from its declaration
;; (annotation, constructor or inferred value) — the assignment declares
;; nothing, and \`_ = T{ ... };\` must not bind \`_\`.
(variable_declaration
"const" . (identifier) @type-binding.name
(struct_initializer
(identifier) @type-binding.type)) @type-binding.constructor
(variable_declaration
"var" . (identifier) @type-binding.name
(struct_initializer
(identifier) @type-binding.type)) @type-binding.constructor
;; Type bindings — qualified constructor: const p = mod.T{ ... }. The whole
;; field_expression is captured so the dotted text "mod.T" survives —
;; receiver dispatch resolves the namespace prefix through the import
;; binding (emitReceiverBoundCalls Case 3).
(variable_declaration
"const" . (identifier) @type-binding.name
(struct_initializer
(field_expression) @type-binding.type)) @type-binding.constructor
(variable_declaration
"var" . (identifier) @type-binding.name
(struct_initializer
(field_expression) @type-binding.type)) @type-binding.constructor
;; Type bindings — generic instantiation literal: const l = List(u8){ ... }.
;; The callee is the type constructor; \`normalizeZigTypeName\` drops the
;; comptime argument list so \`List(u8)\` looks up \`List\`.
(variable_declaration
"const" . (identifier) @type-binding.name
(struct_initializer
(call_expression) @type-binding.type)) @type-binding.constructor
(variable_declaration
"var" . (identifier) @type-binding.name
(struct_initializer
(call_expression) @type-binding.type)) @type-binding.constructor
;; Type bindings — declared type: var x: T = …; const x: T = .init(…);
;; The annotation is the ONLY type source for \`= undefined\` and for 0.14+
;; decl literals (\`.init\`, \`.empty\`), which are the idiomatic
;; constructors in current std. Ranked below constructor inference by the
;; shared resolver (source 'annotation'), so a literal on the right still
;; wins when both are present.
(variable_declaration
. (identifier) @type-binding.name
type: (_) @type-binding.type) @type-binding.annotation
;; Type bindings — value inference (F6): const t = <value>; where <value> is a
;; call (\`Counter.init()\`, \`makeThing()\`, \`node.asElement()\`), possibly
;; wrapped in \`try\` / \`catch …\` / \`… orelse …\` / parentheses — the shape of
;; nearly every Zig constructor call (\`const p = try Page.init(…)\`). The
;; query only pins the declaration; \`emitZigScopeCaptures\` unwraps the
;; wrappers and rewrites \`@type-binding.type\` to the type source (see
;; \`zigCallReturnTypeOf\`). Keyword-gated: \`_ = e.top();\` is an assignment
;; (same node type), not a binding of \`_\`. Rust's twin is
;; \`let x = Foo::new()\` / \`let x = foo().await\`.
(variable_declaration
"const" . (identifier) @type-binding.name
(_) @type-binding.value .) @type-binding.call-return
(variable_declaration
"var" . (identifier) @type-binding.name
(_) @type-binding.value .) @type-binding.call-return
;; Type bindings — return-type annotation (F6): \`fn make() !*Thing\` binds
;; \`make ↦ Thing\` in the enclosing scope (Module for free fns, the container's
;; Class scope for methods — that is where the compound resolver reads a
;; method's return type for \`node.asElement()\`), so \`const t = makeThing()\`
;; chains to \`Thing\`. Rust: \`(function_item … return_type:) @type-binding.return\`.
;; \`emitZigScopeCaptures\` drops builtin / \`type\` returns (a \`List ↦ type\`
;; binding would hijack the \`List(u8){}\` constructor chain).
(function_declaration
name: (identifier) @type-binding.name
type: (_) @type-binding.type) @type-binding.return
;; Type bindings — aliases (F5 field-access aliases + F7 type aliases):
;; \`const page = self.page;\` (F5: the RHS path is kept verbatim as the
;; "type", the compound resolver's member-alias branch re-resolves it as a
;; receiver chain — head \`self\` → class → field type), \`const LocalAlias = Local;\`,
;; \`const Proto = HtmlElement;\`, \`const T2 = Thing;\` (alias of an alias /
;; import), \`const B = util.List(u8);\` (an INSTANTIATED generic type
;; constructor). Zig has no \`type X = Y\` syntax — a type alias is a const
;; whose value is a type expression, and it stays a Const in the graph. What
;; must change is the scope side: bind the alias NAME to the value's type
;; text (Rust's \`let x = y\` / JS's \`const B = Foo\` \`@type-binding.alias\`,
;; source 'assignment-inferred'), so \`LocalAlias.mk()\` types through Case 4,
;; \`B.init()\` / \`x: B\` / \`B{}\` through Case 3 once \`normalizeZigTypeName\`
;; drops the comptime arguments (\`util.List(u8)\` → \`util.List\`), and every
;; binding that names the alias (\`var l = LocalAlias.mk()\`, \`var x: B\`) is
;; chained to the target by the shared \`followChainedRef\` /
;; \`followChainPostFinalize\`. The identifier / member shapes take \`var\` too:
;; \`var node = orig_node;\` is the same value alias as Rust's \`let x = y\`
;; and chains to the type of \`orig_node\` (a Zig type is comptime and never
;; \`var\`, so the type-alias reading only ever applies to \`const\`). The
;; call shape is \`const\`-only and kept only when the callee's last
;; identifier is TitleCase — see \`emitZigScopeCaptures\` (a value call
;; \`const t = util.makeThing()\` belongs to the call-return rules above and
;; must not receive a competing binding). A promoted namespace-member alias
;; (\`const Counter = counter.Counter;\` → named import) is skipped there too:
;; the import binding already carries the type.
(variable_declaration
"const" . (identifier) @type-binding.name
(identifier) @type-binding.type .) @type-binding.alias
(variable_declaration
"var" . (identifier) @type-binding.name
(identifier) @type-binding.type .) @type-binding.alias
(variable_declaration
"const" . (identifier) @type-binding.name
(field_expression) @type-binding.type .) @type-binding.alias
(variable_declaration
"var" . (identifier) @type-binding.name
(field_expression) @type-binding.type .) @type-binding.alias
(variable_declaration
"const" . (identifier) @type-binding.name
(call_expression) @type-binding.type .) @type-binding.alias
;; References — free calls: foo(...)
(call_expression
function: (identifier) @reference.name) @reference.call.free
;; References — member calls: obj.method(...) / mod.fn(...)
(call_expression
function: (field_expression
object: (_) @reference.receiver
member: (identifier) @reference.name)) @reference.call.member
;; References — constructor uses: T{ ... }
(struct_initializer
(identifier) @reference.name) @reference.call.constructor
;; References — qualified constructor uses: mod.T{ ... } / hub.sub.T{ ... }
;; Captured with the RECEIVER, not as a free constructor with a raw qualified
;; name, on purpose: the free-call fallback resolves a qualified constructor by
;; its simple tail, and a workspace-unique \`Thing\` then answers for
;; \`other.Thing{}\` whichever module the source named (measured: \`c.Thing{}\`
;; with no \`Thing\` in c.zig bound to a.zig's). With the receiver the site goes
;; through the receiver-bound namespace case, which resolves the member inside
;; the module the receiver is bound to — the same path \`mod.fn()\` takes — so
;; \`a.Thing{}\` and \`b.Thing{}\` each bind their own file and \`std.Thread.Mutex{}\`
;; binds nothing even when a local \`Mutex\` exists.
(struct_initializer
(field_expression
object: (_) @reference.receiver
member: (identifier) @reference.name)) @reference.call.constructor
;; References — generic instantiation literals: List(u8){ ... } /
;; lists.List(u8){ ... }. The type head is a call_expression — the
;; instantiation of the type constructor — which neither constructor rule
;; above matches, so the OUTER aggregate event had no site: only the inner
;; \`List(u8)\` call (a free / member call reference on the call node) reached
;; the graph (PR #1432 review, 8.11). Two sites on two anchors: the call
;; (an invocation of \`List\`) and this initializer (a construction of the
;; container \`List\` returns, marked \`(constructor)\`). The receiver form goes
;; through the same namespace path as \`mod.T{}\`.
(struct_initializer
(call_expression
function: (identifier) @reference.name)) @reference.call.constructor
(struct_initializer
(call_expression
function: (field_expression
object: (_) @reference.receiver
member: (identifier) @reference.name))) @reference.call.constructor
`;
let _parser: Parser | null = null;
let _query: Parser.Query | null = null;
function getZigLanguage(): Parameters<Parser['setLanguage']>[0] {
return _require('@tree-sitter-grammars/tree-sitter-zig');
}
export function getZigParser(): Parser {
if (_parser === null) {
_parser = new Parser();
_parser.setLanguage(getZigLanguage());
}
return _parser;
}
export function getZigScopeQuery(): Parser.Query {
if (_query === null) {
_query = new Parser.Query(getZigLanguage(), ZIG_SCOPE_QUERY);
}
return _query;
}

View file

@ -0,0 +1,375 @@
/**
* Zig payload captures (F6): `for (items) |it|`, `if (opt) |v|`,
* `while (it.next()) |x|`. The captured name has no annotation anywhere — its
* type is the SUBJECT's type minus one layer (slice/array element, optional
* payload) — so the tree-sitter query cannot bind it; this post-finalize hook
* reads the subject's binding from the finished scope model and injects the
* payload's. Rust/Go do the same (`rust/range-binding.ts`,
* `go/range-binding.ts`).
*
* Deliberately narrow and honest: a payload binds only when the subject's
* WRITTEN type visibly has the layer the construct removes — `[]T` / `[N]T` /
* `[*]T` / `*[N]T` for `for`, `?T` (after any `E!`) for `if` / `while`. A
* subject typed `std.ArrayList(T)`, `anytype`, or anything unresolved binds
* nothing rather than guessing. `catch |err|` (an error, never a container
* receiver) and `switch` prongs are skipped. The same one-layer projection
* types `const t = items[i];` / `opt.?` / `ptr.*` (`bindProjectedLocal`).
*
* Runs after `propagateImportedReturnTypes`, so return bindings hoisted from
* other files are visible when a subject is a call.
*/
import type { ParsedFile, Scope, ScopeId, TypeRef } from 'gitnexus-shared';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import { getZigParser } from './query.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe, ParseTimeoutError } from '../../../tree-sitter/safe-parse.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { logger } from '../../../logger.js';
import {
findClassBindingInScope,
findReceiverTypeBinding,
isClassLike,
} from '../../scope-resolution/scope/walkers.js';
import { isZigKeywordDeclaration, zigUnwrapValue } from './captures.js';
import { normalizeZigTypeName } from './interpret.js';
type ZigTree = ReturnType<ReturnType<typeof getZigParser>['parse']>;
const PAYLOAD_HOSTS: ReadonlySet<string> = new Set([
'for_statement',
'for_expression',
'if_statement',
'if_expression',
'while_statement',
'while_expression',
]);
export function populateZigRangeBindings(
parsedFiles: readonly ParsedFile[],
indexes: ScopeResolutionIndexes,
ctx: {
readonly fileContents: ReadonlyMap<string, string>;
readonly treeCache?: { get(filePath: string): unknown };
},
): void {
const parser = getZigParser();
// Class def nodeId → its Class scope (whose typeBindings hold the members'
// field types and the methods' return types). Same derivation as
// `buildWorkspaceResolutionIndex`, which this hook does not receive.
const classScopeByDefId = new Map<string, Scope>();
for (const parsed of parsedFiles) {
for (const scope of parsed.scopes) {
if (scope.kind !== 'Class') continue;
const cd = scope.ownedDefs.find((d) => isClassLike(d.type));
if (cd !== undefined) classScopeByDefId.set(cd.nodeId, scope);
}
}
for (const parsed of parsedFiles) {
const sourceText = ctx.fileContents.get(parsed.filePath);
if (sourceText === undefined) continue;
let tree = ctx.treeCache?.get(parsed.filePath) as ZigTree | undefined;
if (tree === undefined) {
try {
tree = parseSourceSafe(parser, sourceText, undefined, {
bufferSize: getTreeSitterBufferSize(sourceText),
});
} catch (err) {
if (err instanceof ParseTimeoutError) {
logger.warn(
{ file: parsed.filePath },
'zig range-binding: parse timed out, skipping file',
);
continue;
}
throw err;
}
}
const scopes = parsed.scopes;
if (scopes.length === 0) continue;
const resolver = new ZigSubjectTypeResolver(scopes, indexes, classScopeByDefId);
// Pre-order: an outer payload is bound before an inner construct reads it
// (`for (pages) |p| { if (p.frame) |f| … }`).
const visit = (node: SyntaxNode): void => {
if (PAYLOAD_HOSTS.has(node.type)) bindPayloads(node, resolver);
else if (node.type === 'variable_declaration') bindProjectedLocal(node, resolver);
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c !== null) visit(c);
}
};
visit(tree.rootNode);
}
}
/** Bind the payload identifiers of one `for` / `if` / `while` node. */
function bindPayloads(node: SyntaxNode, resolver: ZigSubjectTypeResolver): void {
const named = node.namedChildren.filter(
(c): c is SyntaxNode => c !== null && c.type !== 'comment',
);
// The payload right after the subject(s); a trailing `else |err|` payload
// belongs to the else_clause and is not a child here.
const payload = named.find((c) => c.type === 'payload');
if (payload === undefined) return;
const isFor = node.type === 'for_statement' || node.type === 'for_expression';
// A `for` lists its subjects BEFORE the payload; the body follows it.
const subjects: SyntaxNode[] = isFor
? named.slice(0, named.indexOf(payload)).filter((c) => c.type !== 'block_label')
: [node.childForFieldName('condition')].filter((c): c is SyntaxNode => c !== null);
if (subjects.length === 0) return;
// For a `for` the payload names pair positionally with the subjects
// (`for (items, 0..) |it, i|`); `if` / `while` capture one value.
const captured = payload.namedChildren.filter(
(c): c is SyntaxNode => c !== null && c.type === 'identifier',
);
const host = payloadHostScope(node, resolver);
if (host === undefined) return;
for (let i = 0; i < captured.length && i < subjects.length; i++) {
const name = captured[i]!.text;
if (name === '_') continue;
const subject = subjects[i]!;
if (subject.type === 'range_expression') continue; // `0..` — an index
const spelling = resolver.spellingOf(subject);
if (spelling === undefined) continue;
const projected = isFor ? zigElementSpelling(spelling) : zigOptionalPayloadSpelling(spelling);
if (projected === undefined) continue;
// `|*p|` captures a POINTER to the element/payload (the `*` is an
// anonymous payload child right before the identifier). Keep the written
// `*` so a later deref projection (`const q = p.*;`) still sees the
// layer; `rawName` strips it again, so method dispatch is unchanged.
const element = captured[i]!.previousSibling?.type === '*' ? `*${projected}` : projected;
const rawName = normalizeZigTypeName(element);
if (rawName.length === 0 || rawName.startsWith('@')) continue;
const existing = host.typeBindings.get(name);
// Zig forbids shadowing, so an existing binding of this name in the host
// can only be a sibling construct's payload (bare-expression bodies share
// the enclosing scope). Two different types for one name: decline both
// rather than let the last one type the first one's body.
if (existing !== undefined) {
if (existing.rawName !== rawName) {
(host.typeBindings as Map<string, TypeRef>).delete(name);
}
continue;
}
const ref: TypeRef =
element === rawName
? { rawName, declaredAtScope: host.id, source: 'annotation' }
: { rawName, declaredSpelling: element, declaredAtScope: host.id, source: 'annotation' };
(host.typeBindings as Map<string, TypeRef>).set(name, ref);
}
}
/** `const t = items[i];` / `const t = opt.?;` / `const t = ptr.*;` — a local
* bound to ONE LAYER under a typed subject: the element of a slice/array
* (not a `[a..b]` re-slice), the payload of an optional, the pointee of a
* pointer. Same honesty rule as the payloads: the subject's WRITTEN type
* must show the layer. Skipped when the name is already typed (an
* annotation, or a capture-time inference). */
function bindProjectedLocal(decl: SyntaxNode, resolver: ZigSubjectTypeResolver): void {
if (!isZigKeywordDeclaration(decl)) return;
const named = decl.namedChildren.filter(
(c): c is SyntaxNode => c !== null && c.type !== 'comment',
);
if (named.length < 2 || named[0]!.type !== 'identifier') return;
const value = named[named.length - 1]!;
if (value.id === decl.childForFieldName('type')?.id) return; // `const x: T;`
const inner = zigUnwrapValue(value);
let subject: SyntaxNode | null;
let project: (spelling: string) => string | undefined;
switch (inner.type) {
case 'index_expression':
if (inner.childForFieldName('index')?.type === 'range_expression') return; // `a[0..n]`
subject = inner.childForFieldName('object');
project = zigElementSpelling;
break;
case 'null_coercion_expression':
subject = inner.namedChild(0);
project = zigOptionalPayloadSpelling;
break;
case 'dereference_expression':
subject = inner.namedChild(0);
project = zigPointeeSpelling;
break;
default:
return;
}
if (subject === null) return;
const name = named[0]!.text;
const host = resolver.scopeAt(decl.startPosition.row + 1, decl.startPosition.column, false);
if (host === undefined || host.typeBindings.has(name)) return;
const spelling = resolver.spellingOf(subject);
if (spelling === undefined) return;
const projected = project(spelling);
if (projected === undefined) return;
const rawName = normalizeZigTypeName(projected);
if (rawName.length === 0 || rawName.startsWith('@')) return;
const ref: TypeRef =
projected === rawName
? { rawName, declaredAtScope: host.id, source: 'assignment-inferred' }
: {
rawName,
declaredSpelling: projected,
declaredAtScope: host.id,
source: 'assignment-inferred',
};
(host.typeBindings as Map<string, TypeRef>).set(name, ref);
}
/** `*T` / `*const T` / `E!*T` → `T`; undefined when not visibly a pointer. */
export function zigPointeeSpelling(spelling: string): string | undefined {
let t = spelling.trim();
const bang = t.lastIndexOf('!');
if (bang !== -1) t = t.slice(bang + 1).trim();
const m = /^\*(const\s+)?/.exec(t);
if (m === null) return undefined;
const rest = t.slice(m[0].length).trim();
return rest.length > 0 ? rest : undefined;
}
function body(node: SyntaxNode): SyntaxNode | null {
return node.childForFieldName('body');
}
/** The scope the payload name lives in: the body block's own scope when the
* body is a block (`|t| { … }`), otherwise the innermost scope enclosing the
* construct (a bare-expression body: `for (items) |t| t.run();`). */
function payloadHostScope(node: SyntaxNode, resolver: ZigSubjectTypeResolver): Scope | undefined {
// for/if/while_statement carry `body:`; in the *_expression forms the
// body is the named child right after the payload.
let bodyNode = body(node);
if (bodyNode === null) {
const named = node.namedChildren.filter((c): c is SyntaxNode => c !== null);
const at = named.findIndex((c) => c.type === 'payload');
bodyNode = at === -1 ? null : (named[at + 1] ?? null);
}
let block: SyntaxNode | null = bodyNode;
if (block?.type === 'block_expression')
block = block.namedChildren.find((c) => c?.type === 'block') ?? null;
if (block?.type === 'labeled_statement')
block = block.namedChildren.find((c) => c?.type === 'block') ?? null;
if (block?.type === 'block') {
const exact = resolver.scopeAt(block.startPosition.row + 1, block.startPosition.column, true);
if (exact !== undefined) return exact;
}
return resolver.scopeAt(node.startPosition.row + 1, node.startPosition.column, false);
}
/** `[]T` / `[]const T` / `[N]T` / `[*]T` / `[*:0]T` / `*[N]T` / `*const []T`
* → `T` (with `T`'s own sigils kept: `[]*Thing` → `*Thing`). Undefined when
* the written type has no slice/array layer to remove. */
export function zigElementSpelling(spelling: string): string | undefined {
let t = spelling.trim();
const bang = t.lastIndexOf('!');
if (bang !== -1) t = t.slice(bang + 1).trim();
// A pointer TO an array/slice iterates the pointee.
t = t.replace(/^\*(const\s+)?(?=\[)/, '');
const m = /^\[[^\]]*\]\s*(const\s+)?/.exec(t);
if (m === null) return undefined;
const rest = t.slice(m[0].length).trim();
return rest.length > 0 ? rest : undefined;
}
/** `?T` / `?*T` / `E!?T` → `T` (sigils of `T` kept). Undefined when the
* written type is not visibly optional. */
export function zigOptionalPayloadSpelling(spelling: string): string | undefined {
let t = spelling.trim();
const bang = t.lastIndexOf('!');
if (bang !== -1) t = t.slice(bang + 1).trim();
if (!t.startsWith('?')) return undefined;
const rest = t.slice(1).trim();
return rest.length > 0 ? rest : undefined;
}
/** Resolves a subject expression to the WRITTEN type spelling of its binding
* (`declaredSpelling` when the capture layer reduced it, else `rawName`),
* through the finished scope model of one file. */
class ZigSubjectTypeResolver {
constructor(
private readonly scopes: readonly Scope[],
private readonly indexes: ScopeResolutionIndexes,
private readonly classScopeByDefId: ReadonlyMap<string, Scope>,
) {}
/** Innermost scope containing (line, col); with `exact`, only a scope
* STARTING there (a block's own scope). */
scopeAt(line: number, col: number, exact: boolean): Scope | undefined {
let best: Scope | undefined;
for (const s of this.scopes) {
const r = s.range;
if (exact) {
if (r.startLine === line && r.startCol === col && s.kind === 'Block') return s;
continue;
}
const startsBefore = r.startLine < line || (r.startLine === line && r.startCol <= col);
const endsAfter = r.endLine > line || (r.endLine === line && r.endCol >= col);
if (!startsBefore || !endsAfter) continue;
if (best === undefined || contains(best.range, r)) best = s;
}
return best;
}
spellingOf(subject: SyntaxNode): string | undefined {
const ref = this.typeRefOf(subject);
return ref === undefined ? undefined : (ref.declaredSpelling ?? ref.rawName);
}
private typeRefOf(subjectRaw: SyntaxNode): TypeRef | undefined {
const subject = zigUnwrapValue(subjectRaw);
const scope = this.scopeAt(subject.startPosition.row + 1, subject.startPosition.column, false);
if (scope === undefined) return undefined;
switch (subject.type) {
case 'identifier':
return findReceiverTypeBinding(scope.id, subject.text, this.indexes);
case 'field_expression': {
// `self.items` / `page.session` — the member's binding on the
// object's class scope (field type, or a method's return type).
const object = subject.childForFieldName('object');
const member = subject.childForFieldName('member');
if (object === null || member === null) return undefined;
return this.memberRefOf(object, member.text, scope.id);
}
case 'call_expression': {
const callee = subject.childForFieldName('function');
if (callee === null) return undefined;
if (callee.type === 'identifier') {
// A free call: the fn's return binding, in the scope chain.
return findReceiverTypeBinding(scope.id, callee.text, this.indexes);
}
if (callee.type !== 'field_expression') return undefined;
const object = callee.childForFieldName('object');
const member = callee.childForFieldName('member');
if (object === null || member === null) return undefined;
return this.memberRefOf(object, member.text, scope.id);
}
default:
return undefined;
}
}
/** The binding of `member` on the class the `object` expression is typed
* by — recursively through field chains (`self.a.b`). */
private memberRefOf(object: SyntaxNode, member: string, scopeId: ScopeId): TypeRef | undefined {
const objectRef = this.typeRefOf(object);
if (objectRef === undefined) return undefined;
// A compound rawName (`node.asElement()`, F6's member-call shape) needs
// the shared compound resolver; not walked here.
if (objectRef.rawName.includes('(')) return undefined;
const classDef = findClassBindingInScope(scopeId, objectRef.rawName, this.indexes);
if (classDef === undefined) return undefined;
const classScope = this.classScopeByDefId.get(classDef.nodeId);
return classScope?.typeBindings.get(member);
}
}
function contains(outer: Scope['range'], inner: Scope['range']): boolean {
const startsBefore =
outer.startLine < inner.startLine ||
(outer.startLine === inner.startLine && outer.startCol <= inner.startCol);
const endsAfter =
outer.endLine > inner.endLine ||
(outer.endLine === inner.endLine && outer.endCol >= inner.endCol);
return startsBefore && endsAfter;
}

View file

@ -0,0 +1,79 @@
/**
* Zig `ScopeResolver` registered in `SCOPE_RESOLVERS` and consumed by the
* generic `runScopeResolution` orchestrator.
*
* Thin wiring: Zig has no inheritance (default MRO linearization over an
* empty heritage set), no `super`, and is statically typed (field-fallback
* heuristic off per the contract guidance). Import resolution reuses the
* same `resolveZigImportInternal` the legacy import-resolver config wraps,
* with `build.zig.zon` `.path` deps threaded through `loadResolutionConfig`.
*/
import type { ParsedFile } from 'gitnexus-shared';
import { SupportedLanguages } from 'gitnexus-shared';
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import { loadZigBuildConfig, type ZigBuildZonConfig } from '../../language-config.js';
import { resolveZigImportInternal } from '../../import-resolvers/zig.js';
import { zigProvider } from '../zig.js';
import { expandZigWildcardNames, zigArityCompatibility, zigMergeBindings } from './index.js';
import { populateZigRangeBindings } from './range-binding.js';
export const zigScopeResolver: ScopeResolver = {
language: SupportedLanguages.Zig,
languageProvider: zigProvider,
importEdgeReason: 'zig-scope: import',
// A struct literal `T{ .f = x }` is a CALLS edge to the Struct node (the
// Rust `T { .. }` / Go `T{}` shape). Zig has no Constructor nodes, so
// nothing but this marker tells that edge from an invocation on the edge
// itself — `main → SpawnRequest` looked like a call to a function
// (PR #1432 review). Emits `local-call (constructor)` and friends.
markConstructionSites: true,
// Hub modules are how Zig projects publish their types: `pub const Terminal
// = @import("Terminal.zig");` / `pub const PRNG = @import("prng.zig");` in a
// file that declares nothing itself. Consumers then write
// `terminal.Terminal.init()`, `stdx.PRNG.from_seed()`, `t: stdx.Thing`.
// Measured on real projects before → after this flag: CALLS into ghostty's
// `src/terminal/` from outside it 46 → 253; into tigerbeetle's `stdx` hub
// from outside it 837 → 1500 (136 `stdx.Type.fn(` sites, 289 annotations).
namespaceExportsIncludeImportedNames: true,
// A qualified receiver is a chain of `const` handles — hub modules
// republishing modules (`hub.sub.Thing{}`), types nested in types
// (`mod.Outer.Inner{}`), enum variants through the module
// (`opmod.Op.lookup.event_max()`) — walked hop by hop from the verified
// import; a one-hop split at the last dot resolved none of them.
resolveNamespaceChains: true,
loadResolutionConfig: (repoPath: string) => loadZigBuildConfig(repoPath),
resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) =>
resolveZigImportInternal(
fromFile,
targetRaw,
allFilePaths,
(resolutionConfig as ZigBuildZonConfig | null | undefined) ?? null,
),
// `pub usingnamespace @import("x.zig");` — target decls become local decls.
expandsWildcardTo: (targetModuleScope, parsedFiles) =>
expandZigWildcardNames(targetModuleScope, parsedFiles),
mergeBindings: zigMergeBindings,
arityCompatibility: zigArityCompatibility,
buildMro: (graph, parsedFiles, nodeLookup) =>
buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
populateOwners: (parsed: ParsedFile) => populateClassOwnedMembers(parsed),
// Payload captures — `for (items) |it|`, `if (opt) |v|`, `while (it.next())
// |x|` — typed from the subject's binding after finalize (F6).
populateRangeBindings: populateZigRangeBindings,
// Zig has no `super`.
isSuperReceiver: () => false,
// Statically typed — the field-fallback heuristic over-connects.
fieldFallbackOnMethodLookup: false,
};

View file

@ -0,0 +1,164 @@
import type {
BindingRef,
Callsite,
CaptureMatch,
ParsedFile,
Scope,
ScopeId,
ScopeTree,
SymbolDefinition,
TypeRef,
} from 'gitnexus-shared';
/** Keep parameter (incl. `self`) typeBindings in the function scope —
* hoisting them to Module would pollute other functions' receiver
* resolution (same rationale as `goBindingScopeFor`). */
export function zigBindingScopeFor(
decl: CaptureMatch,
innermost: Scope,
tree: ScopeTree,
): ScopeId | null {
if (decl['@type-binding.parameter'] !== undefined) {
return innermost.id;
}
// A container returned by a generic type constructor (`fn List(comptime T:
// type) type { return struct {…}; }`) is anchored on the container node,
// whose innermost enclosing scope is the FUNCTION BODY. Its name is only
// useful to callers (`List(u8){}`, `List(u8).init()`), so bind it in the
// module scope beside the Function def of the same name — `List` really is
// both a callable and a type. `zigTypeConstructorOf` in the walker decides
// WHICH containers get this rule; the marker capture carries the verdict.
if (decl['@declaration.type-constructor'] !== undefined) {
let scope: Scope | undefined = innermost;
while (scope !== undefined && scope.kind !== 'Module') scope = tree.getParent(scope.id);
return scope?.id ?? null;
}
// File-struct members: the file's Class scope spans the whole file (same
// range as the Module scope, nested under it). Their DEFS stay owned by the
// Class scope (that is what makes them methods/fields of the file's Struct),
// but their NAMES must stay visible at module level — `Page.init()` from
// another file is a namespace-member lookup over the module scope's
// bindings, and `usingnamespace` expansion reads the same. Hoist every
// declaration binding hosted by an equal-range Class scope to its Module
// parent. Type bindings (`@type-binding.*`, incl. field types) are NOT
// hoisted: the compound resolver reads member types from the class scope.
const anchor = ZIG_DECLARATION_ANCHORS.map((k) => decl[k]).find((c) => c !== undefined);
if (anchor !== undefined) {
// Reproduce the extractor's auto-hoist (a scope-creating declaration is
// bound in the scope OUTSIDE its own body), then step past the file's
// Class scope when that is where the name would land.
let host: Scope | undefined = innermost;
if (host.parent !== null && sameRange(anchor.range, host.range)) {
host = tree.getParent(host.id);
}
if (host !== undefined && host.kind === 'Class' && host.parent !== null) {
const parent = tree.getParent(host.id);
if (parent !== undefined && parent.kind === 'Module' && sameRange(parent.range, host.range)) {
return parent.id;
}
}
}
return null; // default auto-hoist for other bindings
}
const ZIG_DECLARATION_ANCHORS = [
'@declaration.function',
'@declaration.method',
'@declaration.struct',
'@declaration.enum',
'@declaration.union',
'@declaration.field',
'@declaration.variable',
] as const;
function sameRange(a: Scope['range'], b: Scope['range']): boolean {
return (
a.startLine === b.startLine &&
a.startCol === b.startCol &&
a.endLine === b.endLine &&
a.endCol === b.endCol
);
}
/** `pub usingnamespace @import("x.zig");` — every declaration of the target
* module becomes a declaration of the importer. Enumerates the target's
* module-level names for the finalize wildcard expansion (Dart pattern).
* Zig visibility (`pub`) is not recorded on scope-side defs, so this
* over-approximates to every top-level name; the structure phase's
* `isExported` is the authoritative visibility. */
export function expandZigWildcardNames(
targetModuleScope: ScopeId,
parsedFiles: readonly ParsedFile[],
): readonly string[] {
const target = parsedFiles.find((p) => p.moduleScope === targetModuleScope);
if (target === undefined) return [];
const seen = new Set<string>();
const names: string[] = [];
for (const def of target.localDefs) {
const qn = def.qualifiedName;
if (qn === undefined || qn.length === 0 || qn.includes('.')) continue; // top-level only
if (seen.has(qn)) continue;
seen.add(qn);
names.push(qn);
}
return names;
}
/** Zig's receiver convention is a FIRST parameter named `self`; the
* `self`-sourced typeBinding on the function scope carries its type.
* Position is enforced upstream, not here: `interpretZigTypeBinding` only
* sources a binding as `self` when `emitZigScopeCaptures` tagged it
* `@type-binding.first-parameter`, so a later parameter named `self`
* arrives as `parameter-annotation` and is never returned by this hook. */
export function zigReceiverBinding(functionScope: Scope): TypeRef | null {
if (functionScope.kind !== 'Function') return null;
for (const binding of functionScope.typeBindings.values()) {
if (binding.source === 'self') return binding;
}
return null;
}
const TIER: Record<BindingRef['origin'], number> = {
local: 0,
namespace: 1,
import: 2,
reexport: 3,
wildcard: 4,
};
/** Local declarations shadow imports; deterministic order within a tier. */
export function zigMergeBindings(
existing: readonly BindingRef[],
incoming: readonly BindingRef[],
_scopeId: string,
): BindingRef[] {
const all = [...existing, ...incoming];
if (all.length === 0) return [];
let bestTier = 99;
for (const b of all) {
const t = TIER[b.origin] ?? 99;
if (t < bestTier) bestTier = t;
}
const byNode = new Map<string, BindingRef>();
for (const b of all) {
if ((TIER[b.origin] ?? 99) !== bestTier) continue;
if (!byNode.has(b.def.nodeId)) byNode.set(b.def.nodeId, b);
}
return [...byNode.values()].sort((a, b) => a.def.nodeId.localeCompare(b.def.nodeId));
}
/** Zig has no overloading; without synthesized arity metadata on
* declarations the comparison is always 'unknown' — kept as a real
* bounds check so it turns on if arity captures are added later. */
export function zigArityCompatibility(
callsite: Callsite,
def: SymbolDefinition,
): 'compatible' | 'unknown' | 'incompatible' {
const max = def.parameterCount;
const min = def.requiredParameterCount;
if (max === undefined && min === undefined) return 'unknown';
if (!Number.isFinite(callsite.arity) || callsite.arity < 0) return 'unknown';
if (min !== undefined && callsite.arity < min) return 'incompatible';
if (max !== undefined && callsite.arity > max) return 'incompatible';
return 'compatible';
}

View file

@ -0,0 +1,141 @@
import { SupportedLanguages } from 'gitnexus-shared';
import type { MethodExtractionConfig, ParameterInfo } from '../../method-types.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { hasZigPubKeyword } from '../../export-detection.js';
import {
ZIG_CONTAINER_TYPES,
zigContainerName,
zigReceiverParameter,
} from '../../languages/zig/captures.js';
/**
* Zig method extraction.
*
* tree-sitter-zig containers (struct/enum/union/opaque) are anonymous; the
* binding name lives on the parent variable_declaration, or on the enclosing
* generic type constructor (`fn List(comptime T: type) type { return struct
* {…}; }`) — `zigContainerName` decides. Methods inside a container appear as
* plain `function_declaration` children of the container node.
*
* The first parameter is the receiver when it is named `self` OR typed as the
* enclosing container (`replica: *Replica`, `pool: *@This()`) — see
* `zigReceiverParameter`; unlike Rust, Zig has no dedicated `self_parameter`
* node type and `self` is a convention, not a rule.
*/
const extractZigOwnerName = (node: SyntaxNode, filePath?: string): string | undefined =>
zigContainerName(node, filePath);
const extractZigName = (node: SyntaxNode): string | undefined => {
const nameNode = node.childForFieldName('name');
return nameNode?.text;
};
/**
* The `parameters` node of a function_declaration. tree-sitter-zig 1.1.2
* attaches it as a plain named child — NOT under a `parameters:` field (only
* `name`, `type` and `body` are fields), so a field lookup is always null
* (and the grammar-literal gate flags it as a dead field). Reading it that
* way silently produced empty parameter lists, no receiver, and
* `isStatic: true` for every method.
*/
const zigParameterList = (node: SyntaxNode): SyntaxNode | null =>
node.namedChildren.find((child): child is SyntaxNode => child?.type === 'parameters') ?? null;
const extractZigReturnType = (node: SyntaxNode): string | undefined => {
// tree-sitter-zig labels the return type as the `type` field on
// function_declaration (the same field name used for parameter types).
const typeNode = node.childForFieldName('type');
return typeNode?.text?.trim();
};
/**
* Regular parameters only. The receiver parameter (`zigReceiverParameter`) is
* reported through `extractReceiverType`, not the parameter list (same split
* as Rust's `self_parameter` skip in `configs/rust.ts`). `filePath` is what
* names a file-struct (`fn add(ledger: *Ledger)` in `Ledger.zig`): without it
* the receiver rule cannot see the file stem and such a fn reads as static
* with the receiver in its arity — an id the scope side, which always has
* the path, never produces, so its CALLS edges went nowhere.
*/
const extractZigParameters = (node: SyntaxNode, filePath?: string): ParameterInfo[] => {
const paramList = zigParameterList(node);
if (!paramList) return [];
const params: ParameterInfo[] = [];
const receiver = zigReceiverParameter(node, filePath);
for (let i = 0; i < paramList.namedChildCount; i++) {
const param = paramList.namedChild(i);
if (!param || param.type !== 'parameter') continue;
if (receiver !== null && param.id === receiver.id) continue;
const nameNode = param.childForFieldName('name');
const typeNode = param.childForFieldName('type');
params.push({
name: nameNode?.text ?? '?',
type: typeNode?.text?.trim() ?? null,
rawType: typeNode?.text?.trim() ?? null,
isOptional: false,
isVariadic: false,
});
}
return params;
};
const extractZigReceiverType = (node: SyntaxNode, filePath?: string): string | undefined =>
zigReceiverParameter(node, filePath)?.childForFieldName('type')?.text?.trim();
/**
* Names a `test_declaration` during the enclosing-function walk (parse-worker
* `findEnclosingFunctionId`) with the SAME spelling ZIG_QUERIES captures as
* `@name` — the string node with its quotes — so calls inside `test "x" {}`
* attribute to the test's own Function node.
*
* Anonymous `test {}` and decl-tests `test add {}` are not graph nodes. They
* return `''`, not `null`: `null` falls through to `genericFuncName`, whose
* first-identifier scan would name `test add {}` "add" — the REAL `fn add`'s
* id — and hang the test body's calls on it. The empty name is falsy, so
* `findEnclosingFunctionId` skips this node WITHOUT attributing to it and
* keeps walking up; a test block can only sit at container level, so the walk
* reaches the file and the calls attribute to the File.
*/
const extractZigFunctionName = (
node: SyntaxNode,
): { funcName: string | null; label: 'Function' } | null => {
if (node.type !== 'test_declaration') return null;
const nameString = node.namedChildren.find(
(child): child is SyntaxNode => child?.type === 'string',
);
return { funcName: nameString?.text ?? '', label: 'Function' };
};
export const zigMethodConfig: MethodExtractionConfig = {
language: SupportedLanguages.Zig,
// `source_file`: a file-struct's top-level fns are methods of the file's Struct.
typeDeclarationNodes: [...ZIG_CONTAINER_TYPES, 'source_file'],
methodNodeTypes: ['function_declaration'],
bodyNodeTypes: [],
extractOwnerName: extractZigOwnerName,
extractName: extractZigName,
extractFunctionName: extractZigFunctionName,
extractReturnType: extractZigReturnType,
extractParameters: extractZigParameters,
// `pub` only: `export fn` is C linkage, still private to other Zig files
// (isExported carries the FFI fact — see export-detection.ts).
extractVisibility: (node) => (hasZigPubKeyword(node) ? 'public' : 'private'),
extractReceiverType: extractZigReceiverType,
isStatic(node, filePath) {
// A Zig "method" is static when it has no receiver parameter — `self` OR
// a first parameter typed as the enclosing container (`replica:
// *Replica`, `pool: *@This()`, `ledger: *Ledger` in `Ledger.zig`); see
// `zigReceiverParameter`.
return zigReceiverParameter(node, filePath) === null;
},
isAbstract() {
return false;
},
isFinal() {
return false;
},
};

View file

@ -90,7 +90,7 @@ export function createMethodExtractor(config: MethodExtractionConfig): MethodExt
// Resolve owner name: config hook → field-based → type_identifier → simple_identifier → "Companion"
let ownerName: string | undefined;
if (config.extractOwnerName) {
ownerName = config.extractOwnerName(node);
ownerName = config.extractOwnerName(node, context.filePath);
}
if (!ownerName) {
const nameField = node.childForFieldName('name');
@ -166,6 +166,17 @@ function findBodies(node: SyntaxNode, bodyNodeSet: Set<string>): SyntaxNode[] {
result.push(bodyField);
addNestedBodies(bodyField, bodyNodeSet, result);
}
// Grammars with no body wrapper at all: a config that declares NO
// `bodyNodeTypes` (tree-sitter-zig's struct_declaration holds its
// function_declaration children directly) uses the type-declaration node
// itself as the body. The downstream walk filters by `methodNodeTypes`, so
// unrelated children are ignored. Deliberately NOT a fallback for configs
// that do declare body wrappers: for them a node without its wrapper is a
// bodiless declaration (forward declaration, `declare class`), and scanning
// it would change every such language for no method it could find.
if (result.length === 0 && bodyNodeSet.size === 0) {
result.push(node);
}
return result;
}
@ -236,13 +247,15 @@ function buildMethod(
// Static-owner detection is config-driven: each language declares which
// container node types imply static (e.g. Ruby singleton_class, Kotlin companion_object).
const isStatic = (config.staticOwnerTypes?.has(ownerNode.type) ?? false) || config.isStatic(node);
const isStatic =
(config.staticOwnerTypes?.has(ownerNode.type) ?? false) ||
config.isStatic(node, context.filePath);
return {
name,
receiverType: config.extractReceiverType?.(node) ?? null,
receiverType: config.extractReceiverType?.(node, context.filePath) ?? null,
returnType: config.extractReturnType(node) ?? null,
parameters: config.extractParameters(node),
parameters: config.extractParameters(node, context.filePath),
visibility: config.extractVisibility(node),
isStatic,
isAbstract,

View file

@ -89,13 +89,19 @@ export interface MethodExtractionConfig {
bodyNodeTypes: string[];
extractName: (node: SyntaxNode) => string | undefined;
extractReturnType: (node: SyntaxNode) => string | undefined;
extractParameters: (node: SyntaxNode) => ParameterInfo[];
/** The optional `filePath` (the extractor context's) is passed to
* `extractParameters`, `isStatic` and `extractReceiverType` for languages
* whose receiver rule depends on the file — Zig's file-struct, whose type
* name is the file stem, so `fn incr(counter: *Counter)` in `Counter.zig`
* is a method only when the file is known. Same optional-trailing-argument
* shape as `extractOwnerName`; every other config ignores it. */
extractParameters: (node: SyntaxNode, filePath?: string) => ParameterInfo[];
extractVisibility: (node: SyntaxNode) => MethodVisibility;
isStatic: (node: SyntaxNode) => boolean;
isStatic: (node: SyntaxNode, filePath?: string) => boolean;
isAbstract: (node: SyntaxNode, ownerNode: SyntaxNode) => boolean;
isFinal: (node: SyntaxNode) => boolean;
extractAnnotations?: (node: SyntaxNode) => string[];
extractReceiverType?: (node: SyntaxNode) => string | undefined;
extractReceiverType?: (node: SyntaxNode, filePath?: string) => string | undefined;
isVirtual?: (node: SyntaxNode) => boolean;
isOverride?: (node: SyntaxNode) => boolean;
isAsync?: (node: SyntaxNode) => boolean;
@ -107,7 +113,7 @@ export interface MethodExtractionConfig {
* When the ownerNode matches one of these types, isStatic is forced true. */
staticOwnerTypes?: ReadonlySet<string>;
/** Resolve the owner name from a standalone method node (e.g. Go receiver type). */
extractOwnerName?: (node: SyntaxNode) => string | undefined;
extractOwnerName?: (node: SyntaxNode, filePath?: string) => string | undefined;
/** Extract a primary constructor from the owner node itself (e.g. C# 12 class Point(int x, int y)). */
extractPrimaryConstructor?: (
ownerNode: SyntaxNode,

View file

@ -7,6 +7,15 @@
* inbound and outbound facts are captured during parse and survive the parse
* cache; until now nothing read them.
*
* Since `asyncApiSpecPath`, the phase has a SECOND source that is not Spring
* and not source code at all: AsyncAPI documents read off disk
* (`ingestion/asyncapi/document.ts`). They mint the same `Destination` nodes on
* the same key, so both sources meet on one node. The reader is deliberately
* framework-neutral and lives outside `frameworks/spring/`; only the emit is
* hosted here, because this is where the node's keying rule is enforced and
* splitting that rule across two phases is how it drifts. The phase name is
* accurate about its origin rather than its current contents.
*
* Shaped after `Route` + `HANDLES_ROUTE` in `routes.ts` — a framework overlay
* node keyed by what it names, with the callable pointing at it, down to the
* detail that the key pairs the address with the one dimension that can make
@ -56,12 +65,16 @@
* prevent.
*
* @deps parse, scopeResolution, springConfig
* @reads Spring messaging capture facts, Method/Function nodes, Property nodes
* @writes Destination nodes; CONSUMES_FROM / PUBLISHES_TO / USES edges
* @reads Spring messaging capture facts, Method/Function nodes, Property nodes,
* AsyncAPI documents under `options.asyncApiSpecPath` (filesystem)
* @writes Destination nodes; synthetic File nodes for out-of-tree documents;
* CONSUMES_FROM / PUBLISHES_TO / USES edges
*/
import path from 'node:path';
import type { GraphNode, Range } from 'gitnexus-shared';
import { generateId } from '../../../lib/utils.js';
import { readAsyncApiDocuments } from '../asyncapi/document.js';
import { logger } from '../../logger.js';
import type { KnowledgeGraph } from '../../graph/types.js';
import { SPRING_CONFIG_DESCRIPTION } from '../frameworks/spring/config-bindings.js';
@ -101,6 +114,37 @@ export interface SpringDestinationsOutput {
readonly refusalsByReason: Readonly<Record<string, number>>;
/** Destination -> Property provenance edges for `${key}` placeholders. */
readonly configKeyLinks: number;
/**
* What reading AsyncAPI documents contributed, present only when
* `asyncApiSpecPath` was configured. Absent means "not asked for", which is
* deliberately distinguishable from a configured path that yielded nothing —
* a mistyped directory and a repository with no documents are different
* problems with different fixes, and one zero cannot say which happened.
*/
readonly specDocuments?: SpecDocumentStats;
}
export interface SpecDocumentStats {
/** Entries skipped because they were symbolic links, and whether a bound
* stopped the walk. Both make every other number here a FLOOR, and a floor
* reported as a total is the failure this whole block exists to prevent. */
readonly symlinksSkipped: number;
readonly truncated: boolean;
/** Files considered under the configured path. */
readonly scanned: number;
/** Files that parsed as an AsyncAPI 3.x document and yielded an operation. */
readonly accepted: number;
/** Operations normalized to a (broker, address, action) triple. */
readonly operations: number;
/** Destination nodes this reading minted that no source site had already. */
readonly destinations: number;
/** CONSUMES_FROM + PUBLISHES_TO edges from documents. */
readonly edges: number;
/** Document- and operation-level refusals, by reason. Kept apart from
* `refusalsByReason` above: that number is the denominator of the SOURCE
* unresolved fraction, and a mistyped specification directory must not be
* able to make the source look worse than it is. */
readonly refusalsByReason: Readonly<Record<string, number>>;
}
/**
@ -274,6 +318,192 @@ function edgeReason(candidate: SpringDestinationCandidate): string {
return `spring-${candidate.source}:${element}${exchange}`;
}
/**
* Pseudo-path prefix for a document that is not a file of this repository.
*
* An edge needs a source node, and the emit below refuses to attach one to a
* `File` that does not exist — so a document supplied from outside the working
* tree needs an identity minted for it. The same answer
* `frameworks/spring/actuator-runtime.ts` gives for Actuator snapshots
* (`spring-actuator:<endpoint>`): a prefixed pseudo-path that a real
* repo-relative path is not expected to take.
*
* That is a CONVENTION, not a guarantee, and the difference is worth stating
* because the neighbouring prefix states it too strongly. A colon is a legal
* POSIX filename character, so a committed file literally named
* `asyncapi:orders.yaml` would share this identity — costing one merged node
* and a misattributed edge, never a wrong address. Windows cannot express the
* collision at all. It is accepted on the same terms the Actuator prefix
* already is rather than escaped, because an escape would have to be applied to
* both prefixes at once to be worth anything.
*/
const DOCUMENT_FILE_PREFIX = 'asyncapi:';
/**
* The `File` node an AsyncAPI operation's edge hangs off.
*
* A document COMMITTED to the repository already has a real `File` node, and
* using it is strictly better: the edge lands on something the reader can open,
* and the ordinary per-file writeback keeps it honest. Only a document from
* outside the tree gets a synthetic node.
*/
function documentFileNodeId(
ctx: PipelineContext,
configuredRoot: string,
documentPath: string,
): string {
const repoRelative = path.relative(ctx.repoPath, documentPath);
if (repoRelative !== '' && !repoRelative.startsWith('..') && !path.isAbsolute(repoRelative)) {
const realId = generateId('File', repoRelative.split(path.sep).join('/'));
if (ctx.graph.getNode(realId) !== undefined) return realId;
}
// Relative to the CONFIGURED root, not to the filesystem root: an absolute
// path would put a machine's directory layout into the graph, and two
// machines indexing the same documents would then disagree about their ids.
const relative = path.relative(configuredRoot, documentPath);
const label =
relative === '' || relative.startsWith('..')
? path.basename(documentPath)
: relative.split(path.sep).join('/');
const filePath = `${DOCUMENT_FILE_PREFIX}${label}`;
const id = generateId('File', filePath);
if (ctx.graph.getNode(id) === undefined) {
ctx.graph.addNode({
id,
label: 'File',
properties: { name: path.basename(documentPath), filePath },
});
}
return id;
}
/**
* Mint destinations stated by AsyncAPI documents, with no claim about code.
*
* ── WHY THIS DOES NOT TRY TO FIND THE HANDLER ─────────────────────────────
*
* A document says an address is sent to or received from; it does not say by
* which method. Guessing that mapping is a real temptation and a bad trade: the
* addresses in one document partition by (broker, action) into buckets that
* usually hold more than one operation, so any assignment beyond a bucket of
* size one is a heuristic — and a wrong one silently attaches a real address to
* the wrong handler, which is a false connection dressed as a resolved one.
*
* So this claims only what the document actually states: that THIS SERVICE
* talks to that address on that broker in that direction. The edge therefore
* starts at the document, not at a callable. That is a weaker statement than a
* source-derived edge and it is worth having anyway, because it is available in
* cases where the source cannot supply one at all — a listener registered
* programmatically, a broker this codebase has no patterns for, or a language
* whose messaging idiom nobody has taught it yet.
*
* The node itself is the ordinary resolved `Destination`: same key, same
* `address` property, so a document and a source site that name one address on
* one broker land on ONE node and the two halves of a conversation meet. That
* is the whole point, and it is why this mints nothing of its own invention.
*/
async function emitSpecDestinations(
ctx: PipelineContext,
specPath: string,
): Promise<SpecDocumentStats> {
const read = await readAsyncApiDocuments(ctx.repoPath, specPath);
const configuredRoot = path.resolve(ctx.repoPath, specPath);
let destinations = 0;
let edges = 0;
for (const operation of read.operations) {
const nodeId = generateId(
'Destination',
destinationNodeKey(operation.broker, operation.address),
);
// Runs AFTER the source pass, so a site that resolved this address already
// owns the node and keeps its own `resolution` provenance. First writer
// wins and the order is fixed, so the property is deterministic rather than
// a race — and `literal` is the more informative of the two answers anyway.
if (ctx.graph.getNode(nodeId) === undefined) {
ctx.graph.addNode({
id: nodeId,
label: 'Destination',
properties: {
name: operation.address,
// Empty for the same reason every connecting destination carries it
// empty: the node is shared, and stamping it with the document's path
// would make it collateral damage of that path's next writeback.
filePath: '',
address: operation.address,
// NOT `'specification'`, though the address did come from one. That
// value belongs to `SpringDestinationVia` and means "a CODE
// CANDIDATE was resolved through the step-4 resolver hook" — a
// different fact with a code site behind it. Reusing it would make a
// query that groups destinations by provenance unable to separate an
// address a document merely states from one a document was used to
// resolve, and the second of those is a claim about source that this
// node is not making.
resolution: 'asyncapi-document',
broker: operation.broker,
},
});
destinations += 1;
}
const sourceId = documentFileNodeId(ctx, configuredRoot, operation.documentPath);
const type = operation.action === 'receive' ? 'CONSUMES_FROM' : 'PUBLISHES_TO';
const reason = `asyncapi:${operation.operationId}`;
ctx.graph.addRelationship({
id: generateId(type, `${sourceId}->${nodeId}:${reason}`),
sourceId,
targetId: nodeId,
type,
confidence: 1.0,
reason,
});
edges += 1;
}
const stats: SpecDocumentStats = {
symlinksSkipped: read.symlinksSkipped,
truncated: read.truncated,
scanned: read.documentsScanned,
accepted: read.documentsAccepted,
operations: read.operations.length,
destinations,
edges,
refusalsByReason: read.refusals as Readonly<Record<string, number>>,
};
// Unconditional, and not `isDev`-gated like the summary below it. The tally
// above is justified on the grounds that an operator must be able to tell a
// mistyped directory from a repository with no documents — and that
// justification is only true if the operator can SEE it. A configured path
// that produced nothing is the one outcome where silence and success look
// identical from outside, which is why `spring-auto-configuration.ts` warns
// unconditionally for the same class of input.
if (stats.accepted === 0 || stats.truncated) {
// Repo-relative when the path is inside the repository, bare name when it
// is not. The same change refuses to persist this path to index metadata on
// the grounds that it would record an operator's directory layout; applying
// that reasoning to metadata and not to logs would be holding one rule in
// two places.
const resolved = path.resolve(ctx.repoPath, specPath);
const relative = path.relative(ctx.repoPath, resolved);
const reportedPath =
relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative)
? relative.split(path.sep).join('/')
: path.basename(resolved);
logger.warn(
{
asyncApiSpecPath: reportedPath,
...stats,
},
stats.accepted === 0
? '⚠️ No AsyncAPI document under the configured path yielded a destination.'
: '⚠️ AsyncAPI document reading hit a bound; the destinations below are a floor, not a total.',
);
}
return stats;
}
export const springDestinationsPhase: PipelinePhase<SpringDestinationsOutput> = {
name: 'springDestinations',
// `parse` supplies the file list and the harvested constants; `scopeResolution`
@ -362,13 +592,28 @@ export const springDestinationsPhase: PipelinePhase<SpringDestinationsOutput> =
);
}
}
// Documents are read whether or not the source pass found anything, and
// that is the point of the closure rather than a straight call here: a
// repository whose messaging is invisible to the source patterns — a broker
// with no rules, a listener registered programmatically — is exactly the
// case a published document exists to cover, and an early return keyed on
// source sites would skip the documents precisely there.
//
// Always invoked AFTER the source emit, so a site that resolved an address
// owns its node first and keeps its own provenance.
const specPath = ctx.options?.asyncApiSpecPath;
const readSpecifications = async (): Promise<SpecDocumentStats | undefined> =>
specPath === undefined ? undefined : emitSpecDestinations(ctx, specPath);
if (sites.length === 0) {
const specDocuments = await readSpecifications();
return {
resolvedDestinations: 0,
unresolvedDestinations: 0,
edges: 0,
refusalsByReason,
configKeyLinks: 0,
...(specDocuments === undefined ? {} : { specDocuments }),
};
}
@ -548,9 +793,20 @@ export const springDestinationsPhase: PipelinePhase<SpringDestinationsOutput> =
edges += 1;
}
const specDocuments = await readSpecifications();
if (isDev) {
const fromSpec =
specDocuments === undefined
? ''
: `, +${specDocuments.destinations} from ${specDocuments.accepted} document(s)`;
// The breakdown, not just the totals. The unresolved FRACTION is the
// number this feature is judged on, and a bare count of unresolved
// destinations says how big the gap is without saying what would close
// it — which is the only question an operator can act on.
logger.info(
`📮 Spring destinations: ${resolvedDestinations} resolved, ${unresolvedDestinations} unresolved, ${edges} edges`,
{ refusalsByReason, ...(specDocuments === undefined ? {} : { specDocuments }) },
`📮 Spring destinations: ${resolvedDestinations} resolved, ${unresolvedDestinations} unresolved, ${edges} edges${fromSpec}`,
);
}
@ -560,6 +816,7 @@ export const springDestinationsPhase: PipelinePhase<SpringDestinationsOutput> =
edges,
refusalsByReason,
configKeyLinks,
...(specDocuments === undefined ? {} : { specDocuments }),
};
},
};

View file

@ -74,6 +74,20 @@ export interface PipelineOptions {
springActuatorPath?: string;
/** Repo-relative Actuator inputs retained only for a cleanup scan. */
springActuatorScanExclusions?: readonly string[];
/**
* Explicit local AsyncAPI 3.x document input, read by the `springDestinations`
* phase. Accepts a directory of documents or a single document; the path is
* resolved against the repository root, so a committed `docs/asyncapi` and an
* absolute cache populated out of band are equally natural. Undefined keeps
* specification reading completely disabled.
*
* There is deliberately no glob-based auto-discovery to go with it. Scanning
* a repository for anything that parses as a document would make every
* existing index grow destination nodes on its next run without an operator
* having decided anything — the same reason new contract extractors ship
* opt-in rather than on.
*/
asyncApiSpecPath?: string;
/** Per-advice Spring AOP candidate inspection cap. `0` disables this cap. */
springAopMaxCandidateInspectionsPerAdvice?: number;
/** Aggregate Spring AOP candidate inspection cap for one analysis. `0` disables this cap. */

View file

@ -322,6 +322,11 @@ function buildReference(site: ReferenceSite, top: Resolution): Reference {
toDef: top.def.nodeId,
atRange: site.atRange,
kind: site.kind,
// The call form survives resolution so the graph bridge can mark
// construction sites (`callForm: 'constructor'`) on the CALLS edge it
// emits — a `Reference` otherwise keeps only the resolved def.
...(site.kind === 'call' && site.callForm !== undefined ? { callForm: site.callForm } : {}),
...(site.kind === 'call' && site.staticGated === true ? { staticGated: true } : {}),
confidence: top.confidence,
evidence: top.evidence,
};

View file

@ -9,6 +9,7 @@ import type Parser from 'tree-sitter';
import type { ExtractedDecoratorRoute } from '../workers/parse-worker.js';
import {
intersectSpringHttpMethods,
isClassLevelMappingAnnotation,
springAnnotationHttpMethods,
unquoteSpringLiteral,
} from './spring-shared.js';
@ -140,19 +141,6 @@ function functionName(node: Parser.SyntaxNode): string | null {
return identifier ? unquoteKotlinIdentifier(identifier.text) : null;
}
/**
* `springAnnotationHttpMethods` parses Java `{A, B}` collections.
* Translate only Kotlin `method = [A, B]` before delegating.
*/
function kotlinSpringHttpMethods(name: string, annotation: Parser.SyntaxNode): readonly string[] {
if (name !== 'RequestMapping') return springAnnotationHttpMethods(name, annotation.text);
const normalized = annotation.text.replace(
/(\bmethod\s*=\s*)\[([^\]]*)\]/gs,
(_match, assignment: string, values: string) => `${assignment}{${values}}`,
);
return springAnnotationHttpMethods(name, normalized);
}
function typeName(node: Parser.SyntaxNode): string | null {
const identifier = node.children.find((child) => child.type === 'type_identifier');
return identifier ? unquoteKotlinIdentifier(identifier.text) : null;
@ -217,8 +205,8 @@ interface ClassMapping {
* mappings, and dynamic expressions fail closed for the whole class.
*/
function classMapping(annotations: readonly Parser.SyntaxNode[]): ClassMapping | null {
const mappings = annotations.filter(
(annotation) => annotationName(annotation) === 'RequestMapping',
const mappings = annotations.filter((annotation) =>
isClassLevelMappingAnnotation(annotationName(annotation) ?? ''),
);
if (mappings.length === 0) return { prefix: '', methods: ['*'] };
if (mappings.length !== 1) return null;
@ -241,7 +229,9 @@ function classMapping(annotations: readonly Parser.SyntaxNode[]): ClassMapping |
}
}
const methods = kotlinSpringHttpMethods('RequestMapping', mapping);
const mappingName = annotationName(mapping);
if (!mappingName) return null;
const methods = springAnnotationHttpMethods(mappingName, mapping.text);
return methods.length === 0 ? null : { prefix, methods };
}
@ -274,7 +264,7 @@ export function extractKotlinSpringRoutes(
const decoratorName = annotationName(annotation);
if (!decoratorName) continue;
const methodMethods = kotlinSpringHttpMethods(decoratorName, annotation);
const methodMethods = springAnnotationHttpMethods(decoratorName, annotation.text);
const methods = intersectSpringHttpMethods(ownerMapping.methods, methodMethods);
if (methods.length === 0) continue;

View file

@ -18,13 +18,14 @@
import type Parser from 'tree-sitter';
import { parseSpringAnnotationArguments } from '../frameworks/spring/annotation-arguments.js';
import { springVendorPrefixes } from '../frameworks/spring/vendor-prefixes.js';
/**
* Spring shortcut method-annotation → HTTP verb.
*
* `@RequestMapping` is intentionally absent: on a method it carries no implicit
* verb (the verb lives in its `method = RequestMethod.X` attribute), and on a
* class it is a URL prefix rather than a route. Callers handle `@RequestMapping`
* class it is a URL prefix rather than a route. Callers handle `RequestMapping`
* separately.
*/
export const METHOD_ANNOTATION_TO_HTTP: Record<string, string> = {
@ -36,7 +37,46 @@ export const METHOD_ANNOTATION_TO_HTTP: Record<string, string> = {
};
/**
* Parse one `RequestMethod.X` literal or a Java annotation array of literals.
* All recognised Spring mapping-annotation simple names (shortcut + base).
* Sorted longest-first so {@link resolveSpringAnnotationAlias} prefers the most
* specific suffix (e.g. `PostMapping` before any hypothetical shorter overlap).
*/
const SPRING_MAPPING_NAMES: readonly string[] = [
...Object.keys(METHOD_ANNOTATION_TO_HTTP),
'RequestMapping',
].sort((a, b) => b.length - a.length);
/**
* Resolve a REGISTERED vendor-derived Spring mapping annotation to its base.
*
* Vendor definitions often live in binary dependencies, so their Spring
* meta-annotations cannot be inspected from repository source. Resolution uses
* the conventional `<vendorPrefix><baseAnnotation>` name instead.
*
* Suffix matching alone accepted unrelated annotations (`@AuditPostMapping`
* produced a phantom route — review P2). Resolution now requires the name to
* be `<registeredPrefix><base>` with the prefix drawn from a small registry:
* `Win` by default (Winning Health), extendable via
* `GITNEXUS_SPRING_VENDOR_PREFIXES=Win,Acme,Other`. Changing the registry
* invalidates persisted JVM route evidence on the next analysis. Exact-known
* Spring annotation names return `undefined`; callers handle those directly.
*/
export function resolveSpringAnnotationAlias(annotationName: string): string | undefined {
const registeredVendorPrefixes = springVendorPrefixes();
for (const base of SPRING_MAPPING_NAMES) {
if (annotationName.length > base.length && annotationName.endsWith(base)) {
const prefix = annotationName.slice(0, annotationName.length - base.length);
if (registeredVendorPrefixes.has(prefix)) {
return base;
}
}
}
return undefined;
}
/**
* Parse one `RequestMethod.X` literal or a Java `{…}` / Kotlin `[…]` array of
* those literals.
* An empty array is valid and means Spring's unrestricted/default method set.
* Runtime expressions fail closed instead of producing a guessed route.
*/
@ -60,16 +100,25 @@ function parseRequestMethodValues(value: string): readonly string[] | null {
}
trimmed += char;
}
const hasOpeningBrace = trimmed.startsWith('{');
const hasClosingBrace = trimmed.endsWith('}');
if (hasOpeningBrace !== hasClosingBrace) return null;
const body = hasOpeningBrace ? trimmed.slice(1, -1).trim() : trimmed;
const wrapped =
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
(trimmed.startsWith('[') && trimmed.endsWith(']'));
if (
!wrapped &&
(trimmed.startsWith('{') ||
trimmed.startsWith('[') ||
trimmed.endsWith('}') ||
trimmed.endsWith(']'))
) {
return null;
}
const body = wrapped ? trimmed.slice(1, -1).trim() : trimmed;
if (body.length === 0) return [];
if (!hasOpeningBrace && body.includes(',')) return null;
if (!wrapped && body.includes(',')) return null;
const methods: string[] = [];
const parts = body.split(',');
if (hasOpeningBrace && parts[parts.length - 1].trim() === '') parts.pop();
if (wrapped && parts[parts.length - 1].trim() === '') parts.pop();
for (const rawPart of parts) {
const part = rawPart.trim();
const match =
@ -89,14 +138,28 @@ function parseRequestMethodValues(value: string): readonly string[] | null {
* one or more static `RequestMethod.X` values; when its `method` member is
* absent or an empty array, `'*'` preserves Spring's method-agnostic semantics.
* A present but non-static method expression yields no methods (fail closed).
*
* Vendor-derived aliases (e.g. `@WinPostMapping`) are resolved by suffix to
* their base annotation before the above logic applies — see
* {@link resolveSpringAnnotationAlias}.
*/
export function springAnnotationHttpMethods(
annotationName: string,
annotationText: string,
): readonly string[] {
// Exact shortcut match (PostMapping → POST, etc.)
const shortcut = METHOD_ANNOTATION_TO_HTTP[annotationName];
if (shortcut) return [shortcut];
if (annotationName !== 'RequestMapping') return [];
// Resolve vendor alias by suffix (WinPostMapping → PostMapping, etc.)
const base = resolveSpringAnnotationAlias(annotationName) ?? annotationName;
// Alias of a shortcut annotation
const aliasShortcut = METHOD_ANNOTATION_TO_HTTP[base];
if (aliasShortcut) return [aliasShortcut];
// Direct or aliased @RequestMapping: parse the method= attribute
if (base !== 'RequestMapping') return [];
const args = parseSpringAnnotationArguments(annotationText);
if (args === null) return [];
@ -109,6 +172,19 @@ export function springAnnotationHttpMethods(
return methods.length > 0 ? methods : ['*'];
}
/**
* True when an annotation name is a class-level request-mapping annotation —
* either Spring's own `@RequestMapping` or a registered vendor alias that
* resolves to it (`@WinRequestMapping`). Class-level handling in both the
* group extractor and the ingestion route extractor routes through this
* predicate so vendor aliases get the same prefix/constraint semantics as
* the base annotation (review P1).
*/
export function isClassLevelMappingAnnotation(annotationName: string): boolean {
if (annotationName === 'RequestMapping') return true;
return resolveSpringAnnotationAlias(annotationName) === 'RequestMapping';
}
/** Intersect class- and method-level Spring mapping constraints. */
export function intersectSpringHttpMethods(
classMethods: readonly string[],

View file

@ -27,6 +27,7 @@ import {
springAnnotationHttpMethods,
isRouteMemberKey,
findEnclosingType,
isClassLevelMappingAnnotation,
unquoteSpringLiteral,
type SharedSpringType,
} from './spring-shared.js';
@ -192,7 +193,10 @@ export function extractSpringRoutes(
if (!annNode || !node || (!valueNode && !valueExprNode)) continue;
const capturedAnnotationName = annNode.text.split('.').pop() ?? annNode.text;
if (node.type === 'class_declaration' && capturedAnnotationName === 'RequestMapping') {
if (
node.type === 'class_declaration' &&
isClassLevelMappingAnnotation(capturedAnnotationName)
) {
if (!isRouteMemberKey(keyNode)) continue;
if (!valueNode) {
classesWithUnfoldablePrefix.add(node.id);
@ -437,12 +441,14 @@ function annotationHasRouteMember(ann: Parser.SyntaxNode): boolean {
/** Static class/interface-level RequestMapping method constraint, or wildcard by default. */
function typeRequestMethods(typeNode: Parser.SyntaxNode): readonly string[] {
const mappings = declarationAnnotations(typeNode).filter(
(ann) => annotationName(ann) === 'RequestMapping',
const mappings = declarationAnnotations(typeNode).filter((ann) =>
isClassLevelMappingAnnotation(annotationName(ann) ?? ''),
);
if (mappings.length === 0) return ['*'];
if (mappings.length !== 1) return [];
return springAnnotationHttpMethods('RequestMapping', mappings[0].text);
const mappingName = annotationName(mappings[0]);
if (!mappingName) return [];
return springAnnotationHttpMethods(mappingName, mappings[0].text);
}
function annotationRoutePathsOrDefault(ann: Parser.SyntaxNode): string[] {
@ -455,7 +461,8 @@ function annotationRoutePathsOrDefault(ann: Parser.SyntaxNode): string[] {
function typeClassPrefixes(typeNode: Parser.SyntaxNode): string[] {
const prefixes: string[] = [];
for (const ann of declarationAnnotations(typeNode)) {
if (annotationName(ann) === 'RequestMapping') prefixes.push(...annotationRoutePaths(ann));
if (isClassLevelMappingAnnotation(annotationName(ann) ?? ''))
prefixes.push(...annotationRoutePaths(ann));
}
return prefixes;
}

View file

@ -1308,6 +1308,11 @@ function pass5CollectReferences(
// detection knows what to do with that. Absent for every language without
// pointer embedding, so their sites stay byte-identical.
const embeddedAsPointer = match['@reference.embedded-pointer'] !== undefined;
// Static-gating marker: the call sits in a branch the language layer proved
// dead at index time (Zig `if (CONST_FALSE)`). Recorded on the site and
// copied to the CALLS edge; absent everywhere else (see
// `ReferenceSite.staticGated`).
const staticGated = kind === 'call' && match['@reference.static-gated'] !== undefined;
const site: ReferenceSite = {
name: nameCap.text,
@ -1329,6 +1334,7 @@ function pass5CollectReferences(
...(receiverChain !== undefined ? { receiverChain } : {}),
...(inCalleePosition ? { inCalleePosition: true } : {}),
...(embeddedAsPointer ? { embeddedAsPointer: true } : {}),
...(staticGated ? { staticGated: true } : {}),
};
referenceSites.push(site);
}
@ -1794,6 +1800,7 @@ const KNOWN_SUB_TAGS: ReadonlySet<string> = new Set<string>([
'@reference.property-key',
'@reference.callee-position',
'@reference.embedded-pointer',
'@reference.static-gated',
'@reference.receiver',
'@reference.operator',
'@reference.arity',

View file

@ -878,6 +878,71 @@ export interface ScopeResolver {
*/
readonly constructorCallTargetsClass?: boolean;
/**
* When true, the CALLS edge emitted for a constructor-form site
* (`callForm === 'constructor'`) carries ` (constructor)` appended to its
* `reason` — `local-call (constructor)`, `import-resolved (constructor)`,
* `scope-resolution: call (constructor)` — so a consumer can tell
* "constructs an instance of" apart from "invokes" on the edge alone.
*
* Opt-in because the unsuffixed strings are a pinned contract: the legacy
* DAG vocabulary (`'import-resolved' | 'local-call' | …`, see the
* same-graph guarantee above) is asserted verbatim by consumers and by the
* per-language resolver suites, constructor sites included. A language
* that links a construction site to the TYPE node itself — a struct
* literal `T{…}` in Zig, where nothing but the marker distinguishes the
* edge from an invocation in the schema (PR #1432 review) — opts in; the
* default leaves every existing edge byte-identical.
*
* The marker rides in `reason` because relationships carry no arbitrary
* properties (adding one moves SCHEMA_FINGERPRINT — the IMPLEMENTS
* `-pointer` precedent in `pipeline/run.ts`). Applies to the free-call
* fallback, the reference bridge and the receiver-bound paths — a
* namespace-qualified literal (`mod.T{…}`, Case 1), a type nested in the
* receiver's class (`A.Item{}`, Case 2) and a dotted type binding (Case 3)
* all go through `constructionSiteReason` — so an opted-in provider sees
* one vocabulary whichever path resolved the site.
*/
readonly markConstructionSites?: boolean;
/**
* When true, a namespace's exported member may also be a name the target
* module IMPORTED and publishes as its own — the hub-module shape, a file
* made only of re-exports (`pub const Terminal = @import("Terminal.zig");`,
* `pub const Thing = @import("thing.zig").Thing;`). Such a file owns no
* local binding, so the default local-only export lookup
* (`findExportedDef`) finds nothing for `terminal.Terminal.init()`,
* `t: stdx.Thing`, `var p = stdx.PRNG.from_seed()` or `var a:
* stdx.BoundedArrayType(u8, 4)`, and the receiver-bound namespace paths
* (Case 1, Case 3, the compound resolver's namespace branch) fall through.
* With the flag those paths use `findExportedDefIncludingImportedNames`,
* which reads the finalized channel where the published names live.
*
* Off by default: in most languages a module's imports are not its exports
* (TypeScript `import { X }` publishes nothing), and the finalized edge does
* not record whether the import was written `pub`. Zig opts in — a hub
* member a consumer can name through the hub is public by construction.
*/
readonly namespaceExportsIncludeImportedNames?: boolean;
/**
* When true, a qualified receiver is walked SEGMENT BY SEGMENT from its
* verified namespace root instead of being split once at the last dot:
* `hub.sub.Thing{}` (a namespace republished by a hub — `pub const sub =
* @import("sub.zig");`), `mod.Outer.Inner{}` (a type nested in a type),
* `opmod.Op.lookup` (an enum variant reached through the module), and the
* typed forms `x: mod.Outer.Inner`. Each hop is either a class-like member
* of the current module(s) / the current class, or a namespace import
* edge the current module's scope binds under that name; a hop that is
* ambiguous — two files behind one handle disagree, or a name is both a
* type and a republished module — resolves nothing rather than picking a
* first match. Off, the receiver-bound paths (Case 1, Case 2's
* namespace-qualified class, Case 3) keep their one-hop lookups exactly
* as they are, so no existing edge moves; Zig opts in (PR #1432 review,
* 8.10), whose module system is nothing but nested `const` handles.
*/
readonly resolveNamespaceChains?: boolean;
/**
* How this language spells a construction expression, so the compound
* receiver resolver can type an INLINE constructor receiver — the

View file

@ -126,6 +126,8 @@ export function tryEmitEdge(
/** See {@link isPhantomCalleeRead}. Set by the extractor from the
* language's `@reference.callee-position` marker; absent otherwise. */
readonly inCalleePosition?: boolean;
/** See `ReferenceSite.staticGated`; copied onto the emitted edge. */
readonly staticGated?: boolean;
},
targetDef: SymbolDefinition,
reason: string,
@ -179,6 +181,7 @@ export function tryEmitEdge(
type: edgeType,
confidence,
reason,
...(site.staticGated === true ? { staticGated: true } : {}),
});
return true;
}
@ -213,6 +216,8 @@ export function tryEmitEdgeWithExplicitTargetId(
readonly inScope: ScopeId;
readonly atRange: { startLine: number; startCol: number };
readonly kind: string;
/** See `ReferenceSite.staticGated`; copied onto the emitted edge. */
readonly staticGated?: boolean;
},
targetGraphId: string,
reason: string,
@ -251,6 +256,7 @@ export function tryEmitEdgeWithExplicitTargetId(
type: edgeType,
confidence,
reason,
...(site.staticGated === true ? { staticGated: true } : {}),
});
return true;
}

View file

@ -288,6 +288,15 @@ export const LINKABLE_LABELS: ReadonlySet<NodeLabel> = new Set<NodeLabel>([
// are unreachable and label-agnostic fallback can alias it to a same-named
// Constructor or Method (#2801).
'Record',
// Union is linkable because Zig wires `union` / `union(enum)` as a member
// container: the definition phase emits HAS_METHOD / HAS_PROPERTY edges FROM
// the Union node (`union_declaration` in MEMBER_OWNER_NODE_TYPES) and the
// scope side dispatches methods on union receivers (`main → isEnergy` in
// test/integration/resolvers/zig.test.ts). Without this entry the schema
// never declares a `FROM Union` pair and `analyze` aborts on the first Zig
// repo that declares a union (reproduced on the zig-basic fixture itself).
// Also lets `Tag{ .energy = 5 }` constructor references bridge to the node.
'Union',
// Trait nodes are linkable so MRO builders can bridge PHP/Rust trait
// defs between scope-resolution DefIds and the graph's node ids.
// IMPLEMENTS edges from classes to traits are otherwise invisible to
@ -303,8 +312,8 @@ export const LINKABLE_LABELS: ReadonlySet<NodeLabel> = new Set<NodeLabel>([
//
// Covers every language that spells an alias this way — TypeScript, Kotlin,
// Dart and Rust all emit `@declaration.type_alias`. The remaining
// `CLASS_KINDS` entries (Typedef, Record, Union, Delegate, Annotation,
// Template) plausibly have the same gap, but nothing exercises them today
// `CLASS_KINDS` entries (Typedef, Delegate, Annotation, Template, Namespace)
// plausibly have the same gap, but nothing exercises them today
// and adding labels no test covers is how this list drifts out of sync with
// what it claims.
'TypeAlias',

View file

@ -6,7 +6,7 @@
* chain looking for an enclosing Function/Method/Class.
* 2. Resolve `toDef` → target graph-node id via `nodeLookup`.
* 3. Emit the edge (`CALLS` / `READS` / `WRITES` / `EXTENDS` / `USES`)
* with the standard reason format.
* with the standard reason format (`referenceEdgeReason`).
*
* Skips (without throwing) when either side fails to map — either side
* may legitimately not exist as a graph node (e.g. a resolved target
@ -36,6 +36,36 @@ import { isValueDefinitionLabel } from '../../utils/ast-helpers.js';
*/
type ReferenceSiteSkipSet = ReadonlySet<string>;
export interface EmitReferencesOptions {
/** When true, a constructor-form call site's edge gets ` (constructor)`
* appended to its reason. See `ScopeResolver.markConstructionSites`. */
readonly markConstructionSites?: boolean;
}
/**
* `reason` of the edge a resolved reference emits: `scope-resolution: <kind>`,
* plus ` (constructor)` for a construction site when the provider opted in —
* a struct literal (`T{…}` in Zig, `T { .. }` in Rust, `T{}` in Go) or a
* `new T()` resolves to the type (or its constructor) as a CALLS edge exactly
* like an invocation does, and nothing else on the edge tells the two apart
* (PR #1432 review).
*
* The marker rides in `reason` because relationships carry no arbitrary
* properties — adding one would change the relation DDL and move
* SCHEMA_FINGERPRINT (see the IMPLEMENTS `-pointer` precedent in
* `pipeline/run.ts`). The plain `scope-resolution: call` prefix is kept, so a
* consumer matching the prefix still sees every call; one matching the exact
* string sees invocations only.
*/
export function referenceEdgeReason(
ref: Pick<Reference, 'kind' | 'callForm'>,
markConstructionSites: boolean | undefined,
): string {
return markConstructionSites === true && ref.kind === 'call' && ref.callForm === 'constructor'
? 'scope-resolution: call (constructor)'
: `scope-resolution: ${ref.kind}`;
}
/**
* Value labels whose defs MAY be function-local. A reference to one of these is
* dropped only when the def is positively identified as living inside a function
@ -76,6 +106,7 @@ export function emitReferencesViaLookup(
* Optional so callers that never capture bare identifiers are unchanged.
*/
functionLocalValueDefIds?: ReadonlySet<string>,
options?: EmitReferencesOptions,
): { emitted: number; skipped: number } {
let emitted = 0;
let skipped = 0;
@ -153,7 +184,8 @@ export function emitReferencesViaLookup(
targetId: targetGraphId,
type: edgeType,
confidence: ref.confidence,
reason: `scope-resolution: ${ref.kind}`,
reason: referenceEdgeReason(ref, options?.markConstructionSites),
...(ref.staticGated === true ? { staticGated: true } : {}),
});
emitted++;
}

View file

@ -38,6 +38,7 @@ import {
findEnclosingClassDef,
findExportedDef,
findExportedDefByName,
findExportedDefIncludingImportedNames,
findReceiverTypeBinding,
isClassLike,
isNamespaceNameShadowed,
@ -128,6 +129,20 @@ interface ResolveCompoundReceiverOptions {
readonly constructionSyntax?: ScopeResolver['constructionSyntax'];
/** Verified namespace handles visible in the current file. */
readonly namespaceTargets?: ReadonlyMap<string, readonly string[]>;
/** A namespace member may be a name the target module imported and
* publishes (hub modules). See `ScopeResolver.namespaceExportsIncludeImportedNames`. */
readonly namespaceExportsIncludeImportedNames?: boolean;
/** Resolve a qualified CLASS name (`opmod.Op`, `hub.sub.Thing`,
* `mod.Outer.Inner`) through the language's namespace chain walk
* (`ScopeResolver.resolveNamespaceChains`). Seeds the dotted-chain walk
* when its head is a namespace rather than a value: `opmod.Op.lookup` is
* the enum `Op` reached through the module `opmod`, then its variant
* `lookup` — a value of `Op` — and only then a method (PR #1432 review,
* 8.10). Absent ⇒ the head must bind in scope, exactly as before. */
readonly resolveQualifiedClass?: (
qualifiedName: string,
inScope: ScopeId,
) => SymbolDefinition | undefined;
/** Compact receiver chain for THIS site (`ReferenceSite.receiverChain`), when
* the language's capture emitter produced one. Present ⇒ the structural fold
* is tried before the text cascade; absent ⇒ behaviour is exactly as before.
@ -241,7 +256,11 @@ function resolveConstructionExpressionClass(
if (namespaceFiles.length > 0) {
if (isNamespaceNameShadowed(namespaceName, inScope, scopes)) return undefined;
const namespaceMatches = namespaceFiles
.map((targetFile) => findExportedDef(targetFile, exportedName, index))
.map((targetFile) =>
options.namespaceExportsIncludeImportedNames === true
? findExportedDefIncludingImportedNames(targetFile, exportedName, index, scopes)
: findExportedDef(targetFile, exportedName, index),
)
.filter((def): def is SymbolDefinition => def !== undefined && isClassLike(def.type));
return namespaceMatches.length === 1 ? namespaceMatches[0] : undefined;
}
@ -1179,8 +1198,30 @@ export function resolveCompoundReceiverClass(
options,
);
}
// Namespace-qualified chain head — `opmod.Op.lookup` / `hub.sub.Thing.x`:
// no binding and no class named `opmod`, but the LONGEST prefix the
// language's chain walk accepts as a class seeds the walk (the class
// itself, so a variant / static member hop is read off the class scope),
// and the remaining segments are walked as members. Longest first: the
// prefix is a class, not a value, and `a.B.C` must seed at `C`, not stop
// at `B` and read `C` as a member of it. The whole receiver may be the
// class (`opmod.Op` — no member segment left), exactly as a bare class
// name head resolves to the class constant above.
let firstHop = 1;
if (currentClass === undefined && headType === undefined && options.resolveQualifiedClass) {
for (let k = parts.length; k >= 2; k--) {
const prefix = parts.slice(0, k).join('.');
if (prefix.includes('(')) continue;
const seeded = options.resolveQualifiedClass(prefix, inScope);
if (seeded === undefined) continue;
currentClass = seeded;
currentIsClassConstant = true;
firstHop = k;
break;
}
}
for (let i = 1; i < parts.length && currentClass !== undefined; i++) {
for (let i = firstHop; i < parts.length && currentClass !== undefined; i++) {
const segment = parts[i];
if (segment === undefined) break;
const memberName = stripCallParens(segment);

View file

@ -22,6 +22,7 @@ import type {
ParameterTypeClass,
ParsedFile,
Reference,
ReferenceSite,
ScopeId,
SymbolDefinition,
} from 'gitnexus-shared';
@ -66,6 +67,9 @@ export function emitFreeCallFallback(
/** When true, `Type(...)` constructor calls link to the Class def
* itself rather than its explicit Constructor. Swift opts in. */
readonly constructorCallTargetsClass?: boolean;
/** When true, a constructor-form site's edge gets ` (constructor)`
* appended to its reason. See `ScopeResolver.markConstructionSites`. */
readonly markConstructionSites?: boolean;
readonly isFileLocalDef?: (def: SymbolDefinition) => boolean;
readonly isCallableVisibleFromCaller?: (ctx: {
readonly callerParsed: ParsedFile;
@ -179,6 +183,8 @@ export function emitFreeCallFallback(
};
for (const parsed of parsedFiles) {
type PendingRel = { rel: Parameters<KnowledgeGraph['addRelationship']>[0]; gatedAll: boolean };
const pending = new Map<string, PendingRel>();
const bindingCandidatesByScope =
options.freeCallsRequireInstanceOwnership === true
? new Map<ScopeId, Map<string, readonly CallableBindingCandidate[]>>()
@ -612,24 +618,57 @@ export function emitFreeCallFallback(
tgtGraphId,
);
const relId = `rel:CALLS:${callerGraphId}->${tgtGraphId}`;
// One edge per (caller, callee): `staticGated` is the AND over every site
// that collapses into it, so a callee reached from one live site and one
// dead site stays live whichever site the walk meets first. Emission is
// deferred to the end of this file's sites for that reason.
const pendingRel = pending.get(relId);
if (pendingRel !== undefined) {
if (site.staticGated !== true) pendingRel.gatedAll = false;
continue;
}
if (seen.has(relId)) continue;
seen.add(relId);
graph.addRelationship({
id: relId,
sourceId: callerGraphId,
targetId: tgtGraphId,
type: 'CALLS',
confidence: 0.85,
// Match legacy DAG's reason convention so consumers that
// assert `reason === 'import-resolved'` keep working.
reason: fnDef.filePath !== parsed.filePath ? 'import-resolved' : 'local-call',
pending.set(relId, {
gatedAll: site.staticGated === true,
rel: {
id: relId,
sourceId: callerGraphId,
targetId: tgtGraphId,
type: 'CALLS',
confidence: 0.85,
// Match legacy DAG's reason convention so consumers that
// assert `reason === 'import-resolved'` keep working. The
// construction-site marker is opt-in for the same reason.
reason: constructionSiteReason(
fnDef.filePath !== parsed.filePath ? 'import-resolved' : 'local-call',
site,
options.markConstructionSites,
),
},
});
emitted++;
}
for (const { rel, gatedAll } of pending.values()) {
graph.addRelationship(gatedAll ? { ...rel, staticGated: true } : rel);
}
}
return emitted;
}
/** `reason` of a free-call edge: the legacy string, plus ` (constructor)` for
* a construction site when the provider opted in
* (`ScopeResolver.markConstructionSites`). */
export function constructionSiteReason(
base: string,
site: Pick<ReferenceSite, 'callForm'>,
markConstructionSites: boolean | undefined,
): string {
return markConstructionSites === true && site.callForm === 'constructor'
? `${base} (constructor)`
: base;
}
function siteKey(
filePath: string,
site: { readonly atRange: { readonly startLine: number; readonly startCol: number } },

View file

@ -59,7 +59,7 @@
* resolved to a wrong target.
*/
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import type { KnowledgeGraph } from '../../../graph/types.js';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import type { SemanticModel } from '../../model/semantic-model.js';
@ -73,6 +73,7 @@ import {
findEnclosingClassDef,
isReceiverOwnedButUnbound,
findExportedDef,
findExportedDefIncludingImportedNames,
findOwnedMember,
findReceiverTypeBinding,
findValueBindingInScope,
@ -86,6 +87,7 @@ import {
tryEmitEdgeWithExplicitTargetId,
type CalleeIdCaptureCtx,
} from '../graph-bridge/edges.js';
import { constructionSiteReason } from './free-call-fallback.js';
import type { CalleeIdSink } from '../graph-bridge/callee-id-sink.js';
import {
resolveCompoundReceiverClass,
@ -115,6 +117,53 @@ import type { DecodedReceiverChain } from '../../utils/receiver-chain-codec.js';
/** Subset of `ScopeResolver` consumed by this pass. Accepting the
* subset rather than the full provider keeps tests and partial
* refactors lighter — callers only need to populate what we read. */
/** Split `text` at the dots that sit at nesting depth 0 and outside string
* literals — `@import("a.zig").Outer.Inner` → three segments, not four;
* `List(u8).Node` → two. The chain walk's segmenter. */
function splitTopLevelDots(text: string): string[] {
const out: string[] = [];
let depth = 0;
let inString = false;
let start = 0;
for (let i = 0; i < text.length; i++) {
const ch = text[i]!;
if (inString) {
if (ch === '\\') i++;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') inString = true;
else if (ch === '(' || ch === '[' || ch === '<') depth++;
else if (ch === ')' || ch === ']' || ch === '>') depth--;
else if (ch === '.' && depth === 0) {
out.push(text.slice(start, i));
start = i + 1;
}
}
out.push(text.slice(start));
return out.filter((s) => s.length > 0);
}
/** Index of the last depth-0, outside-string dot of `text`, or -1. */
function lastTopLevelDot(text: string): number {
let depth = 0;
let inString = false;
let last = -1;
for (let i = 0; i < text.length; i++) {
const ch = text[i]!;
if (inString) {
if (ch === '\\') i++;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') inString = true;
else if (ch === '(' || ch === '[' || ch === '<') depth++;
else if (ch === ')' || ch === ']' || ch === '>') depth--;
else if (ch === '.' && depth === 0) last = i;
}
return last;
}
type ReceiverBoundProviderSubset = Pick<
ScopeResolver,
| 'isSuperReceiver'
@ -135,6 +184,9 @@ type ReceiverBoundProviderSubset = Pick<
| 'constraintCompatibility'
| 'isStaticOnly'
| 'normalizeTypeArgument'
| 'markConstructionSites'
| 'namespaceExportsIncludeImportedNames'
| 'resolveNamespaceChains'
>;
/** A bare, undecorated identifier and nothing else — see {@link isBareTypeName}. */
@ -329,9 +381,145 @@ export function emitReceiverBoundCalls(
const fieldFallback = provider.fieldFallbackOnMethodLookup ?? true;
const collapse = provider.collapseMemberCallsByCallerTarget === true;
const hoistTypeBindingsToModule = provider.hoistTypeBindingsToModule === true;
// Namespace-member lookup for Case 1 / Case 3: local exports only, unless
// the provider publishes imported names too (hub modules — see
// `ScopeResolver.namespaceExportsIncludeImportedNames`).
const lookupNamespaceMember = (targetFile: string, name: string): SymbolDefinition | undefined =>
provider.namespaceExportsIncludeImportedNames === true
? findExportedDefIncludingImportedNames(targetFile, name, index, scopes)
: findExportedDef(targetFile, name, index);
// A class-like member `name` unique across `files`, or nothing — two
// same-named classes behind one handle would mint a confident wrong edge.
const uniqueClassAcross = (
files: readonly string[],
name: string,
): SymbolDefinition | undefined => {
let picked: SymbolDefinition | undefined;
for (const file of files) {
const def = lookupNamespaceMember(file, name);
if (def === undefined || !isClassLike(def.type)) continue;
if (picked !== undefined && picked.nodeId !== def.nodeId) return undefined;
picked = def;
}
return picked;
};
// A class-like def NESTED in `owner` (`A.Item` inside `A`): its qualified
// name is the owner's plus the segment — the identity the structure phase
// and `populateClassOwnedMembers` agree on — so the qualified-name index
// answers directly; same file as the owner, unique or nothing. Only the
// chain walk reads this: `findOwnedMember` knows methods and fields, and a
// nested type is neither.
const findNestedClass = (owner: SymbolDefinition, name: string): SymbolDefinition | undefined => {
if (owner.qualifiedName === undefined || owner.qualifiedName.length === 0) return undefined;
let picked: SymbolDefinition | undefined;
for (const id of scopes.qualifiedNames.get(`${owner.qualifiedName}.${name}`)) {
const def = scopes.defs.get(id);
if (def === undefined || !isClassLike(def.type) || def.filePath !== owner.filePath) continue;
if (picked !== undefined && picked.nodeId !== def.nodeId) return undefined;
picked = def;
}
return picked;
};
// Namespace CHAIN walk (`ScopeResolver.resolveNamespaceChains`): resolve
// every segment of a qualified prefix from its verified namespace root —
// or, failing a namespace, from a class binding in scope (`Outer.Inner`).
// The cursor is either "these module files" or "this class"; a hop from a
// module is a class-like member of it (→ class) or a namespace-import edge
// its module scope binds under the segment — a republished module,
// `pub const sub = @import("sub.zig");` (→ files); a hop from a class is a
// nested class-like. Anything ambiguous resolves nothing.
const walkChains = provider.resolveNamespaceChains === true;
const namespaceImportTargetsOf = (file: string, name: string): readonly string[] => {
const moduleScope = index.moduleScopeByFile.get(file);
if (moduleScope === undefined) return [];
const out: string[] = [];
for (const edge of scopes.imports.get(moduleScope.id) ?? []) {
if (edge.kind !== 'namespace' || edge.localName !== name || edge.targetFile === null)
continue;
if (!out.includes(edge.targetFile)) out.push(edge.targetFile);
}
return out;
};
type ChainCursor =
| { readonly files: readonly string[] }
| { readonly classDef: SymbolDefinition };
const resolveNamespaceChain = (
prefix: string,
inScope: ScopeId,
namespaceTargets: ReadonlyMap<string, readonly string[]>,
): ChainCursor | undefined => {
const segments = splitTopLevelDots(prefix);
if (segments.length === 0) return undefined;
let cursor: ChainCursor | undefined;
let rest: readonly string[] = [];
// The LONGEST namespace key wins: a provider may bind dotted handles
// (`namespaceReceiverPaths`) and an inline `@import("x.zig")` handle
// carries a dot of its own inside the quotes.
for (let k = segments.length; k >= 1; k--) {
const key = segments.slice(0, k).join('.');
const files = namespaceTargets.get(key);
if (files === undefined) continue;
if (isNamespaceNameShadowed(key, inScope, scopes)) return undefined;
cursor = { files };
rest = segments.slice(k);
break;
}
if (cursor === undefined) {
const head = findClassBindingInScope(inScope, segments[0]!, scopes);
if (head === undefined || !isClassLike(head.type)) return undefined;
cursor = { classDef: head };
rest = segments.slice(1);
}
for (const segment of rest) {
if (segment.includes('(') || segment.includes('[')) return undefined;
if ('files' in cursor) {
const asClass = uniqueClassAcross(cursor.files, segment);
const asModule: string[] = [];
for (const file of cursor.files) {
for (const target of namespaceImportTargetsOf(file, segment)) {
if (!asModule.includes(target)) asModule.push(target);
}
}
if (asClass !== undefined && asModule.length > 0) return undefined; // both — refuse
if (asClass !== undefined) cursor = { classDef: asClass };
else if (asModule.length > 0) cursor = { files: asModule };
else return undefined;
} else {
const nested = findNestedClass(cursor.classDef, segment);
if (nested === undefined) return undefined;
cursor = { classDef: nested };
}
}
return cursor;
};
// `ns.Type` as a receiver, where `ns` is a verified namespace of the current
// file and `Type` a class-like member of it — or, with the chain walk, any
// `a.b.c.Type` whose prefix resolves. Unique or nothing.
const resolveNamespaceQualifiedClass = (
receiverName: string,
inScope: ScopeId,
namespaceTargets: ReadonlyMap<string, readonly string[]>,
): SymbolDefinition | undefined => {
const dot = walkChains ? lastTopLevelDot(receiverName) : receiverName.lastIndexOf('.');
if (dot <= 0 || dot === receiverName.length - 1) return undefined;
const head = receiverName.slice(0, dot);
const tail = receiverName.slice(dot + 1);
if (tail.includes('(') || tail.includes('[')) return undefined;
if (walkChains) {
const cursor = resolveNamespaceChain(head, inScope, namespaceTargets);
if (cursor === undefined) return undefined;
return 'classDef' in cursor
? findNestedClass(cursor.classDef, tail)
: uniqueClassAcross(cursor.files, tail);
}
const files = namespaceTargets.get(head);
if (files === undefined || isNamespaceNameShadowed(head, inScope, scopes)) return undefined;
return uniqueClassAcross(files, tail);
};
const compoundOpts = {
fieldFallback,
elementTypeOf: provider.elementTypeOf,
namespaceExportsIncludeImportedNames: provider.namespaceExportsIncludeImportedNames === true,
hoistTypeBindingsToModule,
stripReceiverCastExpressions: provider.stripReceiverCastExpressions === true,
constructionSyntax: provider.constructionSyntax,
@ -778,7 +966,16 @@ export function emitReceiverBoundCalls(
receiverPaths: provider.namespaceReceiverPaths,
moduleFileExists: (filePath) => index.moduleScopeByFile.has(filePath),
});
const fileCompoundOpts = { ...compoundOpts, namespaceTargets };
const fileCompoundOpts = {
...compoundOpts,
namespaceTargets,
...(walkChains
? {
resolveQualifiedClass: (qualifiedName: string, inScope: ScopeId) =>
resolveNamespaceQualifiedClass(qualifiedName, inScope, namespaceTargets),
}
: {}),
};
// Per-file resolved-callee-id capture context (#2227 U2). Built once per
// file; `undefined` when the sink is absent (pdg off) so the `tryEmitEdge`
// capture is a no-op and emission stays byte-identical (R4).
@ -1265,15 +1462,22 @@ export function emitReceiverBoundCalls(
// that is usually empty. Mirrors the order the compound-receiver
// construction path already uses.
const namespaceCandidates = namespaceTargets.get(receiverName);
const targetFiles =
let targetFiles: readonly string[] | undefined =
namespaceCandidates !== undefined &&
!isNamespaceNameShadowed(receiverName, site.inScope, scopes)
? namespaceCandidates
: undefined;
// Chain walk: `hub.sub.helper()` / `hub.sub.Thing{}` — the receiver is
// no handle of this file, but its segments reach a module (see
// `resolveNamespaceChain`). A prefix that ends in a CLASS is Case 2's.
if (targetFiles === undefined && walkChains && lastTopLevelDot(receiverName) > 0) {
const cursor = resolveNamespaceChain(receiverName, site.inScope, namespaceTargets);
if (cursor !== undefined && 'files' in cursor) targetFiles = cursor.files;
}
if (targetFiles !== undefined && provider.resolveQualifiedReceiverMember === undefined) {
let found = false;
for (const targetFile of targetFiles) {
const memberDef = findExportedDef(targetFile, memberName, index);
const memberDef = lookupNamespaceMember(targetFile, memberName);
if (memberDef !== undefined) {
if (
suppressDeletedCallTarget(
@ -1293,7 +1497,15 @@ export function emitReceiverBoundCalls(
nodeLookup,
site,
memberDef,
memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global',
// A namespace-qualified construction site (`mod.T{…}`) resolves
// here like `mod.fn()` does; the provider's opt-in marker keeps
// it distinguishable from an invocation (see
// `ScopeResolver.markConstructionSites`).
constructionSiteReason(
memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global',
site,
provider.markConstructionSites,
),
seen,
0.85,
collapse,
@ -1369,7 +1581,16 @@ export function emitReceiverBoundCalls(
}
// ── Case 2: class-name receiver ──────────────────────────────
const classDef = findClassBindingInScope(site.inScope, receiverName, scopes);
// A namespace-qualified class (`stdx.PRNG.from_seed()`, `terminal
// .Terminal.init()`) binds nothing in the caller's scope chain; when the
// head names a verified namespace, the tail is looked up as that
// module's member — through the same lookup Case 1 / Case 3 use, so a
// hub module (a file made only of re-exports) answers when the provider
// opted in. Only a bare tail is walked here; `ns.Type.field.m()` is the
// compound resolver's shape.
const classDef =
findClassBindingInScope(site.inScope, receiverName, scopes) ??
resolveNamespaceQualifiedClass(receiverName, site.inScope, namespaceTargets);
if (classDef !== undefined) {
const chain = [classDef.nodeId, ...scopes.methodDispatch.mroFor(classDef.nodeId)];
let memberDef: SymbolDefinition | undefined;
@ -1413,6 +1634,45 @@ export function emitReceiverBoundCalls(
handledSites.add(siteKey);
continue;
}
// `A.Item{}` / `mod.Outer.Inner{}` — a construction whose member is a
// type NESTED in the class the receiver names. Neither a method nor a
// field, so the owner walk above cannot see it; the chain walk's
// nested-class lookup can (`resolveNamespaceChains`).
if (memberDef === undefined && walkChains && site.callForm === 'constructor') {
const nested = findNestedClass(classDef, memberName);
if (nested !== undefined) {
if (
suppressDeletedCallTarget(
options.recordResolutionOutcome,
parsed.filePath,
site,
nested,
)
) {
handledSites.add(siteKey);
continue;
}
const ok = tryEmitEdge(
graph,
scopes,
nodeLookup,
site,
nested,
constructionSiteReason(
nested.filePath !== parsed.filePath ? 'import-resolved' : 'global',
site,
provider.markConstructionSites,
),
seen,
0.85,
collapse,
calleeCapture,
);
if (ok) emitted++;
handledSites.add(siteKey);
continue;
}
}
if (memberDef !== undefined) {
if (
suppressDeletedCallTarget(
@ -1455,11 +1715,23 @@ export function emitReceiverBoundCalls(
if (typeRef !== undefined && typeRef.rawName.includes('.')) {
const [nsName, ...classNameParts] = typeRef.rawName.split('.');
const className = classNameParts.join('.');
const targetFiles3 = namespaceTargets.get(nsName);
// With the chain walk the dotted type is resolved as a whole
// (`x: mod.Outer.Inner`, `t: hub.sub.Thing`); the candidate list then
// has one entry or none. Without it: the historical one-hop split.
const chainDef3 = walkChains
? resolveNamespaceQualifiedClass(typeRef.rawName, site.inScope, namespaceTargets)
: undefined;
const targetFiles3 = walkChains
? chainDef3 === undefined
? undefined
: [chainDef3.filePath]
: namespaceTargets.get(nsName);
if (targetFiles3 !== undefined && className.length > 0) {
let found3 = false;
for (const targetFile3 of targetFiles3) {
const classDef3 = findExportedDef(targetFile3, className, index);
const classDef3 = walkChains
? chainDef3
: lookupNamespaceMember(targetFile3, className);
if (classDef3 !== undefined) {
const picked =
site.kind === 'call'
@ -1499,7 +1771,15 @@ export function emitReceiverBoundCalls(
nodeLookup,
site,
memberDef,
memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global',
// Same marker rule as Case 1 / Case 2: a constructor-form site
// reached through a dotted type binding keeps its
// ` (constructor)` suffix when the provider opted in; for
// every other provider the string is unchanged.
constructionSiteReason(
memberDef.filePath !== parsed.filePath ? 'import-resolved' : 'global',
site,
provider.markConstructionSites,
),
seen,
// Explicit defaults so the trailing capture ctx (#2227 U2) can
// be threaded without changing dedup/confidence behavior.

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