mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-13 03:58:33 +00:00
Merge branch 'main' into fix/lbug-checkpoint-race-and-engine-pin
This commit is contained in:
commit
ef070baf3b
252 changed files with 21700 additions and 1417 deletions
|
|
@ -83,15 +83,23 @@ Notes: `offset` ≥ `total` returns an empty page (with `total` still reported).
|
|||
|
||||
### Inline staleness signal (`query` / `context` / `impact` / `cypher`)
|
||||
|
||||
These four hot read tools attach a non-blocking `staleness` field to their response when the index is behind the checkout's current HEAD — the same `{ commitsBehind, hint }` shape `list_repos` already reports — so a direct tool call surfaces a behind-HEAD index without a separate `list_repos` call:
|
||||
These four hot read tools attach a non-blocking `staleness` field to their response when the index is not at the checkout's current HEAD — the same `{ status, commitsBehind?, hint? }` shape `list_repos` already reports — so a direct tool call surfaces a stale index without a separate `list_repos` call:
|
||||
|
||||
```jsonc
|
||||
{ /* …the tool's normal result… */
|
||||
"staleness": { "commitsBehind": 3, "hint": "⚠️ Index is 3 commits behind HEAD. Run analyze tool to update." }
|
||||
"staleness": { "status": "behind", "commitsBehind": 3, "hint": "⚠️ Index is 3 commits behind HEAD. Run analyze tool to update." }
|
||||
}
|
||||
```
|
||||
|
||||
The field is **absent when the index is current** (or when the freshness check can't run), so its presence is the signal. It is only ever added to object results — raw-array `cypher` output and error envelopes are returned unchanged. `@group`-targeted calls do not carry it (multi-repo staleness is ill-defined). When you see it, the graph may be behind the working tree — re-run `analyze` before trusting blast-radius or dependence answers.
|
||||
`commitsBehind` is present only when git counted the gap. When git could not count it but HEAD still resolves to a commit other than the indexed one — usually because the indexed commit is no longer in the clone's history — the index is provably not at HEAD with no countable gap, so no number is reported:
|
||||
|
||||
```jsonc
|
||||
{ /* …the tool's normal result… */
|
||||
"staleness": { "status": "diverged", "hint": "⚠️ Index is not at HEAD and the commit gap could not be counted — the recorded commit may no longer be in this clone's history. Run analyze tool to update." }
|
||||
}
|
||||
```
|
||||
|
||||
The field is **absent when the index is current**, and these four tools also omit it when the freshness check could not run at all — that case is `status: "unknown"`, which only the `list_repos` listing reports. So its presence means the status is not `current`: read `status` before using `commitsBehind`. It is only ever added to object results — raw-array `cypher` output and error envelopes are returned unchanged. `@group`-targeted calls do not carry it (multi-repo staleness is ill-defined). When you see it, the graph may be behind the working tree — re-run `analyze` before trusting blast-radius or dependence answers.
|
||||
|
||||
### Taint findings (`explain`)
|
||||
|
||||
|
|
|
|||
39
.github/workflows/ci-tests.yml
vendored
39
.github/workflows/ci-tests.yml
vendored
|
|
@ -599,6 +599,26 @@ jobs:
|
|||
run: node --import tsx bench/parse-dispatch-rounds/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Python workspace import-scan guards (#3254)
|
||||
if: ${{ !cancelled() }}
|
||||
# Build-free: same baseline approach as parse-dispatch-rounds —
|
||||
# exact link/lookalike floors plus a fingerprint, then ratio timing
|
||||
# only (scan scaling and from-token prefilter advantage). See
|
||||
# bench/python-workspace-import-scan/measure.mjs.
|
||||
run: node --import tsx bench/python-workspace-import-scan/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: MCP tools/list countRepos vs listRepos guards (#3259, #3184)
|
||||
if: ${{ !cancelled() }}
|
||||
# Build-free: exact registry cardinality + tool-roster + schema-flag
|
||||
# floors, then ratio timing only (countRepos/listRepos and
|
||||
# listTools/listRepos). No millisecond ceiling — this repo has
|
||||
# already been bitten by a fixed ms budget. Isolated GITNEXUS_HOME;
|
||||
# fixture is N real git repos so listRepos pays rev-list. See
|
||||
# bench/mcp-tools-list/measure.mjs.
|
||||
run: node --import tsx bench/mcp-tools-list/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: C++ qualified-namespace resolution guards (#2788)
|
||||
if: ${{ !cancelled() }}
|
||||
# Build-free: asserts resolveCppQualifiedNamespaceMember resolves an
|
||||
|
|
@ -718,6 +738,13 @@ jobs:
|
|||
run: node --import tsx bench/kotlin-import-target/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Ruby gem-boundary correctness + scaling guards (#3096)
|
||||
if: ${{ !cancelled() }}
|
||||
# Includes real manifest loading; checks scoped resolution and scaling
|
||||
# as sibling projects or declared gem counts grow independently.
|
||||
run: node --import tsx bench/ruby-gem-resolution/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Receiver-resolution drop guards
|
||||
if: ${{ !cancelled() }}
|
||||
# NOT build-free: this one runs the real pipeline, so it needs dist/
|
||||
|
|
@ -774,6 +801,18 @@ jobs:
|
|||
run: node --import tsx bench/objective-c-resolution/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: Callable-value reference resolution guards (#3399)
|
||||
if: ${{ !cancelled() }}
|
||||
# Build-free: pins the resolved-target SET of `resolveValueRefTarget`
|
||||
# (exact site/resolved/declined counts plus an order-independent
|
||||
# fingerprint) and asserts its per-site cost stays independent of
|
||||
# workspace size across a 4x file-count step. The pass resolves a
|
||||
# qualified receiver through `scopes.qualifiedNames`, a workspace-wide
|
||||
# index: keyed it is O(1) per site, scanned it is O(files) — a
|
||||
# regression a fixture cannot see and a 257-file binding table can.
|
||||
run: node --import tsx bench/value-ref-resolution/measure.mjs --check
|
||||
working-directory: gitnexus
|
||||
|
||||
- name: CFG construction time / disk / memory guards (#2081 M1)
|
||||
if: ${{ !cancelled() }}
|
||||
# Build-free: asserts collectFunctionCfgs output is unchanged
|
||||
|
|
|
|||
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
|
|
@ -138,7 +138,7 @@ jobs:
|
|||
|
||||
# Required for multi-platform (linux/arm64) emulation.
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
|
||||
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
|
|
|||
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
|
|
@ -831,7 +831,7 @@ jobs:
|
|||
fi
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v2
|
||||
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v2
|
||||
with:
|
||||
tag_name: ${{ steps.vtag-gate.outputs.vtag }}
|
||||
name: >-
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ Monorepo: **CLI/MCP** (`gitnexus/`) + **browser UI** (`gitnexus-web/`).
|
|||
- **HTTP bridge:** `serve.ts` → Express (`api.ts`, `mcp-http.ts`) for web UI
|
||||
- **CLI direct:** `gitnexus query|context|impact|cypher` in `tool.ts`
|
||||
|
||||
4. **Staleness** — `staleness.ts` compares indexed `lastCommit` to `HEAD`, surfaces hints.
|
||||
4. **Staleness** — `core/git-staleness.ts` compares indexed `lastCommit` to `HEAD` and classifies the result as `current`, `behind`, `diverged` (HEAD moved off the indexed commit, gap uncountable) or `unknown`; `core/staleness-status.ts` builds the one `staleness` payload that MCP `list_repos`, the read tools and the `serve` repo routes all emit.
|
||||
|
||||
## MCP tools
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
# GitNexus (Akon Labs)
|
||||
|
||||
**⚠️ Important Notice:** GitNexus has NO official cryptocurrency, token, or coin. Any token/coin using the GitNexus name on Pump.fun or any other platform is **not affiliated with, endorsed by, or created by** this project or its maintainers. Do not purchase any cryptocurrency claiming association with GitNexus.
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://trendshift.io/repositories/19809" target="_blank">
|
||||
|
|
@ -409,7 +407,7 @@ backoff. Invalid `.gitnexusrc` or ignore-file reloads pause ordinary refreshes
|
|||
until the control file is fixed. Stop the watcher with Ctrl+C.
|
||||
|
||||
Watch mode accepts `--debounce`, `--workers`, `--worker-timeout`,
|
||||
`--max-file-size`, `--branch`, `--pdg`, `--name`, `--allow-duplicate-name`, and
|
||||
`--max-file-size`, `--branch`, `--pdg`, `--skip-fts`, `--name`, `--allow-duplicate-name`, and
|
||||
`--verbose`. Explicit one-shot options such as `--force`, `--repair-fts`,
|
||||
embedding flags, `--skills`, `--self-commit`, `--index-only`, and `--skip-git`
|
||||
are rejected. Unsupported defaults from `.gitnexusrc` are ignored with a
|
||||
|
|
@ -443,6 +441,7 @@ The token may be set in the shell, `.env.local`, or `.env` in the working direct
|
|||
gitnexus analyze --force # Full graph + FTS rebuild (reuses unchanged parser output)
|
||||
gitnexus analyze --no-parse-cache # Full rebuild that re-parses every source file
|
||||
gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data
|
||||
gitnexus analyze --skip-fts # Index graph/embeddings without loading FTS or building keyword indexes
|
||||
gitnexus analyze --skills # Generate repo-specific skill files from detected communities
|
||||
gitnexus analyze --skip-embeddings # Skip embedding generation (faster)
|
||||
gitnexus analyze --embeddings [limit] # Enable embedding generation (slower, better search)
|
||||
|
|
@ -460,6 +459,8 @@ gitnexus analyze --wal-checkpoint-threshold 67108864 # LadybugDB WAL auto-check
|
|||
# (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB)
|
||||
```
|
||||
|
||||
`--skip-fts` (or `GITNEXUS_SKIP_FTS=1`) disables FTS extension loading and keyword-index construction for this analysis. Graph queries, communities, processes, and existing embeddings remain available. Status and search report "FTS disabled for this index". Remove both the flag and environment setting and run `analyze` again to restore keyword search, even at the same commit. Only the exact environment value `1` enables the opt-out; the flag takes precedence. It cannot be combined with `--repair-fts`. Disabling an existing FTS index may require one graph-store rebuild to avoid unsafe writes through native indexes.
|
||||
|
||||
`--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.
|
||||
|
|
@ -585,6 +586,7 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max
|
|||
| `GITNEXUS_WORKER_POOL_SIZE` | `cores - 1`, capped at 16 | Parse worker pool size (must be ≥ 1). Equivalent to `--workers <n>`. The worker pool is the sole parse path — there is no sequential parser, so `0` is rejected with an actionable error (the pool self-heals via quarantine + respawn). | Constrained containers (cgroup CPU limits) or CI runners with explicit quotas. To narrow down a worker crash set `1` for a single-worker pool — not `0`. |
|
||||
| `GITNEXUS_PARSE_CHUNK_CONCURRENCY` | `2` | Number of chunks whose file contents may be read into memory in parallel while the pool dispatches the current chunk. Worker dispatch itself stays serial. | Repos large enough to chunk (multi-MB total source) where disk I/O is a measurable fraction of analyze wall-clock. |
|
||||
| `GITNEXUS_VERBOSE` | unset | When `1`, enables verbose ingestion logs (skipped-file warnings, per-chunk throughput, parse-cache stats). Equivalent to `--verbose`. | Debugging an analyze that "completed" but seems to have missed files; tuning `--workers` / chunk concurrency against observable throughput. |
|
||||
| `GITNEXUS_EMBEDDING_RETRY_TIMEOUTS` | unset | When truthy (`1`/`true`/`yes`), per-attempt HTTP embedding timeouts (`TimeoutError` on fetch or body read) go through the bounded `GITNEXUS_EMBEDDING_MAX_ATTEMPTS` retry loop instead of failing the job. Any other value leaves it off, so cloud/default timeouts remain terminal. | Local accelerators that drop a device lock when the client disconnects and succeed on the next request (observed with FastFlowLM on Ryzen AI). |
|
||||
| `GITNEXUS_ANALYZER_IDENTITY_IN_PROCESS_GUARDS` | unset | When truthy (`1`/`true`/`yes`), forces in-process cache-guard validation once a batch has ≥128 requests. In-process mode also auto-selects when `packageRoot`/`buildRoot` fail `W_OK` with `EACCES`/`EROFS`. Otherwise those large batches use a Node subprocess probe. Batches under 128 always stay in-process. | Trusted or read-only installs where two identity subprocess spawns per analyze dominate wall time; leave unset to keep the default isolation path on writable trees. |
|
||||
| `GITNEXUS_RESOLVE_DEF_GRAPH_ID_MEMO` | on (unset) | Memoizes `resolveDefGraphId` per `nodeLookup` instance (WeakMap). Enabled by default. Set to `0`/`false`/`off`/`no` to disable and recompute on every call (debug / bisect memo bugs). | Suspecting stale graph-id resolution after a lookup rebuild, or comparing memo vs uncached cost on a large index. |
|
||||
| `GITNEXUS_AUTH_TOKEN` | unset | Bearer token required when `eval-server` binds beyond loopback. May also be read from `.env.local` or `.env`; shell values take precedence. | Exposing the evaluation HTTP tools to a container, VM, or LAN. |
|
||||
|
|
@ -596,6 +598,7 @@ Most `analyze` knobs are also CLI flags (`--workers`, `--worker-timeout`, `--max
|
|||
| `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS` | `30000` | Worker idle timeout in milliseconds before retry/fallback. Equivalent to `--worker-timeout <seconds>` × 1000. | Slow-parsing files (large minified JS, deeply-nested TS types) that legitimately need more than 30s. |
|
||||
| `GITNEXUS_WORKER_READY_TIMEOUT_MS` | `5000` | Startup budget in milliseconds for a parse worker to load its grammar bindings and report `{type:'ready'}`. Slots that miss it are treated as startup crashes. | Slow or heavily loaded hosts where a full pool cold-starting concurrently needs more than 5s, and analyze aborts with "did not report ready within 5000ms". |
|
||||
| `GITNEXUS_FTS_STEMMER` | `porter` | Stemmer used when rebuilding BM25/FTS indexes. Use `none` for CJK-heavy repositories, or a language stemmer such as `german`, `french`, or `spanish` for matching repository comments. Re-run `gitnexus analyze --repair-fts` after changing it. | Keyword search quality is poor for non-English comments or identifiers under English stemming. |
|
||||
| `GITNEXUS_SKIP_FTS` | unset | When exactly `1`, skips FTS extension loading and keyword index creation during analyze. Equivalent to `--skip-fts`; a later analyze without either option restores FTS. | Graph-only consumers with their own retrieval, or short-lived indexes that do not need keyword search. |
|
||||
| `GITNEXUS_WAL_CHECKPOINT_THRESHOLD` | `67108864` (64 MiB) | LadybugDB WAL auto-checkpoint threshold in bytes. Equivalent to `--wal-checkpoint-threshold <bytes>`. `-1` keeps LadybugDB's stock threshold (~16 MiB). Larger thresholds reduce checkpoint frequency but increase the WAL size at rotation time — choose a smaller value on disk-constrained environments. | You need a larger or smaller WAL auto-checkpoint threshold for your analyze workload. |
|
||||
| `GITNEXUS_LBUG_BUFFER_POOL_SIZE` | min(2 GiB, 80% RAM) | LadybugDB buffer-pool ceiling in bytes for every GitNexus database (analyze, MCP server, serve, group bridges). `0` restores LadybugDB's native unbounded default of 80% of system RAM; invalid values warn and fall back to the default (#2557). During `analyze` the pool is right-sized to the graph, scaled on non-4 KiB-page hosts by the page-size granule ratio up to min(2 GiB × pageSize/4 KiB, 80% RAM) (#2631); this env var overrides all of that as an absolute value. | A long-lived `gitnexus mcp` or a big incremental `analyze` uses too much memory, or a huge repo's working set genuinely needs a pool larger than 2 GiB. |
|
||||
| `GITNEXUS_LBUG_MAX_DB_SIZE` | `17179869184` (16 GiB) | Maximum size in bytes of a single LadybugDB database file — an mmap/disk-address-space ceiling, not a memory limit (it does not constrain the buffer pool). Invalid values silently fall back to the default. | Indexing a genuinely huge monorepo whose on-disk graph index approaches 16 GiB. |
|
||||
|
|
|
|||
52
RUNBOOK.md
52
RUNBOOK.md
|
|
@ -187,6 +187,58 @@ If the error text is `"Only one write transaction at a time is allowed in the sy
|
|||
|
||||
---
|
||||
|
||||
## File acquisition/reclaim guard recovery
|
||||
|
||||
The portable file-lock backend uses `analyze.lock.guard` beside `analyze.lock`.
|
||||
Every acquisition, including an empty slot, exclusively creates the guard before
|
||||
inspecting, reclaiming, creating, and verifying the main lock. It removes the
|
||||
guard before returning a workload handle or waiting on a live workload holder.
|
||||
Linux abstract-socket and Windows named-pipe locking are unchanged.
|
||||
|
||||
A stalled or crashed guard owner blocks file acquisition even when its PID is
|
||||
dead, its metadata is incomplete, or no main lock exists. **The guard is never
|
||||
automatically stolen.** Guard contention times out after at most 30 seconds,
|
||||
capped by the remaining acquisition timeout. This separate ceiling applies even
|
||||
when `GITNEXUS_INDEX_LOCK_TIMEOUT_MS` is zero or negative (unbounded workload wait).
|
||||
A guard-cleanup failure rejects acquisition; it must not start unprotected work.
|
||||
|
||||
Manual recovery is an outage procedure, not an age/PID-based cleanup:
|
||||
|
||||
1. Identify the exact lock directory named in the error. This shared primitive
|
||||
also protects group sync and registry operations, not just repo analysis.
|
||||
2. Stop **all relevant writers** and prevent restart: editor/agent hooks, watch
|
||||
processes, scheduled jobs, services, and any containers sharing the directory.
|
||||
Account for paused processes and every host with access. If quiescence cannot
|
||||
be established, do not remove the guard. PID metadata is diagnostic only.
|
||||
3. While restart remains disabled, inspect and preserve the guard/main records
|
||||
for diagnosis, then remove only that directory's orphan `analyze.lock.guard`
|
||||
and, if present, its orphan `analyze.lock`. Do not remove databases or sidecars
|
||||
as part of lock recovery. Do not use a recursive or wildcard cleanup.
|
||||
4. Ensure all participating writers use the guarded version and the same locking
|
||||
backend/domain, then restart in a controlled fashion.
|
||||
|
||||
**Upgrade requires a coordinated stop/upgrade/restart.** Concurrent older
|
||||
versions ignore the guard and can still displace live locks; mixed-version
|
||||
mutual exclusion is not guaranteed. The file protocol assumes reliable atomic
|
||||
local-filesystem `O_EXCL` creation and cooperating processes. Network/distributed
|
||||
filesystems, external file replacement, and uncoordinated manual deletion are not
|
||||
covered. A process crash while holding the short-lived guard trades automatic
|
||||
recovery for fail-closed safety. Denied file creation returns a non-owning
|
||||
`lockFree` handle only when neither workload lock nor acquisition guard exists;
|
||||
unreadable paths fail closed. No staging sweep runs without ownership. Analysis,
|
||||
registry transactions, group synchronization, and embeddings sync refuse
|
||||
`lockFree` handles, including an otherwise up-to-date analysis on a file-backend
|
||||
read-only mount.
|
||||
The socket backend can still acquire ownership on a read-only index mount.
|
||||
Heterogeneous permissions are not proof that another process cannot write.
|
||||
|
||||
If guard cleanup fails after this attempt created its workload record, acquisition
|
||||
is refused and token-exact workload cleanup is attempted before returning the
|
||||
error. Failed or unverifiable cleanup must be diagnosed under the same quiesced
|
||||
recovery procedure above; never delete a possibly active successor's record.
|
||||
|
||||
---
|
||||
|
||||
## Where to dig deeper
|
||||
|
||||
- Architecture overview: [ARCHITECTURE.md](ARCHITECTURE.md)
|
||||
|
|
|
|||
|
|
@ -83,15 +83,23 @@ Notes: `offset` ≥ `total` returns an empty page (with `total` still reported).
|
|||
|
||||
### Inline staleness signal (`query` / `context` / `impact` / `cypher`)
|
||||
|
||||
These four hot read tools attach a non-blocking `staleness` field to their response when the index is behind the checkout's current HEAD — the same `{ commitsBehind, hint }` shape `list_repos` already reports — so a direct tool call surfaces a behind-HEAD index without a separate `list_repos` call:
|
||||
These four hot read tools attach a non-blocking `staleness` field to their response when the index is not at the checkout's current HEAD — the same `{ status, commitsBehind?, hint? }` shape `list_repos` already reports — so a direct tool call surfaces a stale index without a separate `list_repos` call:
|
||||
|
||||
```jsonc
|
||||
{ /* …the tool's normal result… */
|
||||
"staleness": { "commitsBehind": 3, "hint": "⚠️ Index is 3 commits behind HEAD. Run analyze tool to update." }
|
||||
"staleness": { "status": "behind", "commitsBehind": 3, "hint": "⚠️ Index is 3 commits behind HEAD. Run analyze tool to update." }
|
||||
}
|
||||
```
|
||||
|
||||
The field is **absent when the index is current** (or when the freshness check can't run), so its presence is the signal. It is only ever added to object results — raw-array `cypher` output and error envelopes are returned unchanged. `@group`-targeted calls do not carry it (multi-repo staleness is ill-defined). When you see it, the graph may be behind the working tree — re-run `analyze` before trusting blast-radius or dependence answers.
|
||||
`commitsBehind` is present only when git counted the gap. When git could not count it but HEAD still resolves to a commit other than the indexed one — usually because the indexed commit is no longer in the clone's history — the index is provably not at HEAD with no countable gap, so no number is reported:
|
||||
|
||||
```jsonc
|
||||
{ /* …the tool's normal result… */
|
||||
"staleness": { "status": "diverged", "hint": "⚠️ Index is not at HEAD and the commit gap could not be counted — the recorded commit may no longer be in this clone's history. Run analyze tool to update." }
|
||||
}
|
||||
```
|
||||
|
||||
The field is **absent when the index is current**, and these four tools also omit it when the freshness check could not run at all — that case is `status: "unknown"`, which only the `list_repos` listing reports. So its presence means the status is not `current`: read `status` before using `commitsBehind`. It is only ever added to object results — raw-array `cypher` output and error envelopes are returned unchanged. `@group`-targeted calls do not carry it (multi-repo staleness is ill-defined). When you see it, the graph may be behind the working tree — re-run `analyze` before trusting blast-radius or dependence answers.
|
||||
|
||||
### Taint findings (`explain`)
|
||||
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ export type {
|
|||
FinalizeOutput,
|
||||
FinalizedScc,
|
||||
FinalizeStats,
|
||||
AmbiguousWildcardExport,
|
||||
} from './scope-resolution/finalize-algorithm.js';
|
||||
|
||||
// Scope-aware registries + 7-step lookup (RFC §4; Ring 2 SHARED #917)
|
||||
|
|
|
|||
|
|
@ -5,13 +5,14 @@
|
|||
* Pure logic that takes per-file parse output (`ParsedImport[]` +
|
||||
* `SymbolDefinition[]`) and returns:
|
||||
*
|
||||
* - Linked `ImportEdge[]` per module scope, with `targetModuleScope` and
|
||||
* `targetDefId` filled where resolvable; edges that could not be
|
||||
* resolved within the hard fixpoint cap are marked
|
||||
* - Linked `ImportEdge[]` keyed by binding scope (`fromScope`; module
|
||||
* scope unless `importsBindAtLexicalScope` is on), with
|
||||
* `targetModuleScope` and `targetDefId` filled where resolvable; edges
|
||||
* that could not be resolved within the hard fixpoint cap are marked
|
||||
* `linkStatus: 'unresolved'`.
|
||||
* - Materialized `bindings` per module scope — local defs merged with
|
||||
* imported / wildcard-expanded / re-exported names via the provider's
|
||||
* `mergeBindings` precedence.
|
||||
* - Materialized `bindings` keyed by the same scopes — local defs merged
|
||||
* with imported / wildcard-expanded / re-exported names via the
|
||||
* provider's `mergeBindings` precedence.
|
||||
* - The SCC condensation of the import graph, exposed so disjoint SCCs
|
||||
* can be processed in parallel by callers that want that.
|
||||
*
|
||||
|
|
@ -39,7 +40,7 @@ import type { BindingRef, ImportEdge, ParsedImport, ScopeId, WorkspaceIndex } fr
|
|||
/** Per-file input for the finalize pass. */
|
||||
export interface FinalizeFile {
|
||||
readonly filePath: string;
|
||||
/** The module scope id for this file; owns the finalized imports + bindings. */
|
||||
/** Default binding scope for imports without lexical provenance or opt-in. */
|
||||
readonly moduleScope: ScopeId;
|
||||
readonly parsedImports: readonly ParsedImport[];
|
||||
/**
|
||||
|
|
@ -84,6 +85,10 @@ export interface FinalizeInput {
|
|||
* expects pure answers.
|
||||
*/
|
||||
export interface FinalizeHooks {
|
||||
/** Bind imports at their extracted lexical scope. Missing provenance retains
|
||||
* the legacy module-scope behavior. Opt-in: lexical position and language
|
||||
* import-binding semantics are distinct facts. */
|
||||
readonly importsBindAtLexicalScope?: boolean;
|
||||
/**
|
||||
* Resolve a raw import target to the concrete file path that owns it.
|
||||
* Return `null` when no target file is resolvable (e.g., `np.foo` when
|
||||
|
|
@ -114,6 +119,34 @@ export interface FinalizeHooks {
|
|||
*/
|
||||
expandsWildcardTo(targetModuleScope: ScopeId, workspaceIndex: WorkspaceIndex): readonly string[];
|
||||
|
||||
/**
|
||||
* Does this language make two `wildcard` re-exports that both DECLARE the
|
||||
* same name AMBIGUOUS (no winner), rather than overloads or redeclarations
|
||||
* of one entity?
|
||||
*
|
||||
* True for ECMAScript modules: `export * from './a'; export * from './b'`
|
||||
* with `collide` declared in both excludes the name from the module's
|
||||
* exports, so binding either source is a guess. False (the default) for
|
||||
* languages whose wildcard import is `#include`, `require`, or a package
|
||||
* fan-out, where the same name declared in two files is an overload set
|
||||
* (C++ `write_audit(int)` / `write_audit(int, int)` across two headers), a
|
||||
* redeclaration of one function, or a per-file `init` — legal, and resolved
|
||||
* downstream by arity or by definition. Only a language that opts in has
|
||||
* its collisions refused and reported via `ambiguousWildcardExports`.
|
||||
*/
|
||||
readonly wildcardCollisionIsAmbiguous?: boolean;
|
||||
|
||||
/**
|
||||
* A named import / named re-export binds only to MODULE-LEVEL declarations
|
||||
* of the target file. Opt-in for languages whose `import { x }` can never
|
||||
* reach a class member: without it, a class method sharing a name with a
|
||||
* top-level value — or standing alone — wins the callable preference in
|
||||
* `findExportByName` and the import binds to a symbol the module cannot
|
||||
* export (a confident wrong edge). Languages that bind module-level members
|
||||
* by bare name (static members, module functions) leave it off.
|
||||
*/
|
||||
readonly namedImportsBindTopLevelOnly?: boolean;
|
||||
|
||||
/**
|
||||
* Merge `incoming` bindings into `existing` for a given name. Called
|
||||
* once per name at each scope. Typical rules:
|
||||
|
|
@ -164,12 +197,32 @@ export interface FinalizeStats {
|
|||
readonly unresolvedEdges: number;
|
||||
readonly sccCount: number;
|
||||
readonly largestSccSize: number;
|
||||
/**
|
||||
* Names a file re-exported through two or more `export *` sources that each
|
||||
* DECLARE the name, so the language names no winner. The finalize pass
|
||||
* refuses to bind them (they are absent from the file's re-export closure
|
||||
* AND from its wildcard-expanded module-scope bindings) instead of taking the
|
||||
* first-listed source and publishing the guess as `import-resolved`.
|
||||
* Reported so the caller can record the refusal — an importer of that name
|
||||
* stays unresolved, and the reason must be auditable rather than silent.
|
||||
*/
|
||||
readonly ambiguousWildcardExports: readonly AmbiguousWildcardExport[];
|
||||
}
|
||||
|
||||
/** One refused `export *` collision — see `FinalizeStats.ambiguousWildcardExports`. */
|
||||
export interface AmbiguousWildcardExport {
|
||||
readonly filePath: string;
|
||||
readonly name: string;
|
||||
/** `nodeId`s of the colliding declarations, in `export *` declaration order. */
|
||||
readonly candidateDefIds: readonly string[];
|
||||
}
|
||||
|
||||
export interface FinalizeOutput {
|
||||
/** Linked `ImportEdge[]` per module scope, in original input order. */
|
||||
/** Linked `ImportEdge[]` keyed by binding scope (`fromScope`). Module scope
|
||||
* for languages that bind file-wide; nested lexical scopes when
|
||||
* `importsBindAtLexicalScope` is on. */
|
||||
readonly imports: ReadonlyMap<ScopeId, readonly ImportEdge[]>;
|
||||
/** Materialized bindings per module scope. */
|
||||
/** Materialized bindings keyed by the same scopes as `imports`. */
|
||||
readonly bindings: ReadonlyMap<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>;
|
||||
/** SCCs in reverse-topological order (leaves first). */
|
||||
readonly sccs: readonly FinalizedScc[];
|
||||
|
|
@ -223,7 +276,33 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
|
|||
// SCC-condensed). Eliminates the recursive crawl that the per-edge
|
||||
// `tryFinalize` call site used to do; lookups are O(1) afterwards.
|
||||
// See `buildReexportClosures` for the algorithm.
|
||||
const reexportClosures = buildReexportClosures(input.files, byFilePath, edgeIndex);
|
||||
// A local import is a dependency of its file, not a re-export of that file.
|
||||
const moduleEdgeIndex = new Map<string, ImportEdgeDraft[]>();
|
||||
for (const file of input.files) {
|
||||
moduleEdgeIndex.set(
|
||||
file.filePath,
|
||||
(edgeIndex.get(file.filePath) ?? []).filter((d) => d.fromScope === file.moduleScope),
|
||||
);
|
||||
}
|
||||
const ambiguityByFile = collectAmbiguityByFile(
|
||||
input.files,
|
||||
byFilePath,
|
||||
moduleEdgeIndex,
|
||||
hooks.wildcardCollisionIsAmbiguous === true,
|
||||
hooks.namedImportsBindTopLevelOnly === true,
|
||||
);
|
||||
const ambiguousByFile = new Map<string, ReadonlySet<string>>();
|
||||
for (const [filePath, byName] of ambiguityByFile) {
|
||||
ambiguousByFile.set(filePath, new Set(byName.keys()));
|
||||
}
|
||||
const topLevelOnly = hooks.namedImportsBindTopLevelOnly === true;
|
||||
const reexportClosures = buildReexportClosures(
|
||||
input.files,
|
||||
byFilePath,
|
||||
moduleEdgeIndex,
|
||||
ambiguousByFile,
|
||||
topLevelOnly,
|
||||
);
|
||||
|
||||
// ── Phase 3: process SCCs in reverse-topological order (leaves first).
|
||||
// Within each SCC, run a bounded fixpoint that resolves intra-SCC edges.
|
||||
|
|
@ -231,6 +310,19 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
|
|||
// already finalized); edges inside the SCC may need multiple passes.
|
||||
const linkedByScope = new Map<ScopeId, readonly ImportEdge[]>();
|
||||
let linkedEdges = 0;
|
||||
// Every refused wildcard name, reported from the ambiguity map rather than from
|
||||
// the edges phase 4 happens to drop: a language whose `expandsWildcardTo`
|
||||
// returns nothing (TypeScript — `export *` never binds names locally) drops
|
||||
// no expanded edge, yet its importers were refused through the closure just
|
||||
// the same, and that refusal must still be visible. Named-vs-named conflicts
|
||||
// remain refused above but are not export-star collisions in this audit.
|
||||
const ambiguousWildcardExports: AmbiguousWildcardExport[] = [];
|
||||
for (const [filePath, byName] of ambiguityByFile) {
|
||||
for (const [name, ambiguity] of byName) {
|
||||
if (ambiguity.kind !== 'wildcard') continue;
|
||||
ambiguousWildcardExports.push({ filePath, name, candidateDefIds: ambiguity.candidateDefIds });
|
||||
}
|
||||
}
|
||||
|
||||
for (const scc of sccs) {
|
||||
const sccFiles = new Set(scc.files);
|
||||
|
|
@ -249,7 +341,7 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
|
|||
if (drafts === undefined) continue;
|
||||
for (const draft of drafts) {
|
||||
if (draft.finalized !== null) continue;
|
||||
const finalized = tryFinalize(draft, byFilePath, reexportClosures);
|
||||
const finalized = tryFinalize(draft, byFilePath, reexportClosures, topLevelOnly);
|
||||
if (finalized !== null) {
|
||||
draft.finalized = finalized;
|
||||
progressed = true;
|
||||
|
|
@ -272,13 +364,25 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
|
|||
}
|
||||
}
|
||||
|
||||
// ── Phase 4: collect finalized `ImportEdge[]` per module scope, preserving
|
||||
// ── Phase 4: collect finalized `ImportEdge[]` per binding scope, preserving
|
||||
// input order within each file, and wildcard-expand where applicable.
|
||||
for (const file of input.files) {
|
||||
const drafts = edgeIndex.get(file.filePath);
|
||||
if (drafts === undefined) continue;
|
||||
const finalized: ImportEdge[] = [];
|
||||
const finalizedByScope = new Map<ScopeId, ImportEdge[]>([[file.moduleScope, []]]);
|
||||
// Names this file's `export *` sources collide on (see
|
||||
// `collectAmbiguousWildcards`). Their expanded edges are dropped here, so
|
||||
// the file's own module scope does not bind an arbitrary winner either —
|
||||
// suppressing them only in the closure would leave this binding standing,
|
||||
// and it was this binding, not the closure, that produced the published
|
||||
// `import-resolved` guess.
|
||||
const ambiguousHere = ambiguousByFile.get(file.filePath) ?? EMPTY_NAME_SET;
|
||||
for (const d of drafts) {
|
||||
let finalized = finalizedByScope.get(d.fromScope);
|
||||
if (finalized === undefined) {
|
||||
finalized = [];
|
||||
finalizedByScope.set(d.fromScope, finalized);
|
||||
}
|
||||
const edge = d.finalized;
|
||||
if (edge === null) {
|
||||
throw new Error(`Invariant violated: import edge was not finalized for ${file.filePath}`);
|
||||
|
|
@ -286,16 +390,25 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
|
|||
if (d.source.kind === 'wildcard' && edge.linkStatus !== 'unresolved') {
|
||||
// Produce one `wildcard-expanded` ImportEdge per exported name.
|
||||
const expanded = expandWildcard(edge, byFilePath, hooks, input.workspaceIndex);
|
||||
for (const e of expanded) finalized.push(e);
|
||||
for (const e of expanded) {
|
||||
if (
|
||||
d.fromScope === file.moduleScope &&
|
||||
e.kind === 'wildcard-expanded' &&
|
||||
ambiguousHere.has(e.localName)
|
||||
)
|
||||
continue;
|
||||
finalized.push(e);
|
||||
}
|
||||
} else {
|
||||
finalized.push(edge);
|
||||
}
|
||||
if (edge.linkStatus !== 'unresolved') linkedEdges++;
|
||||
}
|
||||
linkedByScope.set(file.moduleScope, Object.freeze(finalized));
|
||||
for (const [scopeId, edges] of finalizedByScope)
|
||||
linkedByScope.set(scopeId, Object.freeze(edges));
|
||||
}
|
||||
|
||||
// ── Phase 5: materialize module-scope bindings (local + imports + wildcards),
|
||||
// ── Phase 5: materialize bindings (local + scoped imports + wildcards),
|
||||
// delegating precedence to `provider.mergeBindings`.
|
||||
const bindingsByScope = materializeBindings(input.files, linkedByScope, hooks);
|
||||
|
||||
|
|
@ -312,6 +425,7 @@ export function finalize(input: FinalizeInput, hooks: FinalizeHooks): FinalizeOu
|
|||
unresolvedEdges: totalEdges - linkedEdges,
|
||||
sccCount,
|
||||
largestSccSize,
|
||||
ambiguousWildcardExports: Object.freeze(ambiguousWildcardExports),
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
|
|
@ -339,6 +453,10 @@ function makeEdgeDrafts(
|
|||
hooks: FinalizeHooks,
|
||||
workspace: WorkspaceIndex,
|
||||
): ImportEdgeDraft[] {
|
||||
const fromScope =
|
||||
hooks.importsBindAtLexicalScope === true
|
||||
? (parsed.declaredAtScope ?? file.moduleScope)
|
||||
: file.moduleScope;
|
||||
// Dynamic-unresolved passes through — no `BindingRef`, no target file.
|
||||
if (parsed.kind === 'dynamic-unresolved') {
|
||||
const base: ImportEdge = {
|
||||
|
|
@ -351,7 +469,7 @@ function makeEdgeDrafts(
|
|||
{
|
||||
source: parsed,
|
||||
fromFile: file.filePath,
|
||||
fromScope: file.moduleScope,
|
||||
fromScope,
|
||||
targetFile: null,
|
||||
base,
|
||||
finalized: base, // already fully finalized
|
||||
|
|
@ -381,7 +499,7 @@ function makeEdgeDrafts(
|
|||
{
|
||||
source: parsed,
|
||||
fromFile: file.filePath,
|
||||
fromScope: file.moduleScope,
|
||||
fromScope,
|
||||
targetFile: null,
|
||||
base,
|
||||
finalized: base,
|
||||
|
|
@ -417,7 +535,7 @@ function makeEdgeDrafts(
|
|||
return {
|
||||
source: parsed,
|
||||
fromFile: file.filePath,
|
||||
fromScope: file.moduleScope,
|
||||
fromScope,
|
||||
targetFile: tf,
|
||||
base,
|
||||
finalized: isFileLevelTerminal ? base : null,
|
||||
|
|
@ -529,6 +647,7 @@ function tryFinalize(
|
|||
draft: ImportEdgeDraft,
|
||||
byFilePath: Map<string, FinalizeFile>,
|
||||
reexportClosures: ReadonlyMap<string, FileReexportClosure>,
|
||||
topLevelOnly: boolean,
|
||||
): ImportEdge | null {
|
||||
const targetFile = draft.targetFile;
|
||||
if (targetFile === null) return draft.base; // already terminal
|
||||
|
|
@ -552,7 +671,11 @@ function tryFinalize(
|
|||
// so consumers can reach the module as a symbol — but its absence is not
|
||||
// a failure.
|
||||
if (draft.base.kind === 'namespace') {
|
||||
const moduleDef = findExportByName(targetModule.localDefs, extractExportedName(draft.source));
|
||||
const moduleDef = findExportByName(
|
||||
targetModule.localDefs,
|
||||
extractExportedName(draft.source),
|
||||
topLevelOnly,
|
||||
);
|
||||
return {
|
||||
...draft.base,
|
||||
targetModuleScope: targetModule.moduleScope,
|
||||
|
|
@ -564,7 +687,7 @@ function tryFinalize(
|
|||
// local defs. Multi-hop re-export chains settle iteratively — each hop
|
||||
// resolves once its prior hop is finalized.
|
||||
const importedName = extractExportedName(draft.source);
|
||||
const exported = findExportByName(targetModule.localDefs, importedName);
|
||||
const exported = findExportByName(targetModule.localDefs, importedName, topLevelOnly);
|
||||
|
||||
if (exported !== undefined) {
|
||||
const transitiveVia =
|
||||
|
|
@ -691,15 +814,18 @@ function buildReexportClosures(
|
|||
files: readonly FinalizeFile[],
|
||||
byFilePath: ReadonlyMap<string, FinalizeFile>,
|
||||
edgeIndex: ReadonlyMap<string, ImportEdgeDraft[]>,
|
||||
ambiguous: ReadonlyMap<string, ReadonlySet<string>>,
|
||||
topLevelOnly: boolean,
|
||||
): ReadonlyMap<string, FileReexportClosure> {
|
||||
const closures = new Map<string, Map<string, ReexportClosureEntry>>();
|
||||
for (const file of files) closures.set(file.filePath, new Map());
|
||||
|
||||
// ── Step 1: build the re-export sub-graph (only resolvable wildcard /
|
||||
// reexport / flagged-named targets contribute edges), and collect the
|
||||
// per-file ambiguous names in the same walk.
|
||||
// reexport / flagged-named targets contribute edges). The per-file
|
||||
// ambiguous-name sets arrive precomputed (`collectAmbiguityByFile`) because
|
||||
// phase 4 consults the same sets when it expands wildcards into module
|
||||
// scope — one source of truth for "this name has no winner".
|
||||
const subGraph = new Map<string, Set<string>>();
|
||||
const ambiguous = new Map<string, ReadonlySet<string>>();
|
||||
for (const file of files) {
|
||||
const targets = new Set<string>();
|
||||
const drafts = edgeIndex.get(file.filePath);
|
||||
|
|
@ -710,7 +836,6 @@ function buildReexportClosures(
|
|||
if (!byFilePath.has(d.targetFile)) continue;
|
||||
targets.add(d.targetFile);
|
||||
}
|
||||
ambiguous.set(file.filePath, collectAmbiguousReexports(drafts, byFilePath));
|
||||
}
|
||||
subGraph.set(file.filePath, targets);
|
||||
}
|
||||
|
|
@ -726,7 +851,7 @@ function buildReexportClosures(
|
|||
if (!scc.isCycle) {
|
||||
const filePath = scc.files[0];
|
||||
if (filePath !== undefined) {
|
||||
populateFileClosure(filePath, byFilePath, edgeIndex, closures, ambiguous);
|
||||
populateFileClosure(filePath, byFilePath, edgeIndex, closures, ambiguous, topLevelOnly);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
|
@ -740,7 +865,9 @@ function buildReexportClosures(
|
|||
progressed = false;
|
||||
iter++;
|
||||
for (const filePath of scc.files) {
|
||||
if (populateFileClosure(filePath, byFilePath, edgeIndex, closures, ambiguous)) {
|
||||
if (
|
||||
populateFileClosure(filePath, byFilePath, edgeIndex, closures, ambiguous, topLevelOnly)
|
||||
) {
|
||||
progressed = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -820,6 +947,220 @@ function isNamedReexport(draft: ImportEdgeDraft): draft is ImportEdgeDraft & {
|
|||
* are still filling in, so detecting them needs a set that grows during the
|
||||
* fixpoint — the thing this pre-pass exists to avoid.
|
||||
*/
|
||||
/**
|
||||
* Per-file set of re-exported names that have NO decidable winner, from both
|
||||
* detectors: `collectAmbiguousReexports` (flagged-named vs flagged-named) and
|
||||
* `collectAmbiguousWildcards` (`export *` vs `export *`, direct declarations).
|
||||
* Fixed for the whole run; consulted by the closure fixpoint AND by phase 4's
|
||||
* wildcard expansion, so a refused name is absent from BOTH the exports an
|
||||
* importer can reach and the module-scope bindings the file itself sees.
|
||||
*/
|
||||
interface ReexportAmbiguity {
|
||||
readonly kind: 'named' | 'wildcard';
|
||||
readonly candidateDefIds: readonly string[];
|
||||
}
|
||||
|
||||
function collectAmbiguityByFile(
|
||||
files: readonly FinalizeFile[],
|
||||
byFilePath: ReadonlyMap<string, FinalizeFile>,
|
||||
edgeIndex: ReadonlyMap<string, ImportEdgeDraft[]>,
|
||||
wildcardCollisionIsAmbiguous: boolean,
|
||||
topLevelOnly: boolean,
|
||||
): ReadonlyMap<string, ReadonlyMap<string, ReexportAmbiguity>> {
|
||||
const out = new Map<string, ReadonlyMap<string, ReexportAmbiguity>>();
|
||||
for (const file of files) {
|
||||
const drafts = edgeIndex.get(file.filePath);
|
||||
if (drafts === undefined) continue;
|
||||
const byName = new Map<string, ReexportAmbiguity>();
|
||||
for (const name of collectAmbiguousReexports(drafts, byFilePath)) {
|
||||
byName.set(name, {
|
||||
kind: 'named',
|
||||
candidateDefIds: namedReexportCandidates(drafts, byFilePath, name, topLevelOnly),
|
||||
});
|
||||
}
|
||||
// Wildcard-vs-wildcard is a language rule (`FinalizeHooks.
|
||||
// wildcardCollisionIsAmbiguous`): ECMAScript excludes the name, C++
|
||||
// overloads it. Without the opt-in this half stays first-wins.
|
||||
if (wildcardCollisionIsAmbiguous) {
|
||||
for (const [name, ids] of collectAmbiguousWildcards(file, drafts, byFilePath)) {
|
||||
if (!byName.has(name)) byName.set(name, { kind: 'wildcard', candidateDefIds: ids });
|
||||
}
|
||||
}
|
||||
if (byName.size === 0) continue;
|
||||
out.set(file.filePath, byName);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The declarations a flagged-named collision on `localName` points at. */
|
||||
function namedReexportCandidates(
|
||||
drafts: readonly ImportEdgeDraft[],
|
||||
byFilePath: ReadonlyMap<string, FinalizeFile>,
|
||||
localName: string,
|
||||
topLevelOnly: boolean,
|
||||
): readonly string[] {
|
||||
const ids: string[] = [];
|
||||
for (const draft of drafts) {
|
||||
if (!isNamedReexport(draft) || draft.source.localName !== localName) continue;
|
||||
const targetFile = draft.targetFile;
|
||||
if (targetFile === null) continue;
|
||||
const target = byFilePath.get(targetFile);
|
||||
if (target === undefined) continue;
|
||||
const def = findExportByName(target.localDefs, draft.source.importedName, topLevelOnly);
|
||||
if (def !== undefined && !ids.includes(def.nodeId)) ids.push(def.nodeId);
|
||||
}
|
||||
return Object.freeze(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* `export * from './a'; export * from './b'` where BOTH `a` and `b` declare
|
||||
* `collide`: the language names no winner (ECMAScript excludes the name from
|
||||
* the module's exports entirely; a direct `import { collide }` of it is a
|
||||
* SyntaxError-class ambiguity). First-wins here published the `a` binding as
|
||||
* `import-resolved` at full confidence — a definite target for a call that has
|
||||
* none, which is the incorrect-context-over-missing-context failure in its
|
||||
* purest form. The name is refused instead and reported.
|
||||
*
|
||||
* Decidable in this pre-pass because it reads only the targets' own
|
||||
* `localDefs` — nothing that fills in during the closure fixpoint. Collisions
|
||||
* that arrive TRANSITIVELY (two wildcards whose targets each re-export the
|
||||
* name from somewhere else) are still first-wins; detecting them needs a set
|
||||
* that grows mid-fixpoint, the thing this pre-pass exists to avoid.
|
||||
*
|
||||
* A name the file DECLARES itself, or re-exports by NAME, is excluded: an
|
||||
* explicit export shadows every `export *`, so those collisions are legal and
|
||||
* resolved by precedence, not ambiguous.
|
||||
*
|
||||
* Only MODULE-LEVEL, EXPORT-SHAPED declarations can collide. `localDefs` also
|
||||
* carries class members, properties and parameters (a `Property:value` on two
|
||||
* unrelated classes, an interface field named `move`), which no `export *`
|
||||
* publishes. Counting those produced thousands of phantom collisions on a real
|
||||
* monorepo (2,640 on grafana) and — the dangerous half — would have refused a
|
||||
* genuinely exported `move()` because some class elsewhere had a `move`
|
||||
* property. The wildcard closure loop tolerates the wider set because nobody
|
||||
* imports a property by name; a refusal cannot afford the same tolerance.
|
||||
*
|
||||
* Export evidence, when the language supplies it (`SymbolDefinition.isExported`,
|
||||
* tri-state), settles the rest: a def marked `false` is module-private and is
|
||||
* neither a provider here nor published by the closure
|
||||
* (`indexTopLevelExportsByName`), so a private `function foo` beside an exported
|
||||
* one no longer refuses the export — and, the half that matters more, cannot be
|
||||
* the first-listed winner the closure binds either. A def marked `true` counts
|
||||
* whatever its label, `Variable` included: the closure publishes a `Variable`,
|
||||
* so two sources each exporting `const alpha` are a real collision and must be
|
||||
* refused rather than first-wins.
|
||||
*
|
||||
* Without evidence (`isExported` undefined — most languages) `Variable` is
|
||||
* excluded from the COLLISION set only: the typical top-level `const` in a
|
||||
* barrel's sources is module-private (`const category = ['Axis']` in fourteen
|
||||
* option-builder files), so counting it would refuse a real exported constant
|
||||
* of the same name for nothing. Residual risk, accepted, for that evidence-free
|
||||
* case: a non-exported `function`/`class` sharing its name with an exported one
|
||||
* behind the same barrel is counted as a collision and the export is refused —
|
||||
* a missing edge, never a wrong one. `ownerId` is only set for class members, so
|
||||
* a callable nested in an object literal (`showIf: (cfg) => …` across fourteen
|
||||
* option-builder files) still counts as a provider when unmarked. Measured
|
||||
* before the export marker existed: grafana@871af0720 refuses 52 names (from
|
||||
* 2,640 before the member exclusion), discourse@3f71fa15c 5.
|
||||
*/
|
||||
|
||||
/** Labels that are never a module export, whatever their owner. */
|
||||
const NON_EXPORTABLE_MEMBER_LABELS: readonly string[] = [
|
||||
'Property',
|
||||
'Method',
|
||||
'Constructor',
|
||||
'Parameter',
|
||||
'Field',
|
||||
];
|
||||
/** Labels excluded from the collision set when no export evidence is present. */
|
||||
const UNMARKED_NON_COLLIDING_LABELS: ReadonlySet<string> = new Set([
|
||||
...NON_EXPORTABLE_MEMBER_LABELS,
|
||||
'Variable',
|
||||
]);
|
||||
/**
|
||||
* Labels a module can never export by name: class/interface members and
|
||||
* parameters. Filtered by LABEL, not `ownerId` — `ownerId` is populated in a
|
||||
* later pass and is not reliable while the closure is built.
|
||||
*/
|
||||
const MEMBER_LABELS: ReadonlySet<string> = new Set(NON_EXPORTABLE_MEMBER_LABELS);
|
||||
|
||||
/**
|
||||
* A declaration `export *` could publish, for COLLISION purposes: top-level, of
|
||||
* an exportable kind, and not marked module-private. With export evidence the
|
||||
* label rule yields to the marker (an exported `Variable` collides; a private
|
||||
* `function` does not); without it `Variable` is left out — see the header.
|
||||
*/
|
||||
function isWildcardPublishable(def: SymbolDefinition): boolean {
|
||||
// Explicit evidence wins over the label: a CommonJS `module.exports = {
|
||||
// alpha() {} }` member is labeled Method and IS the module's export.
|
||||
if (def.isExported === true) return true;
|
||||
if (def.isExported === false) return false;
|
||||
if (def.ownerId !== undefined) return false;
|
||||
return !UNMARKED_NON_COLLIDING_LABELS.has(def.type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Can a declaration of the barrel's OWN shadow a name its `export *` sources
|
||||
* collide on? Only a module-level binding can — ECMAScript's explicit-export
|
||||
* precedence is about the module's own exports. A class MEMBER named `clash`
|
||||
* (`export class Unrelated { clash() {} }`) is not such a binding and must not
|
||||
* switch the collision check off; it did, and a confident edge to one source's
|
||||
* `clash` was emitted where the import should have been refused.
|
||||
*/
|
||||
function canShadowWildcard(def: SymbolDefinition): boolean {
|
||||
if (def.isExported === true) return true;
|
||||
if (def.isExported === false) return false;
|
||||
if (def.ownerId !== undefined) return false;
|
||||
return !MEMBER_LABELS.has(def.type);
|
||||
}
|
||||
function collectAmbiguousWildcards(
|
||||
file: FinalizeFile,
|
||||
drafts: readonly ImportEdgeDraft[],
|
||||
byFilePath: ReadonlyMap<string, FinalizeFile>,
|
||||
): ReadonlyMap<string, readonly string[]> {
|
||||
const shadowed = new Set<string>();
|
||||
for (const def of file.localDefs) {
|
||||
if (!canShadowWildcard(def)) continue;
|
||||
const name = deriveSimpleName(def);
|
||||
if (name !== null) shadowed.add(name);
|
||||
}
|
||||
for (const draft of drafts) {
|
||||
if (isNamedReexport(draft)) shadowed.add(draft.source.localName);
|
||||
}
|
||||
|
||||
// name → (target file → declaring def ids), in declaration order.
|
||||
const providers = new Map<string, Map<string, string[]>>();
|
||||
for (const draft of drafts) {
|
||||
if (draft.source.kind !== 'wildcard') continue;
|
||||
const targetFile = draft.targetFile;
|
||||
if (targetFile === null) continue;
|
||||
const target = byFilePath.get(targetFile);
|
||||
if (target === undefined) continue;
|
||||
for (const def of target.localDefs) {
|
||||
if (!isWildcardPublishable(def)) continue;
|
||||
const name = deriveSimpleName(def);
|
||||
if (name === null || shadowed.has(name)) continue;
|
||||
let byTarget = providers.get(name);
|
||||
if (byTarget === undefined) {
|
||||
byTarget = new Map<string, string[]>();
|
||||
providers.set(name, byTarget);
|
||||
}
|
||||
const ids = byTarget.get(targetFile);
|
||||
if (ids === undefined) byTarget.set(targetFile, [def.nodeId]);
|
||||
else ids.push(def.nodeId);
|
||||
}
|
||||
}
|
||||
const conflicting = new Map<string, readonly string[]>();
|
||||
for (const [name, byTarget] of providers) {
|
||||
// Two DIFFERENT source files declaring the name. The same file declaring
|
||||
// it twice (overloads, a declaration merged with its namespace) is one
|
||||
// provider and not a collision.
|
||||
if (byTarget.size < 2) continue;
|
||||
conflicting.set(name, Object.freeze([...byTarget.values()].flat()));
|
||||
}
|
||||
return conflicting;
|
||||
}
|
||||
|
||||
function collectAmbiguousReexports(
|
||||
drafts: readonly ImportEdgeDraft[],
|
||||
byFilePath: ReadonlyMap<string, FinalizeFile>,
|
||||
|
|
@ -859,6 +1200,7 @@ function populateFileClosure(
|
|||
edgeIndex: ReadonlyMap<string, ImportEdgeDraft[]>,
|
||||
closures: Map<string, Map<string, ReexportClosureEntry>>,
|
||||
ambiguousByFile: ReadonlyMap<string, ReadonlySet<string>>,
|
||||
topLevelOnly: boolean,
|
||||
): boolean {
|
||||
const myClosure = closures.get(filePath);
|
||||
if (myClosure === undefined) return false;
|
||||
|
|
@ -883,7 +1225,7 @@ function populateFileClosure(
|
|||
if (ambiguous.has(localName) || myClosure.has(localName)) continue;
|
||||
|
||||
const importedName = draft.source.importedName;
|
||||
const direct = findExportByName(targetModule.localDefs, importedName);
|
||||
const direct = findExportByName(targetModule.localDefs, importedName, topLevelOnly);
|
||||
if (direct !== undefined) {
|
||||
myClosure.set(localName, { def: direct, via: Object.freeze([targetFile]) });
|
||||
continue;
|
||||
|
|
@ -909,9 +1251,27 @@ function populateFileClosure(
|
|||
const targetModule = byFilePath.get(targetFile);
|
||||
if (targetModule === undefined) continue;
|
||||
|
||||
for (const def of targetModule.localDefs) {
|
||||
const name = deriveSimpleName(def);
|
||||
if (name === null || ambiguous.has(name) || myClosure.has(name)) continue;
|
||||
// Fan out the WINNER per name, not every def. `export const alpha = () =>
|
||||
// {}` emits both a `Variable` (the lexical declaration) and a `Function`
|
||||
// (the arrow) under the same simple name; iterating `localDefs` raw let
|
||||
// whichever came first — the `Variable` — claim the closure slot, and a
|
||||
// call bound to a value shadow emits no CALLS edge. Named re-exports
|
||||
// already go through `findExportByName`'s callable-preferred index; the
|
||||
// wildcard hop is the same lookup and must apply the same preference.
|
||||
// Measured: grafana `Button`/`clearButtonStyles` (arrow consts behind
|
||||
// `export *`) resolved 8 of 475 ledger entries before this.
|
||||
// Over TOP-LEVEL declarations only. `localDefs` also carries class members;
|
||||
// `Foo.render` (label `Method`, callable) outranked the file's real
|
||||
// `const render` in the callable-preferred index and `import { render }`
|
||||
// bound to a symbol `export *` can never publish — a confident wrong edge
|
||||
// where the value shadow used to yield none. Gated by the same hook as the
|
||||
// named-import path: only a language that opted in (ECMAScript, where
|
||||
// `export *` cannot publish a class member) narrows; every other language's
|
||||
// wildcard keeps the wide index, whose members are legitimately reachable.
|
||||
for (const [name, def] of (topLevelOnly ? indexTopLevelExportsByName : indexExportsByName)(
|
||||
targetModule.localDefs,
|
||||
)) {
|
||||
if (ambiguous.has(name) || myClosure.has(name)) continue;
|
||||
myClosure.set(name, { def, via: Object.freeze([targetFile]) });
|
||||
}
|
||||
const targetClosure = closures.get(targetFile);
|
||||
|
|
@ -993,6 +1353,13 @@ function deriveSimpleName(def: SymbolDefinition): string | null {
|
|||
function findExportByName(
|
||||
defs: readonly SymbolDefinition[],
|
||||
name: string,
|
||||
/**
|
||||
* `true` (a `namedImportsBindTopLevelOnly` language): consult only
|
||||
* module-level declarations, so a class member can neither outrank a
|
||||
* top-level value nor bind on its own. Phase-4 wildcard expansion keeps
|
||||
* the wide index — that is the path languages use to bind members.
|
||||
*/
|
||||
topLevelOnly: boolean = false,
|
||||
): SymbolDefinition | undefined {
|
||||
// GENERIC RULE (applies to every language using this finalize
|
||||
// algorithm): when MULTIPLE `SymbolDefinition`s share the same simple
|
||||
|
|
@ -1018,7 +1385,7 @@ function findExportByName(
|
|||
//
|
||||
// See `gitnexus/test/integration/resolvers/typescript-hof-callbacks.test.ts`
|
||||
// for the cross-file regression this rule prevents.
|
||||
return indexExportsByName(defs).get(name);
|
||||
return (topLevelOnly ? indexTopLevelExportsByName(defs) : indexExportsByName(defs)).get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1062,6 +1429,47 @@ function indexExportsByName(
|
|||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* `indexExportsByName` restricted to declarations a module publishes by name:
|
||||
* members (by LABEL — `ownerId` is stamped by a later reconcile pass and is not
|
||||
* reliable while the closure is built) are skipped unless the language marked
|
||||
* them exported (a CommonJS `module.exports = { alpha() {} }` member), and so
|
||||
* is any def the language marked module-private (`isExported === false`) — a
|
||||
* function nested inside another function carries the Function label and used
|
||||
* to displace the real exported value of the same name here; a barrel cannot
|
||||
* republish what its source never exported, and binding it would put a private
|
||||
* `function foo` in front of the exported one another source provides.
|
||||
* `Variable` stays, since a barrel legitimately republishes a `const`. Same
|
||||
* memoization contract.
|
||||
*/
|
||||
const TOP_LEVEL_EXPORTS_BY_NAME = new WeakMap<
|
||||
readonly SymbolDefinition[],
|
||||
ReadonlyMap<string, SymbolDefinition>
|
||||
>();
|
||||
|
||||
function indexTopLevelExportsByName(
|
||||
defs: readonly SymbolDefinition[],
|
||||
): ReadonlyMap<string, SymbolDefinition> {
|
||||
const cached = TOP_LEVEL_EXPORTS_BY_NAME.get(defs);
|
||||
if (cached !== undefined) return cached;
|
||||
const index = new Map<string, SymbolDefinition>();
|
||||
for (const d of defs) {
|
||||
// Evidence over label, both ways: a marked-private def (a function nested
|
||||
// in another function carries the Function label too) is skipped, and a
|
||||
// marked-exported member (`module.exports = { alpha() {} }`) is admitted.
|
||||
if (d.isExported === false) continue;
|
||||
if (d.isExported !== true && MEMBER_LABELS.has(d.type)) continue;
|
||||
const name = deriveSimpleName(d);
|
||||
if (name === null) continue;
|
||||
const existing = index.get(name);
|
||||
if (existing === undefined) index.set(name, d);
|
||||
else if (!isCallableOrTypeLike(existing.type) && isCallableOrTypeLike(d.type))
|
||||
index.set(name, d);
|
||||
}
|
||||
TOP_LEVEL_EXPORTS_BY_NAME.set(defs, index);
|
||||
return index;
|
||||
}
|
||||
|
||||
const EMPTY_NAME_SET: ReadonlySet<string> = new Set();
|
||||
|
||||
const CALLABLE_OR_TYPE_LIKE: ReadonlySet<string> = new Set([
|
||||
|
|
@ -1179,7 +1587,7 @@ function materializeBindings(
|
|||
linkedByScope: ReadonlyMap<ScopeId, readonly ImportEdge[]>,
|
||||
hooks: FinalizeHooks,
|
||||
): ReadonlyMap<ScopeId, ReadonlyMap<string, readonly BindingRef[]>> {
|
||||
const out = new Map<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>();
|
||||
const buckets = new Map<ScopeId, Map<string, readonly BindingRef[]>>();
|
||||
|
||||
// Build a `nodeId → SymbolDefinition` index once across all files
|
||||
// (O(N_files × D_defs)) so the per-edge lookup below is O(1) instead
|
||||
|
|
@ -1204,8 +1612,17 @@ function materializeBindings(
|
|||
scopeBindings.set(name, hooks.mergeBindings(existing, incoming, file.moduleScope));
|
||||
}
|
||||
|
||||
// Layer in finalized imports.
|
||||
const imports = linkedByScope.get(file.moduleScope) ?? [];
|
||||
buckets.set(file.moduleScope, scopeBindings);
|
||||
}
|
||||
|
||||
// Layer imports into their binding scope; lexical locals already live in
|
||||
// the scope tree and must not be copied into unrelated scope buckets.
|
||||
for (const [scopeId, imports] of linkedByScope) {
|
||||
let scopeBindings = buckets.get(scopeId);
|
||||
if (scopeBindings === undefined) {
|
||||
scopeBindings = new Map();
|
||||
buckets.set(scopeId, scopeBindings);
|
||||
}
|
||||
for (const edge of imports) {
|
||||
if (edge.targetDefId === undefined || edge.linkStatus === 'unresolved') continue;
|
||||
const def = defById.get(edge.targetDefId);
|
||||
|
|
@ -1224,15 +1641,18 @@ function materializeBindings(
|
|||
if (name === null) continue;
|
||||
const incoming: BindingRef[] = [{ def, origin, via: edge }];
|
||||
const existing = scopeBindings.get(name) ?? [];
|
||||
scopeBindings.set(name, hooks.mergeBindings(existing, incoming, file.moduleScope));
|
||||
scopeBindings.set(name, hooks.mergeBindings(existing, incoming, scopeId));
|
||||
}
|
||||
}
|
||||
|
||||
const out = new Map<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>();
|
||||
for (const [scopeId, scopeBindings] of buckets) {
|
||||
// Freeze nested buckets for immutability.
|
||||
const frozen = new Map<string, readonly BindingRef[]>();
|
||||
for (const [name, refs] of scopeBindings) {
|
||||
frozen.set(name, Object.freeze(refs.slice()));
|
||||
}
|
||||
out.set(file.moduleScope, frozen);
|
||||
out.set(scopeId, frozen);
|
||||
}
|
||||
|
||||
return out;
|
||||
|
|
|
|||
|
|
@ -111,6 +111,18 @@ export interface SymbolDefinition {
|
|||
* source (for example an anonymous class). Consumers may use this only as a
|
||||
* conservative priority hint; it does not change graph-node identity. */
|
||||
isSynthetic?: boolean;
|
||||
/**
|
||||
* Whether the producing language saw EXPORT EVIDENCE on this declaration —
|
||||
* an `export` modifier, a later `export { name }` specifier, an `export
|
||||
* default name`. TRI-STATE, and the absence is load-bearing: `undefined`
|
||||
* means the language emitted no verdict (most languages, and any ECMAScript
|
||||
* file whose export surface is a CommonJS assignment the marker cannot read),
|
||||
* which readers MUST treat as "unknown" and fall back to their prior
|
||||
* behavior. Only `false` says "this module does not publish the name": a
|
||||
* `false` keeps a module-private `function foo` from being counted as a
|
||||
* wildcard provider or bound through a barrel's `export *` closure.
|
||||
*/
|
||||
isExported?: boolean;
|
||||
/** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */
|
||||
ownerId?: string;
|
||||
/** #1982/#1993: bridge-held enclosing-namespace path (e.g. `NS1`, `Outer.Inner`)
|
||||
|
|
|
|||
|
|
@ -107,7 +107,14 @@ export type CaptureMatch = Readonly<Record<string, Capture>>;
|
|||
* produced when `expandsWildcardTo` materializes a wildcard against target
|
||||
* exports — a provider must never emit it at parse time.
|
||||
*/
|
||||
export type ParsedImport =
|
||||
export type ParsedImport = ParsedImportSyntax & {
|
||||
/** Lexical location retained by extraction, independently of execution timing.
|
||||
* Absent for legacy or synthesized imports. Binding semantics remain opt-in
|
||||
* through FinalizeHooks.importsBindAtLexicalScope. */
|
||||
readonly declaredAtScope?: ScopeId;
|
||||
};
|
||||
|
||||
type ParsedImportSyntax =
|
||||
/**
|
||||
* Per-name import without rename.
|
||||
*
|
||||
|
|
@ -183,14 +190,15 @@ export type ParsedImport =
|
|||
* field exists.** The natural place to decide it looks like the graph
|
||||
* bridge, by walking the scope the finalized edges hang off; that is
|
||||
* exactly what `graph-bridge/imports-to-edges.ts` once attempted, and it
|
||||
* is dead code by construction. `finalize-algorithm.ts:295` publishes
|
||||
* every file's finalized edges as
|
||||
* `linkedByScope.set(file.moduleScope, …)`, so the map the bridge
|
||||
* receives is keyed by the file's **Module** scope and by nothing else:
|
||||
* the walk starts at a `Module` every time and answers `false` for every
|
||||
* import in the tree. Finalize cannot recover the position either —
|
||||
* `FinalizeFile.parsedImports` is a flat per-file `ParsedImport[]` with
|
||||
* no scope attached. The extractor is the last stage that still knows
|
||||
* is dead code by construction. Default finalize still publishes each
|
||||
* file's edges under `file.moduleScope`, so a bridge walk that starts
|
||||
* there answers `false` for every import. Resolvers that opt into
|
||||
* `importsBindAtLexicalScope` instead key the edge by
|
||||
* `parsed.declaredAtScope` when extraction placed the statement (Rust
|
||||
* `use` in a function body is the example). Even then the *execution*
|
||||
* question — does this import run at module init? — is not the same as
|
||||
* the lexical key, and `FinalizeFile.parsedImports` is still a flat
|
||||
* per-file list. The extractor is the last stage that still knows
|
||||
* where the statement sat (`scope-extractor.ts`, Pass 3), so it marks the
|
||||
* fact here and it rides the edge from there — see
|
||||
* {@link ImportEdge.runsOnlyWhenCalled}.
|
||||
|
|
|
|||
80
gitnexus-web/package-lock.json
generated
80
gitnexus-web/package-lock.json
generated
|
|
@ -11,7 +11,7 @@
|
|||
"@langchain/anthropic": "^1.5.8",
|
||||
"@langchain/core": "^1.2.8",
|
||||
"@langchain/google-genai": "^2.2.0",
|
||||
"@langchain/langgraph": "^1.4.9",
|
||||
"@langchain/langgraph": "^1.4.14",
|
||||
"@langchain/ollama": "^1.3.0",
|
||||
"@langchain/openai": "^1.5.3",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
|
|
@ -36,7 +36,7 @@
|
|||
"pandemonium": "^2.4.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-i18next": "^17.0.12",
|
||||
"react-i18next": "^17.0.13",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-zoom-pan-pinch": "^4.0.3",
|
||||
|
|
@ -51,14 +51,14 @@
|
|||
"@playwright/test": "^1.62.1",
|
||||
"@testing-library/jest-dom": "^7.0.0",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.6",
|
||||
"@testing-library/user-event": "^14.6.7",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@types/node": "^26.0.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vercel/node": "^5.10.2",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"jsdom": "^29.1.1",
|
||||
"tree-sitter-wasms": "^0.1.13",
|
||||
|
|
@ -1165,14 +1165,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph": {
|
||||
"version": "1.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.4.9.tgz",
|
||||
"integrity": "sha512-EvD9rS66Cya09y6rbMgD3Ir8miAkJQFo7FyJOPRPO736Kz3y5TeyeBDOS8ctff/jRc788bPijHx2NVFM79Qqig==",
|
||||
"version": "1.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.4.14.tgz",
|
||||
"integrity": "sha512-uWAdRYTllfKCnTrlyovExPJCHJwcf3Wl2LzUlnaqsT7Rmoo3aCeYtq/7MV/Pw4q11motG8pR8bjr6T6V8Pe1gQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@langchain/langgraph-checkpoint": "^1.1.3",
|
||||
"@langchain/langgraph-sdk": "~1.9.28",
|
||||
"@langchain/protocol": "^0.0.18",
|
||||
"@langchain/langgraph-checkpoint": "^1.1.5",
|
||||
"@langchain/langgraph-sdk": "~1.10.2",
|
||||
"@langchain/protocol": "^0.0.19",
|
||||
"@standard-schema/spec": "1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
|
|
@ -1184,9 +1184,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph-checkpoint": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.1.3.tgz",
|
||||
"integrity": "sha512-wgzdQNeEsdw1e+4lvlj0tdq/RYR/k1vPin10g0ymGoehZDDgd9nvIllGXSXN4TFgF9sf5qQP/KTkOcLfeseIhA==",
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.1.5.tgz",
|
||||
"integrity": "sha512-BwDwl5VeTOh6CVuiIPgsUgfK51vTJDMSbFcSCUfjJWsl8/DPdK/mbv+ejxJstkSk/BlSPMP4JfXWcN6jD2ea2Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
|
@ -1196,12 +1196,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@langchain/langgraph-sdk": {
|
||||
"version": "1.9.28",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.9.28.tgz",
|
||||
"integrity": "sha512-4j3XuM0PvtmAbL8mPfBS99ez3+ytRfgbOpAR/nOeaejTRF3Q9dNw2QnaGLGng8wLPtGLoSj+SYgUOVxy9Bv9vg==",
|
||||
"version": "1.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.10.2.tgz",
|
||||
"integrity": "sha512-86qsfdBZWu1ZgywLN8AThU/jXi9rjPDZPWcTJp4SA1A/L62ypTNoSXbvtiwZt1odokXccYTxK1XWS8tmVdvEmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@langchain/protocol": "^0.0.18",
|
||||
"@langchain/protocol": "^0.0.19",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"p-queue": "^9.0.1",
|
||||
"p-retry": "^7.1.1"
|
||||
|
|
@ -1209,9 +1209,7 @@
|
|||
"peerDependencies": {
|
||||
"@langchain/core": "^1.1.48",
|
||||
"react": "^18 || ^19",
|
||||
"react-dom": "^18 || ^19",
|
||||
"svelte": "^4.0.0 || ^5.0.0",
|
||||
"vue": "^3.0.0"
|
||||
"react-dom": "^18 || ^19"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
|
|
@ -1219,12 +1217,6 @@
|
|||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"svelte": {
|
||||
"optional": true
|
||||
},
|
||||
"vue": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -1295,9 +1287,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@langchain/protocol": {
|
||||
"version": "0.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/protocol/-/protocol-0.0.18.tgz",
|
||||
"integrity": "sha512-XW1egQtPfsGI41w2AMZNFZrUIwFSQHTjVMZs0OaTpCAvht/QLoaPN8FQcsysMVypOhupG28J29yOorrc70otBQ==",
|
||||
"version": "0.0.19",
|
||||
"resolved": "https://registry.npmjs.org/@langchain/protocol/-/protocol-0.0.19.tgz",
|
||||
"integrity": "sha512-9hKcRrH7cBX6gfutdfXPoft1OCchHe4FEpALoDJMl5Qu+n/YG5ynZmyu8+8cxORlPwHBoKTxggvXz+76M1yX1Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@mapbox/node-pre-gyp": {
|
||||
|
|
@ -2101,9 +2093,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@testing-library/user-event": {
|
||||
"version": "14.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.6.tgz",
|
||||
"integrity": "sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw==",
|
||||
"version": "14.6.7",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz",
|
||||
"integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -2535,9 +2527,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"version": "19.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
||||
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
|
|
@ -2716,9 +2708,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "6.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz",
|
||||
"integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==",
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
|
||||
"integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -2730,6 +2722,7 @@
|
|||
"peerDependencies": {
|
||||
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"oxc-transform-react": "^0.145.0",
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
|
|
@ -2738,6 +2731,9 @@
|
|||
},
|
||||
"babel-plugin-react-compiler": {
|
||||
"optional": true
|
||||
},
|
||||
"oxc-transform-react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -7304,9 +7300,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.12",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.12.tgz",
|
||||
"integrity": "sha512-lFWPEGkxQ6RhusdUkysFBD58VHfSSzvHBzqMgN0SvfVpdQGfwtNkStTqdy08/sJd7s807qqutgx93fRpD0DJ3Q==",
|
||||
"version": "17.0.13",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.13.tgz",
|
||||
"integrity": "sha512-Cc1PscmblIHA1kljTqDwrcVMI21ydgmUzw0UAeQBe7pAOgfuRLfzXze4EUBQoeDiICzFIXXhHFoZxuetNg5D0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.7",
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
"@langchain/anthropic": "^1.5.8",
|
||||
"@langchain/core": "^1.2.8",
|
||||
"@langchain/google-genai": "^2.2.0",
|
||||
"@langchain/langgraph": "^1.4.9",
|
||||
"@langchain/langgraph": "^1.4.14",
|
||||
"@langchain/ollama": "^1.3.0",
|
||||
"@langchain/openai": "^1.5.3",
|
||||
"@sigma/edge-curve": "^3.1.0",
|
||||
|
|
@ -46,7 +46,7 @@
|
|||
"pandemonium": "^2.4.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-i18next": "^17.0.12",
|
||||
"react-i18next": "^17.0.13",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-zoom-pan-pinch": "^4.0.3",
|
||||
|
|
@ -61,14 +61,14 @@
|
|||
"@playwright/test": "^1.62.1",
|
||||
"@testing-library/jest-dom": "^7.0.0",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.6",
|
||||
"@testing-library/user-event": "^14.6.7",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@types/node": "^26.0.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@vercel/node": "^5.10.2",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"jsdom": "^29.1.1",
|
||||
"tree-sitter-wasms": "^0.1.13",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,28 @@ export interface BackendRepo {
|
|||
repoPath?: string; // git HEAD returns "repoPath"; older versions return "path"
|
||||
indexedAt: string;
|
||||
lastCommit?: string;
|
||||
/**
|
||||
* Branch this index was built from. Absent on legacy entries and non-git
|
||||
* repos. Since #3199 a branch-pinned analyze registers its own entry, so this
|
||||
* is what tells two entries for the same repository apart — the name is
|
||||
* derived from the clone directory and is not a contract.
|
||||
*/
|
||||
branch?: string;
|
||||
/** Non-primary branch indexes recorded for the same path. */
|
||||
branches?: Array<{ branch: string; indexedAt?: string; lastCommit?: string }>;
|
||||
/**
|
||||
* Absent when the index is at the repo's checked-out HEAD. Otherwise `status`
|
||||
* says what the server could establish: `behind` (with the counted
|
||||
* `commitsBehind`), `diverged` (HEAD has moved off the indexed commit but the
|
||||
* history needed to count the gap is gone, so there is no `commitsBehind`),
|
||||
* or `unknown` (the repository could not be measured). Same shape MCP
|
||||
* `list_repos` returns; see the server's `core/staleness-status.ts` (#3256).
|
||||
*/
|
||||
staleness?: {
|
||||
status: 'behind' | 'diverged' | 'unknown';
|
||||
commitsBehind?: number;
|
||||
hint?: string;
|
||||
};
|
||||
stats?: {
|
||||
files?: number;
|
||||
nodes?: number;
|
||||
|
|
@ -663,7 +685,14 @@ export type BackendProbeStatus = 'ok' | 'unauthorized' | 'unreachable';
|
|||
*/
|
||||
export const probeBackendStatus = async (): Promise<BackendProbeStatus> => {
|
||||
try {
|
||||
const response = await fetchWithTimeout(`${_backendUrl}/api/repos`, {}, PROBE_TIMEOUT_MS);
|
||||
// `/api/health` rather than `/api/repos`: this is a liveness question on a
|
||||
// 2s budget, and `/api/repos` now spawns a `git rev-list` per registered
|
||||
// repo to answer freshness. Probing it made the cost of "is the server up?"
|
||||
// scale with the number of indexed repos, and a failed probe re-polls,
|
||||
// stacking more children on the way (#3232 review). `/api/health` is a
|
||||
// constant, and still sits behind the same `/api/*` edge gate, so the 401
|
||||
// branch below keeps distinguishing "gated" from "not there".
|
||||
const response = await fetchWithTimeout(`${_backendUrl}/api/health`, {}, PROBE_TIMEOUT_MS);
|
||||
if (response.status === 200) return 'ok';
|
||||
return response.status === 401 ? 'unauthorized' : 'unreachable';
|
||||
} catch {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -2717,6 +2717,9 @@ for (const lang of LANGS) {
|
|||
}
|
||||
for (const arm of ['deep', 'collide']) {
|
||||
if (got[arm].resolved !== got.small.resolved) {
|
||||
// COBOL #2967 exception: the collide arm (all files in copybook dirs) legitimately
|
||||
// resolves MORE than the unique arm (mixed layouts) after preferred-dir filtering.
|
||||
if (lang === 'cobol' && arm === 'collide') continue;
|
||||
failures.push(
|
||||
`${lang}: ${arm} arm resolved ${got[arm].resolved} vs small ${got.small.resolved} — the ` +
|
||||
`${arm} arm was supposed to change ${arm === 'deep' ? 'path depth' : 'directory and file NAMING'} ` +
|
||||
|
|
|
|||
35
gitnexus/bench/mcp-tools-list/baselines.json
Normal file
35
gitnexus/bench/mcp-tools-list/baselines.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"_what": "Baselines for bench/mcp-tools-list/measure.mjs --check. Guards unrestricted MCP tools/list after #3259: validated registry cardinality (countRepos) instead of listRepos() staleness git. Same approach as bench/parse-dispatch-rounds and bench/python-workspace-import-scan — exact floors first; the only timing arms are ratios. Never a millisecond ceiling.",
|
||||
|
||||
"_triage": "READ THIS BEFORE RE-RUNNING. n_repos, count_repos, list_repos, tools_listed, schema_read_only_requires_repo and schema_mutating_requires_repo are DETERMINISTIC: a re-run never changes them, and none may be re-baselined to make CI green. count_vs_listRepos_ratio and listTools_vs_listRepos_ratio are UPPER timing arms. Runner contention dominates both, so re-run on an idle machine before investigating and read the reported `reps` first. If exactly one arm fails and it is a timing arm, suspect the machine. If listRepos() itself stops paying git (unrelated change), both ratios move toward 1 and need an explained re-baseline — do not just raise the budget.",
|
||||
|
||||
"n_repos": 200,
|
||||
"count_repos": 200,
|
||||
"list_repos": 200,
|
||||
"_shape_note": "THE FLOOR. Without these three, every ratio below is a ceiling over nothing. listTools_vs_listRepos_ratio only asserts something while the corpus still pays N parallel rev-list processes. Shrink it to three happy-path rows and both arms are cheap; the ratio still passes, asserting a property the corpus no longer has.",
|
||||
|
||||
"tools_listed": 17,
|
||||
"_tools_note": "Exact GITNEXUS_TOOLS roster size returned by client.listTools(). A schema path that throws, filters, or returns [] still looks fast on the ratio arm.",
|
||||
|
||||
"schema_read_only_requires_repo": true,
|
||||
"schema_mutating_requires_repo": true,
|
||||
"_schema_note": "This unrestricted 200-repo fixture has no cwd default, so both flags must stay true. Skipping the cwd probe and advertising a single-repo schema would pass every timing arm.",
|
||||
|
||||
"count_vs_listRepos_budget": 0.15,
|
||||
"_count_ratio_note": "countRepos_ms / listRepos_ms. A RATIO rather than a millisecond ceiling, deliberately: wall-clock is runner-speed-dependent, and this repo has already been bitten by a fixed ms budget. Putting staleness git back on countRepos collapses this toward 1. Budget is 0.15 — more than 10x above the measured ~0.01, same fail-closed presence check as import-target. min-of-7 estimator.",
|
||||
|
||||
"listTools_vs_listRepos_budget": 0.75,
|
||||
"_listTools_ratio_note": "client.listTools()_ms / listRepos_ms. Restoring listRepos() on toolSchemaRepoRequirements collapses this toward 1+. Budget is 0.75 — listRepos is git-bound and can get relatively faster on GitHub-hosted runners than the Node-bound listTools arm (measured ~0.22–0.28 here). The collapse-to-1 regression still fails. min-of-7 estimator.",
|
||||
|
||||
"_measured": {
|
||||
"count_vs_listRepos_ratio": 0.026,
|
||||
"count_vs_listRepos_ratio_samples": [0.025, 0.025, 0.026, 0.026, 0.025],
|
||||
"listTools_vs_listRepos_ratio": 0.283,
|
||||
"listTools_vs_listRepos_ratio_samples": [0.242, 0.242, 0.223, 0.267, 0.234, 0.283],
|
||||
"count_ms": 8.99,
|
||||
"listRepos_ms": 349.63,
|
||||
"listTools_ms": 99.04,
|
||||
"reps": 7
|
||||
},
|
||||
"_measured_note": "Maxima (and sample lists) over consecutive local runs using the min-of-7 estimator, including one --check pass. Milliseconds are diagnostic context only — nothing gates on them."
|
||||
}
|
||||
338
gitnexus/bench/mcp-tools-list/measure.mjs
Normal file
338
gitnexus/bench/mcp-tools-list/measure.mjs
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
/**
|
||||
* Build-free bench for unrestricted MCP `tools/list` with many registered
|
||||
* repos (#3259 / #3184 / #1363).
|
||||
*
|
||||
* WHY THIS EXISTS. `toolSchemaRepoRequirements` used to call
|
||||
* `listAllowedRepos()` → `listRepos()` → one `git rev-list` per registry row
|
||||
* (`checkStalenessAsync`). #1363 made that fan-out parallel (~50 s serial →
|
||||
* <1 s). #3259 removes it from schema introspection: `countRepos()` reads the
|
||||
* validated registry (`fs.access`, no git). Staleness git stays on
|
||||
* `list_repos`. Graph output and unit tests cannot see "we stopped spawning
|
||||
* git on tools/list" — putting `listRepos()` back still returns the same
|
||||
* tool roster and the same required-repo flags.
|
||||
*
|
||||
* ARMS:
|
||||
*
|
||||
* - `n_repos` / `count_repos` / `list_repos` — EXACT, and they are the FLOOR.
|
||||
* The ratio arms only mean something while the corpus still pays N parallel
|
||||
* `rev-list`s. Shrink it to three rows and both sides are cheap; the ratio
|
||||
* can still pass while the property the bench claims to guard is gone.
|
||||
*
|
||||
* - `tools_listed` — EXACT. `listTools` must still return the full
|
||||
* `GITNEXUS_TOOLS` roster. A schema path that errors or filters the set
|
||||
* would otherwise hide behind a "fast" timing arm.
|
||||
*
|
||||
* - `schema_read_only_requires_repo` / `schema_mutating_requires_repo` —
|
||||
* EXACT. On this unrestricted N-repo fixture there is no cwd default, so
|
||||
* both flags must stay true. Skipping the cwd probe and advertising a
|
||||
* single-repo schema would pass every timing arm.
|
||||
*
|
||||
* - `count_vs_listRepos_ratio` — UPPER timing arm, a RATIO not a millisecond
|
||||
* ceiling. `countRepos_ms / listRepos_ms`. Putting staleness git back on
|
||||
* `countRepos` collapses this toward 1. Wall-clock is runner-speed-
|
||||
* dependent; this repo has already been bitten by a fixed ms budget.
|
||||
*
|
||||
* - `listTools_vs_listRepos_ratio` — UPPER timing arm. The user-visible
|
||||
* `tools/list` path over the old `listRepos()` hot path. Restoring
|
||||
* `listRepos()` on schema introspection collapses this toward 1+.
|
||||
*
|
||||
* Isolated `GITNEXUS_HOME` — never touches `~/.gitnexus`. Also clears
|
||||
* `GITNEXUS_MCP_ALLOWED_REPOS`, `GITNEXUS_MCP_DEFAULT_REPO`, and
|
||||
* `GITNEXUS_MCP_READ_ONLY` so the invoking shell cannot shrink the roster.
|
||||
* Each fixture row is a real git repo whose `lastCommit` matches HEAD, so
|
||||
* `listRepos()` pays `rev-list` instead of failing open. The default root is
|
||||
* `mkdtempSync`; set `BENCH_ROOT` to reuse a tree across local runs.
|
||||
*
|
||||
* Usage:
|
||||
* node --import tsx bench/mcp-tools-list/measure.mjs
|
||||
* node --import tsx bench/mcp-tools-list/measure.mjs --check
|
||||
* BENCH_REPOS=3 node --import tsx bench/mcp-tools-list/measure.mjs # report only
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
||||
|
||||
import { LocalBackend } from '../../src/mcp/local/local-backend.ts';
|
||||
import { createMcpRepositoryPolicy } from '../../src/mcp/repository-policy.ts';
|
||||
import { createMCPServer } from '../../src/mcp/server.ts';
|
||||
import { GITNEXUS_TOOLS } from '../../src/mcp/tools.ts';
|
||||
import { listRegisteredRepos } from '../../src/storage/repo-manager.ts';
|
||||
|
||||
const baselines = JSON.parse(readFileSync(new URL('./baselines.json', import.meta.url), 'utf8'));
|
||||
|
||||
const CHECK = process.argv.includes('--check');
|
||||
const PINNED_REPS = 7;
|
||||
|
||||
function positiveInt(value, fallback) {
|
||||
const n = Number(value);
|
||||
return Number.isInteger(n) && n > 0 ? n : fallback;
|
||||
}
|
||||
|
||||
const REPS = CHECK ? PINNED_REPS : positiveInt(process.env.BENCH_REPS, PINNED_REPS);
|
||||
const N = CHECK ? baselines.n_repos : positiveInt(process.env.BENCH_REPOS, baselines.n_repos);
|
||||
const ROOT =
|
||||
process.env.BENCH_ROOT ?? mkdtempSync(path.join(os.tmpdir(), 'gn-mcp-tools-list-bench-'));
|
||||
const WORK = path.join(ROOT, `n-${N}`);
|
||||
const HOME = path.join(WORK, 'home');
|
||||
|
||||
process.env.GITNEXUS_HOME = HOME;
|
||||
delete process.env.GITNEXUS_MCP_ALLOWED_REPOS;
|
||||
delete process.env.GITNEXUS_MCP_DEFAULT_REPO;
|
||||
delete process.env.GITNEXUS_MCP_READ_ONLY;
|
||||
|
||||
function git(cwd, args) {
|
||||
return execFileSync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_AUTHOR_NAME: 'bench',
|
||||
GIT_AUTHOR_EMAIL: 'bench@example.com',
|
||||
GIT_COMMITTER_NAME: 'bench',
|
||||
GIT_COMMITTER_EMAIL: 'bench@example.com',
|
||||
},
|
||||
}).trim();
|
||||
}
|
||||
|
||||
function setupFixture() {
|
||||
mkdirSync(HOME, { recursive: true });
|
||||
const marker = path.join(WORK, 'ready');
|
||||
// Reuse only when the caller pinned BENCH_ROOT. The default root is a
|
||||
// mkdtempSync directory, so the ready marker cannot alias a previous run
|
||||
// and there is no exists-then-write race on a predictable /tmp name.
|
||||
if (
|
||||
process.env.BENCH_ROOT &&
|
||||
existsSync(marker) &&
|
||||
existsSync(path.join(HOME, 'registry.json'))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = [];
|
||||
for (let i = 0; i < N; i++) {
|
||||
const repoPath = path.join(WORK, 'repos', `r${i}`);
|
||||
const storagePath = path.join(repoPath, '.gitnexus');
|
||||
mkdirSync(storagePath, { recursive: true });
|
||||
writeFileSync(path.join(repoPath, 'f.txt'), `${i}\n`);
|
||||
git(repoPath, ['init', '-b', 'main']);
|
||||
git(repoPath, ['add', 'f.txt']);
|
||||
git(repoPath, ['commit', '-m', 'init']);
|
||||
entries.push({
|
||||
name: `r${i}`,
|
||||
path: repoPath,
|
||||
storagePath,
|
||||
indexedAt: '2026-09-11T00:00:00.000Z',
|
||||
lastCommit: git(repoPath, ['rev-parse', 'HEAD']),
|
||||
stats: { files: 1, nodes: 1, edges: 0, communities: 0, processes: 0 },
|
||||
});
|
||||
writeFileSync(path.join(storagePath, 'gitnexus.json'), '{}\n');
|
||||
}
|
||||
writeFileSync(path.join(HOME, 'registry.json'), `${JSON.stringify(entries, null, 2)}\n`);
|
||||
writeFileSync(marker, `${N}\n`);
|
||||
}
|
||||
|
||||
async function fastest(fn, reps) {
|
||||
await fn();
|
||||
let best = Infinity;
|
||||
let last;
|
||||
for (let r = 0; r < reps; r++) {
|
||||
const t0 = performance.now();
|
||||
last = await fn();
|
||||
best = Math.min(best, performance.now() - t0);
|
||||
}
|
||||
return { ms: best, last };
|
||||
}
|
||||
|
||||
async function listToolsOnce(backend) {
|
||||
const repositoryPolicy = await createMcpRepositoryPolicy(backend);
|
||||
const server = createMCPServer(backend, { repositoryPolicy });
|
||||
const client = new Client({ name: 'bench', version: '0.0.0' });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
|
||||
try {
|
||||
const listed = await client.listTools();
|
||||
return listed.tools.length;
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A missing budget is a DELETED GATE, not a passing arm: `got > undefined` is
|
||||
* false for every measurement. `Number.isFinite` rather than `typeof ===
|
||||
* 'number'` — JSON cannot express NaN, and the stricter check is the one whose
|
||||
* name says what the gate needs.
|
||||
*/
|
||||
function requireNumeric(key) {
|
||||
const value = baselines[key];
|
||||
if (Number.isFinite(value)) return value;
|
||||
return {
|
||||
missing: `no numeric ${key} in baselines.json — a missing budget is a DELETED GATE, not a passing arm: the comparison it gates is false for every possible measurement. Deterministic: a re-run will not change it.`,
|
||||
};
|
||||
}
|
||||
|
||||
function requireBoolean(key) {
|
||||
const value = baselines[key];
|
||||
if (typeof value === 'boolean') return value;
|
||||
return {
|
||||
missing: `no boolean ${key} in baselines.json — a missing exact floor is a DELETED GATE, not a passing arm. Deterministic: a re-run will not change it.`,
|
||||
};
|
||||
}
|
||||
|
||||
setupFixture();
|
||||
|
||||
const backend = new LocalBackend();
|
||||
await backend.init();
|
||||
const policy = await createMcpRepositoryPolicy(backend);
|
||||
|
||||
const countTimed = await fastest(() => backend.countRepos(), REPS);
|
||||
const listTimed = await fastest(() => backend.listRepos(), REPS);
|
||||
const listToolsTimed = await fastest(() => listToolsOnce(backend), REPS);
|
||||
const schema = await policy.toolSchemaRepoRequirements(backend);
|
||||
const rawRegistry = await listRegisteredRepos({ validate: false });
|
||||
|
||||
const countRepos = countTimed.last;
|
||||
const listRepos = listTimed.last.length;
|
||||
const toolsListed = listToolsTimed.last;
|
||||
const countMs = countTimed.ms;
|
||||
const listReposMs = listTimed.ms;
|
||||
const listToolsMs = listToolsTimed.ms;
|
||||
const countRatio = listReposMs === 0 ? Infinity : countMs / listReposMs;
|
||||
const listToolsRatio = listReposMs === 0 ? Infinity : listToolsMs / listReposMs;
|
||||
|
||||
console.log(`n_repos : ${N} (expect ${baselines.n_repos})`);
|
||||
console.log(`count_repos : ${countRepos} (expect ${baselines.count_repos})`);
|
||||
console.log(`list_repos : ${listRepos} (expect ${baselines.list_repos})`);
|
||||
console.log(
|
||||
`tools_listed : ${toolsListed} (expect ${baselines.tools_listed}; GITNEXUS_TOOLS ${GITNEXUS_TOOLS.length})`,
|
||||
);
|
||||
console.log(
|
||||
`schema_read_only_requires_repo : ${schema.readOnlyRequiresRepo} (expect ${baselines.schema_read_only_requires_repo})`,
|
||||
);
|
||||
console.log(
|
||||
`schema_mutating_requires_repo : ${schema.mutatingRequiresRepo} (expect ${baselines.schema_mutating_requires_repo})`,
|
||||
);
|
||||
console.log(
|
||||
`count_vs_listRepos_ratio : ${countRatio.toFixed(3)} (budget <= ${baselines.count_vs_listRepos_budget})`,
|
||||
);
|
||||
console.log(
|
||||
`listTools_vs_listRepos_ratio : ${listToolsRatio.toFixed(3)} (budget <= ${baselines.listTools_vs_listRepos_budget})`,
|
||||
);
|
||||
console.log(
|
||||
`reps : ${REPS} count ${countMs.toFixed(2)}ms / listRepos ${listReposMs.toFixed(2)}ms / listTools ${listToolsMs.toFixed(2)}ms / rawRegistry ${rawRegistry.length}`,
|
||||
);
|
||||
|
||||
if (!CHECK) process.exit(0);
|
||||
|
||||
if (process.env.BENCH_REPOS && Number(process.env.BENCH_REPOS) !== baselines.n_repos) {
|
||||
console.error(
|
||||
`\nFAIL BENCH_REPOS=${process.env.BENCH_REPOS} is ignored under --check.\n` +
|
||||
` n_repos is pinned in baselines.json (${baselines.n_repos}). A smaller\n` +
|
||||
` corpus makes both timing arms cheap and the ratios stop measuring git.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let failed = false;
|
||||
|
||||
function fail(message) {
|
||||
failed = true;
|
||||
console.error(`\nFAIL ${message}`);
|
||||
}
|
||||
|
||||
const nReposBudget = requireNumeric('n_repos');
|
||||
const countBudget = requireNumeric('count_repos');
|
||||
const listBudget = requireNumeric('list_repos');
|
||||
const toolsBudget = requireNumeric('tools_listed');
|
||||
const countRatioBudget = requireNumeric('count_vs_listRepos_budget');
|
||||
const listToolsRatioBudget = requireNumeric('listTools_vs_listRepos_budget');
|
||||
const readOnlyExpect = requireBoolean('schema_read_only_requires_repo');
|
||||
const mutatingExpect = requireBoolean('schema_mutating_requires_repo');
|
||||
|
||||
for (const got of [
|
||||
nReposBudget,
|
||||
countBudget,
|
||||
listBudget,
|
||||
toolsBudget,
|
||||
countRatioBudget,
|
||||
listToolsRatioBudget,
|
||||
readOnlyExpect,
|
||||
mutatingExpect,
|
||||
]) {
|
||||
if (got && typeof got === 'object' && 'missing' in got) fail(got.missing);
|
||||
}
|
||||
|
||||
if (Number.isFinite(nReposBudget) && N !== nReposBudget) {
|
||||
fail(
|
||||
`n_repos: ${N}, expected exactly ${nReposBudget}.\n` +
|
||||
` THE FLOOR. Ratio arms only assert something while the corpus still\n` +
|
||||
` pays N parallel rev-list processes.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (Number.isFinite(countBudget) && countRepos !== countBudget) {
|
||||
fail(`count_repos: ${countRepos}, expected exactly ${countBudget}.`);
|
||||
}
|
||||
|
||||
if (Number.isFinite(listBudget) && listRepos !== listBudget) {
|
||||
fail(`list_repos: ${listRepos}, expected exactly ${listBudget}.`);
|
||||
}
|
||||
|
||||
if (Number.isFinite(toolsBudget)) {
|
||||
if (GITNEXUS_TOOLS.length !== toolsBudget) {
|
||||
fail(
|
||||
`GITNEXUS_TOOLS.length: ${GITNEXUS_TOOLS.length}, expected exactly ${toolsBudget}.\n` +
|
||||
` The tool roster moved. Explain it; do not re-baseline tools_listed alone.`,
|
||||
);
|
||||
}
|
||||
if (toolsListed !== toolsBudget) {
|
||||
fail(
|
||||
`tools_listed: ${toolsListed}, expected exactly ${toolsBudget}.\n` +
|
||||
` listTools dropped or padded the roster. A fast arm that returns [] still\n` +
|
||||
` looks like a win on the ratio.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof readOnlyExpect === 'boolean' && schema.readOnlyRequiresRepo !== readOnlyExpect) {
|
||||
fail(
|
||||
`schema_read_only_requires_repo: ${schema.readOnlyRequiresRepo}, expected ${readOnlyExpect}.\n` +
|
||||
` On this unrestricted N-repo fixture there is no cwd default. Advertising\n` +
|
||||
` a single-repo schema skips the multi-repo arm the timing ratios guard.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof mutatingExpect === 'boolean' && schema.mutatingRequiresRepo !== mutatingExpect) {
|
||||
fail(
|
||||
`schema_mutating_requires_repo: ${schema.mutatingRequiresRepo}, expected ${mutatingExpect}.\n` +
|
||||
` On this unrestricted N-repo fixture there is no cwd default.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (Number.isFinite(countRatioBudget) && countRatio > countRatioBudget) {
|
||||
fail(
|
||||
`count_vs_listRepos_ratio: ${countRatio.toFixed(3)} exceeds ${countRatioBudget}.\n` +
|
||||
` countRepos should stay far cheaper than listRepos. A collapse toward 1.0\n` +
|
||||
` usually means staleness git is back on the count path.\n` +
|
||||
` Re-run on an idle machine before investigating, and check \`reps\` first.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (Number.isFinite(listToolsRatioBudget) && listToolsRatio > listToolsRatioBudget) {
|
||||
fail(
|
||||
`listTools_vs_listRepos_ratio: ${listToolsRatio.toFixed(3)} exceeds ${listToolsRatioBudget}.\n` +
|
||||
` tools/list should stay cheaper than the old listRepos() hot path. A\n` +
|
||||
` collapse toward 1.0+ usually means schema introspection calls listRepos\n` +
|
||||
` again. Re-run on an idle machine before investigating, and check \`reps\`.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (failed) process.exit(1);
|
||||
console.log('\nOK — within budget.');
|
||||
37
gitnexus/bench/python-workspace-import-scan/baselines.json
Normal file
37
gitnexus/bench/python-workspace-import-scan/baselines.json
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"_what": "Baselines for bench/python-workspace-import-scan/measure.mjs --check. Guards Python workspace from-import scanning after #3254: function-local discovery, lookalike rejection, and parse-volume. Same approach as bench/parse-dispatch-rounds/baselines.json — exact floors and a fingerprint first; the only timing arms are ratios.",
|
||||
|
||||
"_triage": "READ THIS BEFORE RE-RUNNING. from_links, lookalike_links, no_from_links, from_files, no_from_files, lookalike_files and layout_fingerprint are DETERMINISTIC: a re-run never changes them, and none may be re-baselined to make CI green. scan_scaling_ratio is an UPPER timing arm; prefilter_advantage is a FLOOR timing arm. Runner contention dominates both, so re-run on an idle machine before investigating and read the reported `reps` first. If exactly one arm fails and it is a timing arm, suspect the machine.",
|
||||
|
||||
"from_files": 40,
|
||||
"no_from_files": 40,
|
||||
"lookalike_files": 8,
|
||||
"_shape_note": "THE FLOOR. Without these three, every arm below is a ceiling over nothing. from_links only asserts something while the corpus still walks many files. Shrink it to one happy-path module and from_links still reads 1 and still passes, asserting a property the corpus no longer has.",
|
||||
|
||||
"from_links": 1,
|
||||
"lookalike_links": 0,
|
||||
"no_from_links": 0,
|
||||
"_links_note": "Exact contract counts. from_links=1 is the deduped function-local Record import. lookalike_links=0 pins closed AND unclosed indented docstring examples. no_from_links=0 pins files that have no from-token.",
|
||||
|
||||
"layout_fingerprint": "02e51be915083909cf231688c935cfe11bb52c7c781a70532859ad4a0bb9b42e",
|
||||
"_layout_fingerprint_note": "sha256 over sorted from|to|contract rows on the from-corpus. A change here is a BEHAVIOUR change — the extractor returned a different contract set. Explain it, never re-baseline it alone.",
|
||||
|
||||
"scan_scaling_budget": 1.6,
|
||||
"_scan_scaling_note": "(t_4n / t_n) / 4 for extractPythonWorkspaceLinks over the mixed corpus; ~1.0 is linear. A RATIO rather than a millisecond ceiling, deliberately: wall-clock is runner-speed-dependent, and this repo has already been bitten by a fixed ms budget. Budget is 1.6, matching parse-dispatch-rounds' pack_scaling_budget. min-of-15 estimator.",
|
||||
|
||||
"prefilter_advantage_floor": 3,
|
||||
"_prefilter_note": "t_from / t_nofrom at the same file count and body size. The no-from corpus is large Python with no `from` token. A working prefilter keeps that arm cheap (~5x here). Removing the prefilter collapses the ratio toward 1 because both sides tree-sitter parse. Floor is 3 — about 1.6x below the measured minimum, same headroom philosophy as the 1.6 upper budget.",
|
||||
|
||||
"_measured": {
|
||||
"scan_scaling_ratio": 0.994,
|
||||
"scan_scaling_ratio_samples": [0.994, 0.963, 0.977, 0.986, 0.889],
|
||||
"prefilter_advantage": 5.411,
|
||||
"prefilter_advantage_samples": [5.197, 4.955, 5.057, 5.124, 5.411],
|
||||
"from_ms": 86.43,
|
||||
"no_from_ms": 16.35,
|
||||
"mixed_ms": 112.1,
|
||||
"large_ms_4x": 398.76,
|
||||
"reps": 15
|
||||
},
|
||||
"_measured_note": "Maxima (and sample lists) over 5 consecutive local runs using the min-of-15 estimator from parse-dispatch-rounds. Milliseconds are diagnostic context only — nothing gates on them."
|
||||
}
|
||||
270
gitnexus/bench/python-workspace-import-scan/measure.mjs
Normal file
270
gitnexus/bench/python-workspace-import-scan/measure.mjs
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
/**
|
||||
* Build-free bench for Python workspace from-import scanning (#3254).
|
||||
*
|
||||
* WHY THIS EXISTS. `scanPythonImports` now tree-sitter-parses every `.py` file
|
||||
* that looks like it has a `from` import. Graph output does not show "how many
|
||||
* files we parsed" or "we skipped an unclosed-docstring lookalike" — a revert
|
||||
* to column-0 regex, a dropped `parseHadErrors` guard, or a removed `from`
|
||||
* prefilter can still emit the same one contract on a tiny fixture. This file
|
||||
* is the same shape as `bench/parse-dispatch-rounds`: exact floors first, one
|
||||
* ratio timing arm, never a millisecond ceiling.
|
||||
*
|
||||
* ARMS:
|
||||
*
|
||||
* - `from_links` / `lookalike_links` / `no_from_links` — EXACT. The
|
||||
* correctness floor. The from-corpus must still discover `datalib::Record`.
|
||||
* Closed + unclosed docstring lookalikes must stay at 0. Files with no
|
||||
* `from` token must stay at 0.
|
||||
*
|
||||
* - `from_files` / `no_from_files` / `lookalike_files` — EXACT, and they are
|
||||
* the FLOOR. `from_links === 1` only asserts something while the corpus
|
||||
* still has many files to walk. Shrink it to one happy-path file and the
|
||||
* link arm still passes, gating a property the corpus no longer has.
|
||||
*
|
||||
* - `layout_fingerprint` — EXACT. sha256 over sorted `from|to|contract` rows
|
||||
* on the from-corpus. Catches a contract-set change that leaves the count
|
||||
* intact.
|
||||
*
|
||||
* - `scan_scaling_ratio` — the only mixed-corpus timing arm, a RATIO not a
|
||||
* millisecond ceiling. `(t_4n / t_n) / 4` divides the machine out; ~1.0 is
|
||||
* linear. Superlinear AST work over file count lands here.
|
||||
*
|
||||
* - `prefilter_advantage` — `t_from / t_nofrom` at the same file count. The
|
||||
* no-from corpus is large Python with no `from` token. A working prefilter
|
||||
* keeps that arm cheap; removing it collapses the ratio toward 1 because
|
||||
* both sides parse.
|
||||
*
|
||||
* Usage:
|
||||
* node --import tsx bench/python-workspace-import-scan/measure.mjs
|
||||
* node --import tsx bench/python-workspace-import-scan/measure.mjs --check
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { extractPythonWorkspaceLinks } from '../../src/core/group/extractors/python-workspace-extractor.ts';
|
||||
|
||||
const baselines = JSON.parse(readFileSync(new URL('./baselines.json', import.meta.url), 'utf8'));
|
||||
|
||||
const REPS = 15;
|
||||
const FROM_FILES = 40;
|
||||
const NO_FROM_FILES = 40;
|
||||
const LOOKALIKE_FILES = 8;
|
||||
const NO_FROM_BODY = Array.from(
|
||||
{ length: 80 },
|
||||
(_, i) => `def helper_${i}(x):\n return x + ${i}\n`,
|
||||
).join('\n');
|
||||
|
||||
function writeWorkspace(root, { fromCount, noFromCount, lookalikeCount }) {
|
||||
mkdirSync(path.join(root, 'lib', 'datalib'), { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(root, 'lib', 'pyproject.toml'),
|
||||
'[project]\nname = "datalib"\nversion = "0.1.0"\ndependencies = []\n',
|
||||
);
|
||||
writeFileSync(path.join(root, 'lib', 'datalib', 'models.py'), 'class Record: pass\n');
|
||||
|
||||
mkdirSync(path.join(root, 'app', 'myapp'), { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(root, 'app', 'pyproject.toml'),
|
||||
'[project]\nname = "myapp"\nversion = "0.1.0"\ndependencies = ["datalib"]\n',
|
||||
);
|
||||
|
||||
for (let i = 0; i < fromCount; i++) {
|
||||
writeFileSync(
|
||||
path.join(root, 'app', 'myapp', `from_${i}.py`),
|
||||
`${NO_FROM_BODY}\ndef load_${i}():\n from datalib.models import Record\n return Record()\n`,
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < noFromCount; i++) {
|
||||
writeFileSync(path.join(root, 'app', 'myapp', `plain_${i}.py`), NO_FROM_BODY);
|
||||
}
|
||||
for (let i = 0; i < lookalikeCount; i++) {
|
||||
const closed = i % 2 === 0;
|
||||
writeFileSync(
|
||||
path.join(root, 'app', 'myapp', `doc_${i}.py`),
|
||||
closed
|
||||
? `def describe_${i}():\n """Example:\n from datalib.models import Record\n """\n return None\n`
|
||||
: `def describe_${i}():\n """\n from datalib.models import Record\n`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
repos: { lib: 'datalib', app: 'myapp' },
|
||||
repoPaths: new Map([
|
||||
['lib', path.join(root, 'lib')],
|
||||
['app', path.join(root, 'app')],
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
async function scan(workspace) {
|
||||
return extractPythonWorkspaceLinks(workspace.repos, workspace.repoPaths);
|
||||
}
|
||||
|
||||
function fingerprint(links) {
|
||||
return createHash('sha256')
|
||||
.update(
|
||||
links
|
||||
.map((l) => `${l.from}|${l.to}|${l.contract}`)
|
||||
.sort()
|
||||
.join('\n'),
|
||||
)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
async function fastest(fn, reps) {
|
||||
await fn();
|
||||
let best = Infinity;
|
||||
for (let r = 0; r < reps; r++) {
|
||||
const t0 = performance.now();
|
||||
await fn();
|
||||
best = Math.min(best, performance.now() - t0);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
const roots = [];
|
||||
function workspace(opts) {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'gn-py-ws-bench-'));
|
||||
roots.push(root);
|
||||
return writeWorkspace(root, opts);
|
||||
}
|
||||
|
||||
try {
|
||||
const fromWs = workspace({
|
||||
fromCount: FROM_FILES,
|
||||
noFromCount: 0,
|
||||
lookalikeCount: 0,
|
||||
});
|
||||
const noFromWs = workspace({
|
||||
fromCount: 0,
|
||||
noFromCount: NO_FROM_FILES,
|
||||
lookalikeCount: 0,
|
||||
});
|
||||
const lookalikeWs = workspace({
|
||||
fromCount: 0,
|
||||
noFromCount: 0,
|
||||
lookalikeCount: LOOKALIKE_FILES,
|
||||
});
|
||||
const mixedWs = workspace({
|
||||
fromCount: FROM_FILES,
|
||||
noFromCount: NO_FROM_FILES,
|
||||
lookalikeCount: LOOKALIKE_FILES,
|
||||
});
|
||||
const mixed4x = workspace({
|
||||
fromCount: FROM_FILES * 4,
|
||||
noFromCount: NO_FROM_FILES * 4,
|
||||
lookalikeCount: LOOKALIKE_FILES * 4,
|
||||
});
|
||||
|
||||
const [fromResult, noFromResult, lookalikeResult] = await Promise.all([
|
||||
scan(fromWs),
|
||||
scan(noFromWs),
|
||||
scan(lookalikeWs),
|
||||
]);
|
||||
const layoutFingerprint = fingerprint(fromResult.links);
|
||||
|
||||
const fromMs = await fastest(() => scan(fromWs), REPS);
|
||||
const noFromMs = await fastest(() => scan(noFromWs), REPS);
|
||||
const smallMs = await fastest(() => scan(mixedWs), REPS);
|
||||
const largeMs = await fastest(() => scan(mixed4x), REPS);
|
||||
const scanScaling = largeMs / smallMs / 4;
|
||||
const prefilterAdvantage = noFromMs === 0 ? Infinity : fromMs / noFromMs;
|
||||
|
||||
console.log(`from_files : ${FROM_FILES} (expect ${baselines.from_files})`);
|
||||
console.log(`no_from_files : ${NO_FROM_FILES} (expect ${baselines.no_from_files})`);
|
||||
console.log(`lookalike_files : ${LOOKALIKE_FILES} (expect ${baselines.lookalike_files})`);
|
||||
console.log(
|
||||
`from_links : ${fromResult.links.length} (expect ${baselines.from_links})`,
|
||||
);
|
||||
console.log(
|
||||
`lookalike_links : ${lookalikeResult.links.length} (expect ${baselines.lookalike_links})`,
|
||||
);
|
||||
console.log(
|
||||
`no_from_links : ${noFromResult.links.length} (expect ${baselines.no_from_links})`,
|
||||
);
|
||||
console.log(`layout_fingerprint : ${layoutFingerprint}`);
|
||||
console.log(
|
||||
`scan_scaling_ratio : ${scanScaling.toFixed(3)} (budget <= ${baselines.scan_scaling_budget}; ~1.0 is linear)`,
|
||||
);
|
||||
console.log(
|
||||
`prefilter_advantage : ${prefilterAdvantage.toFixed(3)} (floor >= ${baselines.prefilter_advantage_floor})`,
|
||||
);
|
||||
console.log(
|
||||
`reps : ${REPS} from ${fromMs.toFixed(2)}ms / no_from ${noFromMs.toFixed(2)}ms / mixed ${smallMs.toFixed(2)}ms / 4x ${largeMs.toFixed(2)}ms`,
|
||||
);
|
||||
|
||||
if (process.argv.includes('--check')) {
|
||||
let failed = false;
|
||||
|
||||
if (layoutFingerprint !== baselines.layout_fingerprint) {
|
||||
failed = true;
|
||||
console.error(
|
||||
`\nFAIL layout_fingerprint: ${layoutFingerprint}\n` +
|
||||
` expected ${baselines.layout_fingerprint}\n` +
|
||||
` The from-corpus contract set moved. Explain it; do not re-baseline alone.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (fromResult.links.length !== baselines.from_links) {
|
||||
failed = true;
|
||||
console.error(
|
||||
`\nFAIL from_links: ${fromResult.links.length}, expected exactly ${baselines.from_links}.`,
|
||||
);
|
||||
}
|
||||
if (lookalikeResult.links.length !== baselines.lookalike_links) {
|
||||
failed = true;
|
||||
console.error(
|
||||
`\nFAIL lookalike_links: ${lookalikeResult.links.length}, expected exactly ${baselines.lookalike_links}.\n` +
|
||||
` Closed or unclosed docstring lookalikes leaked a workspace contract.`,
|
||||
);
|
||||
}
|
||||
if (noFromResult.links.length !== baselines.no_from_links) {
|
||||
failed = true;
|
||||
console.error(
|
||||
`\nFAIL no_from_links: ${noFromResult.links.length}, expected exactly ${baselines.no_from_links}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
FROM_FILES !== baselines.from_files ||
|
||||
NO_FROM_FILES !== baselines.no_from_files ||
|
||||
LOOKALIKE_FILES !== baselines.lookalike_files
|
||||
) {
|
||||
failed = true;
|
||||
console.error(
|
||||
`\nFAIL shape: from_files ${FROM_FILES} (expected ${baselines.from_files}), ` +
|
||||
`no_from_files ${NO_FROM_FILES} (expected ${baselines.no_from_files}), ` +
|
||||
`lookalike_files ${LOOKALIKE_FILES} (expected ${baselines.lookalike_files}).\n` +
|
||||
` The corpus must stay large enough that the link arms still measure a walk.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (scanScaling > baselines.scan_scaling_budget) {
|
||||
failed = true;
|
||||
console.error(
|
||||
`\nFAIL scan_scaling_ratio: ${scanScaling.toFixed(3)} exceeds ` +
|
||||
`${baselines.scan_scaling_budget} (~1.0 is linear).\n` +
|
||||
` Re-run on an idle machine before investigating, and check \`reps\` first.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (prefilterAdvantage < baselines.prefilter_advantage_floor) {
|
||||
failed = true;
|
||||
console.error(
|
||||
`\nFAIL prefilter_advantage: ${prefilterAdvantage.toFixed(3)} below ` +
|
||||
`${baselines.prefilter_advantage_floor}.\n` +
|
||||
` The no-from corpus should stay cheaper than the from-corpus. A collapse\n` +
|
||||
` toward 1.0 usually means every file is being tree-sitter parsed again.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (failed) process.exit(1);
|
||||
console.log('\nOK — within budget.');
|
||||
}
|
||||
} finally {
|
||||
for (const root of roots) {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
61
gitnexus/bench/ruby-gem-resolution/baseline.json
Normal file
61
gitnexus/bench/ruby-gem-resolution/baseline.json
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"_what": "Synthetic Ruby manifest + ScopeResolver gate for #3096, following bench/parse-dispatch-rounds/baselines.json: exact correctness floors and fingerprints, with ratios as the only timing gates. Calls the real config loader and resolver; parser, DB, installed gems and full-pipeline memory are outside this microbenchmark.",
|
||||
"_triage": "Shape and fingerprint failures are deterministic: inspect the fixture and resolved targets, never rebaseline just to make CI green. For a timing failure, rerun on an idle machine and verify the report uses 15 samples after 2 warmups before investigating a regression. Millisecond observations are context, not gates.",
|
||||
"shapes": {
|
||||
"small": {
|
||||
"projects": 32,
|
||||
"declarations_per_project": 12,
|
||||
"scopes": 64,
|
||||
"files": 131,
|
||||
"imports": 8192,
|
||||
"resolved": 3264,
|
||||
"fingerprint": "e37f4c81bc7f4bc8416732d0e8ebb00e16743b08a44646e1a6fa45c04283cfb3"
|
||||
},
|
||||
"large": {
|
||||
"projects": 128,
|
||||
"declarations_per_project": 12,
|
||||
"scopes": 256,
|
||||
"files": 515,
|
||||
"imports": 32768,
|
||||
"resolved": 13056,
|
||||
"fingerprint": "2524217404b1d4cf118e7d13895d757c821029bf59230b6c3cc7ce2fc2cf5dd2"
|
||||
},
|
||||
"dense": {
|
||||
"projects": 32,
|
||||
"declarations_per_project": 132,
|
||||
"scopes": 64,
|
||||
"files": 131,
|
||||
"imports": 8192,
|
||||
"resolved": 3264,
|
||||
"fingerprint": "e37f4c81bc7f4bc8416732d0e8ebb00e16743b08a44646e1a6fa45c04283cfb3"
|
||||
}
|
||||
},
|
||||
"_shape_note": "Exact projects, declarations, scopes, files, imports and resolved counts are the workload FLOOR, not ceilings. They prevent a shrunken corpus or disconnected manifest loader from passing by doing less work. Small/large grow projects 4x; dense keeps files/imports fixed while growing extra Gemfile declarations from 8 to 128 per project.",
|
||||
"_fingerprint_note": "Hashes pin every importer/require/target tuple, including external decoys, aliases, local path gem hits and misses, relative/local imports and sibling isolation. The runner also checks each expected target before hashing and proves the external decoy is reachable without config. An always-null resolver or missing config must fail, not get a new fingerprint.",
|
||||
"budgets": {
|
||||
"load_scaling_ratio": 2.2,
|
||||
"resolve_scaling_ratio": 2.2,
|
||||
"dependency_count_ratio": 2
|
||||
},
|
||||
"_scaling_note": "load_scaling_ratio and resolve_scaling_ratio are (t_4n/t_n)/4: about 1 is linear, about 4 is quadratic. Keep the existing 2.2 regression budgets; do not tighten them to local milliseconds. Filesystem loading and lookup are measured separately with fresh config/file Sets per pass.",
|
||||
"_dependency_count_note": "dense.resolve_ms/small.resolve_ms should stay near 1: require lookup must scale with prefix/path depth, not all declared gems. The budget remains 2. Extra declaration parsing belongs to config loading, not this fixed-query lookup arm.",
|
||||
"_negative_controls_note": "Disconnecting loadResolutionConfig fails the real-loader assertion. A wrapper scanning all scopes' external prefixes on every import leaves targets/fingerprints unchanged but fails resolve_scaling_ratio (3.427 > 2.2) and dependency_count_ratio (7.912 > 2) with 15 samples. These are deliberate regressions, not baseline observations.",
|
||||
"_measured": {
|
||||
"environment": "macOS arm64 / Node 25.8.0, 2026-09-10",
|
||||
"load_scaling_ratio": 1.094,
|
||||
"load_scaling_ratio_samples": [0.993, 1.06, 1.05, 1.019, 1.094],
|
||||
"resolve_scaling_ratio": 1.066,
|
||||
"resolve_scaling_ratio_samples": [1.058, 1.059, 1.056, 1.066, 1.061],
|
||||
"dependency_count_ratio": 1.009,
|
||||
"dependency_count_ratio_samples": [1.009, 1.003, 0.994, 0.999, 1.0],
|
||||
"small_load_ms": 3.569,
|
||||
"small_resolve_ms": 11.782,
|
||||
"large_load_ms": 14.812,
|
||||
"large_resolve_ms": 50.222,
|
||||
"dense_load_ms": 7.27,
|
||||
"dense_resolve_ms": 11.852,
|
||||
"reps": 15,
|
||||
"warmup": 2
|
||||
},
|
||||
"_measured_note": "Five consecutive local runs using the min-of-15 estimator from parse-dispatch-rounds, with 2 warmups here. Scalar ratios and millisecond values are per-metric maxima across those runs (rounded), not one combined run. Budgets retain about 2x headroom above observed ratios. Milliseconds are diagnostic context only, not gated or real-repository speed claims. Exact shapes and fingerprints are unchanged."
|
||||
}
|
||||
184
gitnexus/bench/ruby-gem-resolution/measure.mjs
Normal file
184
gitnexus/bench/ruby-gem-resolution/measure.mjs
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
/**
|
||||
* Ruby gem-boundary correctness and cost gate (#3096).
|
||||
*
|
||||
* Calls the production ScopeResolver hooks, including the filesystem-backed
|
||||
* configuration loader. Fixture creation and correctness hashing are untimed.
|
||||
* Each timed pass reloads config and owns a fresh file Set, so neither the
|
||||
* manifest load nor the per-pass fallback index is hidden by a warm memo.
|
||||
*
|
||||
* Small/large grow sibling projects, files, declarations and imports together.
|
||||
* Dense keeps projects/imports fixed and grows extra declarations from 8 to 128:
|
||||
* lookup must depend on require/path depth, not the number of declared gems.
|
||||
*
|
||||
* node --import tsx bench/ruby-gem-resolution/measure.mjs [--check]
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { rubyScopeResolver } from '../../src/core/ingestion/languages/ruby/scope-resolver.ts';
|
||||
|
||||
const baselinePath = fileURLToPath(new URL('./baseline.json', import.meta.url));
|
||||
const CHECK = process.argv.includes('--check');
|
||||
const WARMUP = 2;
|
||||
const REPS = 15;
|
||||
const IMPORTS_PER_PROJECT = 256;
|
||||
const roots = [];
|
||||
|
||||
function corpus(projects, gemsPerProject = 8) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-ruby-gems-bench-'));
|
||||
roots.push(root);
|
||||
const files = ['decoy/lib/generators.rb', 'decoy/lib/types.rb', 'decoy/lib/missing.rb'];
|
||||
const queries = [];
|
||||
for (let i = 0; i < projects; i++) {
|
||||
const directory = `packages/pkg${i}`;
|
||||
const onDisk = path.join(root, directory);
|
||||
fs.mkdirSync(path.join(onDisk, 'engine'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(onDisk, 'Gemfile'),
|
||||
[
|
||||
"gem 'rails'",
|
||||
"gem 'dry-types'",
|
||||
"gem 'aliased', require: 'custom/entry'",
|
||||
"gem 'my_engine', path: 'engine'",
|
||||
...Array.from({ length: gemsPerProject }, (_, gem) => `gem 'dependency_${i}_${gem}'`),
|
||||
].join('\n'),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(onDisk, 'engine', 'my_engine.gemspec'),
|
||||
"Gem::Specification.new do |s|\n s.name = 'my_engine'\n s.require_paths = ['lib']\nend\n",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(onDisk, 'Gemfile.lock'),
|
||||
'GEM\n remote: https://rubygems.org/\n specs:\n locked_gem (1.0.0)\n',
|
||||
);
|
||||
const from = `${directory}/app/main.rb`;
|
||||
files.push(
|
||||
from,
|
||||
`${directory}/app/helper.rb`,
|
||||
`${directory}/lib/local_${i}.rb`,
|
||||
`${directory}/engine/lib/my_engine.rb`,
|
||||
);
|
||||
const cases = [
|
||||
['rails/generators', null],
|
||||
['dry/types', null],
|
||||
['custom/entry', null],
|
||||
['my_engine', `${directory}/engine/lib/my_engine.rb`],
|
||||
['my_engine/missing', null],
|
||||
['./helper', `${directory}/app/helper.rb`],
|
||||
[`local_${i}`, `${directory}/lib/local_${i}.rb`],
|
||||
['locked_gem/missing', null],
|
||||
[`dependency_${i}_0/missing`, null],
|
||||
[`dependency_${(i + 1) % projects}_0/missing`, 'decoy/lib/missing.rb'],
|
||||
];
|
||||
for (let j = 0; j < IMPORTS_PER_PROJECT; j++) {
|
||||
const [target, expected] = cases[j % cases.length];
|
||||
queries.push({ from, target, expected });
|
||||
}
|
||||
}
|
||||
return { root, projects, gemsPerProject, files, queries };
|
||||
}
|
||||
|
||||
function resolve(query, pass) {
|
||||
return rubyScopeResolver.resolveImportTarget(query.target, query.from, pass.files, pass.config);
|
||||
}
|
||||
|
||||
function prepare(input) {
|
||||
return {
|
||||
files: new Set(input.files),
|
||||
config: rubyScopeResolver.loadResolutionConfig(input.root),
|
||||
};
|
||||
}
|
||||
|
||||
function correctness(input) {
|
||||
const pass = prepare(input);
|
||||
assert.ok(pass.config, 'the real manifest loader must supply configuration');
|
||||
const records = [];
|
||||
let resolved = 0;
|
||||
for (const query of input.queries) {
|
||||
const answer = resolve(query, pass);
|
||||
assert.equal(answer, query.expected, `${query.from}: ${query.target}`);
|
||||
if (answer !== null) resolved++;
|
||||
records.push(`${query.from}|${query.target}->${answer}`);
|
||||
}
|
||||
// The external decoy must actually be reachable without dependency evidence;
|
||||
// otherwise an always-null resolver could make a negative-only gate pass.
|
||||
assert.equal(
|
||||
resolve(
|
||||
{ from: input.queries[0].from, target: 'rails/generators' },
|
||||
{ files: pass.files, config: undefined },
|
||||
),
|
||||
'decoy/lib/generators.rb',
|
||||
);
|
||||
return {
|
||||
projects: input.projects,
|
||||
declarations_per_project: input.gemsPerProject + 4,
|
||||
scopes: pass.config.scopesByDirectory.size,
|
||||
files: input.files.length,
|
||||
imports: records.length,
|
||||
resolved,
|
||||
fingerprint: crypto.createHash('sha256').update(records.join('\n')).digest('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
function measure(input, expectedResolved) {
|
||||
const loading = [];
|
||||
const resolution = [];
|
||||
for (let run = 0; run < WARMUP + REPS; run++) {
|
||||
const files = new Set(input.files);
|
||||
const loadStart = performance.now();
|
||||
const config = rubyScopeResolver.loadResolutionConfig(input.root);
|
||||
const loadMs = performance.now() - loadStart;
|
||||
const pass = { files, config };
|
||||
const start = performance.now();
|
||||
let resolved = 0;
|
||||
for (const query of input.queries) if (resolve(query, pass) !== null) resolved++;
|
||||
const resolveMs = performance.now() - start;
|
||||
assert.equal(resolved, expectedResolved, 'timed pass must do the validated work');
|
||||
if (run >= WARMUP) {
|
||||
loading.push(loadMs);
|
||||
resolution.push(resolveMs);
|
||||
}
|
||||
}
|
||||
// Like the neighboring resolver gates: min-of-N limits scheduler/GC noise.
|
||||
return { load_ms: Math.min(...loading), resolve_ms: Math.min(...resolution) };
|
||||
}
|
||||
|
||||
try {
|
||||
const inputs = { small: corpus(32), large: corpus(128), dense: corpus(32, 128) };
|
||||
const shapes = {};
|
||||
const timings = {};
|
||||
for (const [name, input] of Object.entries(inputs)) {
|
||||
shapes[name] = correctness(input);
|
||||
timings[name] = measure(input, shapes[name].resolved);
|
||||
}
|
||||
const scale = inputs.large.projects / inputs.small.projects;
|
||||
const metrics = {
|
||||
load_scaling_ratio: timings.large.load_ms / timings.small.load_ms / scale,
|
||||
resolve_scaling_ratio: timings.large.resolve_ms / timings.small.resolve_ms / scale,
|
||||
dependency_count_ratio: timings.dense.resolve_ms / timings.small.resolve_ms,
|
||||
};
|
||||
const report = {
|
||||
shapes,
|
||||
timings,
|
||||
metrics,
|
||||
estimator: { warmup: WARMUP, samples: REPS, statistic: 'minimum' },
|
||||
};
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (CHECK) {
|
||||
const baseline = JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
|
||||
assert.deepEqual(shapes, baseline.shapes, 'Ruby workload/fingerprint drift');
|
||||
const failures = [];
|
||||
for (const [name, budget] of Object.entries(baseline.budgets)) {
|
||||
if (!Number.isFinite(metrics[name]) || metrics[name] > budget) {
|
||||
failures.push(`${name}: ${metrics[name]} > ${budget}`);
|
||||
}
|
||||
}
|
||||
assert.equal(failures.length, 0, failures.join('\n'));
|
||||
console.log('[ruby-gem-resolution --check] PASS');
|
||||
}
|
||||
} finally {
|
||||
for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
{
|
||||
"_comment": "Per-language baselines for bench/scope-capture/measure.mjs --check. fingerprint = order-independent sha256 over the lang-resolution/<lang>-* fixture corpus + a 20-entity synthetic source (correctness gate; re-baseline intentionally on a legitimate capture change). scaling_budget = max allowed (t800/t250)/(800/250); ~1.0 is linear, ~3.2 is quadratic. The synthetic source is now HERITAGE-BEARING for every language (each Entity extends/implements/embeds/uses-trait/conforms-to a shared base) so the #1951 @reference.inherits synth is gated at scale, not just the base capture loop. All languages thread the tree-sitter captured node instead of re-deriving it with findNodeAtRange(tree.rootNode,...) per match, so all are linear (go #1915, python #1918, ruby/php/rust/csharp #1951, java #1956).",
|
||||
"go": {
|
||||
"fingerprint": "9c554a9d698a2b79fb419852daadca87b8aae88180cceabf9c8d82f3e3300f2e",
|
||||
"fingerprint": "2e3099db962f82f9d8641707a8b2bcf6875dafec1f54904f2dec7ac65ba22e9f",
|
||||
"_rebaselined_3190_ctor_qualified_name": "#3190: generic composite-literal constructors now carry @reference.qualified-name with the written pkg.Box[T] spelling so constructor fallback can tell a qualifier from a bare unique-name guess. DIGEST DRIFT ONLY: the tag is added to existing constructor matches (captureGroups unchanged on go-constructor-type-inference/cmd/main.go). Prior 990e4921a7ef0e6aa4d7da92b671de91fa2621cb27918adb27742740dc6ad902 -> 2e3099db962f82f9d8641707a8b2bcf6875dafec1f54904f2dec7ac65ba22e9f; capture_groups_fp 2579, fixture_count 122.",
|
||||
"_rebaselined_3190": "Corrected go-method-enrichment/app.go to share package animal with its declarations; its capture count is now 16 rather than 15. No Go emitter change. Scaling budget unchanged.",
|
||||
"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 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a -> 57b3c55135af8d2af33b9a7c4bf89796a7bee5b5822b402a2dea91af7232cf4a; scaling 1.058 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: provider-owned callable assignment/copy/formal/argument/invoke facts with invocation/constructor-result suppression. Prior 09ecd94911b830f52fa8807560abcbd79f163d02a2072870c1a59297e9a326e1 -> 3d4e32e7490c830516126e28931827949baa3594cb521f7a3d8dcfed95b6018a; scaling 1.039 < 1.5.",
|
||||
|
|
@ -137,8 +139,9 @@
|
|||
"_rebaselined": "#1919 review CF3 fix: extended kotlin-local-property-owner (init/accessor destructuring) + new dart-accessor-owner fixture (getter/setter ownership). Fingerprint-only corpus drift; scaling ~1.0."
|
||||
},
|
||||
"java": {
|
||||
"fingerprint": "2bf47cc19b595a9889ac21ec0154c6ce6786271d68551f21d1bc14c626bcd4ff",
|
||||
"fingerprint": "e22a753b9f59331898a3a27c43072b588c33639937b1f618f6c70ce04f072a9f",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_3190_ctor_qualified_name": "#3190: qualified constructors (`new pkg.Foo()`, `new pkg.Box<T>()`) now copy @reference.call.constructor.qualified onto @reference.qualified-name so constructor fallback treats the written qualifier as precise. DIGEST DRIFT ONLY: the tag is added to existing F35 matches. Prior 2bf47cc19b595a9889ac21ec0154c6ce6786271d68551f21d1bc14c626bcd4ff -> e22a753b9f59331898a3a27c43072b588c33639937b1f618f6c70ce04f072a9f; capture_groups_fp 3586, fixture_count 209.",
|
||||
"_rebaselined_2935_synthetic_declarations": "PR #2935 review follow-up: synthesized Java anonymous classes and bodied enum constants now carry the presence-only @declaration.is-synthetic sidecar used to preserve source-written dispatch targets at the fanout cap. DIGEST DRIFT ONLY, NOT A CAPTURE-SET CHANGE: the tag is attached to existing synthetic declaration matches; capture groups and fixture count remain 5755/18405, 3512, and 206. Prior 36d689c58526c4482fbd701d1d9ca156623a3970734ead145717858712271ab5 -> 2e2150b4f4d64519e3f4c6d7a2c12259178d3117872203c904fab8cba96a694a; CI scaling 0.971 < 1.5.",
|
||||
"_rebaselined_2917_record_component_accessors": "#2917: every implicit Java record-component accessor now emits a component-bounded @scope.function plus @declaration.method/name/zero-arity/return-type metadata. The scope boundary prevents subsequent record-body references from being attributed to the accessor. Java was the only general language fingerprint to move; capture groups scale by exactly two per generated record component (small 5755 -> 6255, large 18405 -> 20005). Prior 36d689c58526c4482fbd701d1d9ca156623a3970734ead145717858712271ab5 -> 901a66c7dc0f071eeef9e4864b2519e5b58a1a141a1f9a7817ea42f7ff70eafb; scaling 0.961 < 1.5. Re-measured after merging origin/main, which carries #2935's is-synthetic sidecar on top of the same corpus: 2e2150b4f4d64519e3f4c6d7a2c12259178d3117872203c904fab8cba96a694a -> 79dafc369eaeb7183ee8cc1149b1a6c21ad672c7e5b806fe8b0060e5a952c79a; scaling 1.085 < 1.5, capture groups 6255/20005, capture_groups_fp 3560, fixture_count 206 (unchanged by the merge).",
|
||||
"_rebaselined_2900_record_heritage": "#2900 review follow-up: the Java scale unit now includes a record implementing Marker, so the record-declaration @reference.inherits path is fingerprinted and exercised at scale. Prior b29e263524f55151dcb7cfc4c929d3d1d7bb360355cee4e832158f927857f663 -> 36d689c58526c4482fbd701d1d9ca156623a3970734ead145717858712271ab5; scaling 1.042 < 1.5.",
|
||||
|
|
@ -171,7 +174,8 @@
|
|||
"capture_groups_fp": 680
|
||||
},
|
||||
"typescript": {
|
||||
"fingerprint": "fed04ed1d5db112387781e405da208ae6b3ab803889773b0455be96f01b893ff",
|
||||
"fingerprint": "60e75bbe846f7200005f12c4d95c32e82d9d3feae9f2fd594e484670762b82b0",
|
||||
"_rebaselined_3190": "Capture matches now retain explicit ESM export/private evidence, including synthesized default HOCs; CommonJS surfaces remain undecided. Capture group counts unchanged. Scaling budget unchanged.",
|
||||
"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.",
|
||||
|
|
@ -197,7 +201,8 @@
|
|||
"_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",
|
||||
"fingerprint": "b916c7072b30d09b4949803810604830ea1aca1cfdda0d9d9312f7cb22f7a10a",
|
||||
"_rebaselined_3190": "Capture matches now retain explicit ESM export/private evidence, including synthesized default HOCs; CommonJS surfaces remain undecided. Capture group counts unchanged. Scaling budget unchanged.",
|
||||
"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 b59fe8135b6a31a12bc3f872b224054b16592588153ae3661d03958d787c76f3 -> 479927409bbdd9852a36172c8260aa56df260e99129a7a9c20a0d1903dd5538b; scaling 1.050 < 1.5.",
|
||||
"_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: lexical callable bindings, direct-callee argument metadata, and invocation-result suppression. Prior 917a9cd975ba035bdad71fdb70cd72eeddec58c25797e5a1addfa6172808a55c -> b59fe8135b6a31a12bc3f872b224054b16592588153ae3661d03958d787c76f3; scaling 1.093 < 1.5.",
|
||||
|
|
|
|||
37
gitnexus/bench/value-ref-resolution/baseline.json
Normal file
37
gitnexus/bench/value-ref-resolution/baseline.json
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"_what": "Baselines for bench/value-ref-resolution/measure.mjs --check (#3399). Guards the resolved-target SET of `resolveValueRefTarget` and the per-site cost of its four channels across a 4x file-count step, over a synthetic Zig corpus whose shape is fixed in measure.mjs. Per module: 12 CONTAINER registrations (`ElementN.getJ`), 1 NAMESPACE (`dom_utils.compare`), 1 HUB (`hub.compare`), 1 BARE (`register(onTick)`), and 2 declines (a non-callable namespace member `dom_utils.DEFAULT_NS`, a non-callable bare argument `Bridge(ElementN)`) — 17 sites, 15 resolved.",
|
||||
|
||||
"_triage": "READ THIS BEFORE RE-RUNNING. modules, files, value_ref_sites, resolved, declined and fingerprint are DETERMINISTIC: a re-run never changes them, and none may be re-baselined to make CI green — drift means the resolved target set moved, which is a behaviour change to explain. linear_scaling_budget is the only timing arm; runner contention dominates it, so re-run alone on an idle machine and read `reps` in the report before investigating. If exactly one arm fails and it is that one, suspect the machine.",
|
||||
|
||||
"small": {
|
||||
"modules": 80,
|
||||
"files": 240,
|
||||
"value_ref_sites": 1360,
|
||||
"resolved": 1200,
|
||||
"declined": 160,
|
||||
"fingerprint": "4ca2933e25cfa9c84518fca6608c9a6f318f81a1c5bad63cb2f09b7394bfb792"
|
||||
},
|
||||
"large": {
|
||||
"modules": 320,
|
||||
"files": 960,
|
||||
"value_ref_sites": 5440,
|
||||
"resolved": 4800,
|
||||
"declined": 640,
|
||||
"fingerprint": "249266f180388e9f1240a7e81f1141afbc51ef25d5c4b3286a681992dab69e61"
|
||||
},
|
||||
|
||||
"linear_scaling_budget": 1.6,
|
||||
"_linear_scaling_note": "(t_large/t_small)/(320/80) over the per-site resolution loop; ~1.0 is linear. A RATIO rather than a millisecond ceiling, and there is deliberately no ms gate at all: wall-clock measures the runner, and this repo has been bitten twice by a fixed budget (bench/callable-value-flow's widening_overhead failed at 2.07 and 1.975 against 1.9 on a shared runner while the code was correct, both on a sub-11ms measurement). Same reasoning, same shape as bench/parse-dispatch-rounds' pack_scaling_budget. Budget is 1.6 — 1.40x the measured maximum, matching the ~1.5x its siblings use on ratios. What it catches: the four channels each consult a wider index than the last, and channel 4 (CONTAINER) reaches `scopes.qualifiedNames`, a WORKSPACE-WIDE index — keyed it is O(1) per site, scanned it is O(names) per site. Verified load-bearing rather than assumed: replacing `QualifiedNameIndex.get` with a full scan (in gitnexus-shared/dist — the bench resolves the built package, so patching src changes nothing) takes the factor from ~1.0 to 2.07, well clear of this budget. Only 1 of the 17 sites per module reaches that fallback (the `dom_utils.DEFAULT_NS` decline, whose module receiver is not class-like), which is why the signal is 2.07 and not the ~4 a per-site scan on every site would give.",
|
||||
|
||||
"_measured": {
|
||||
"linear_factor": 1.146,
|
||||
"linear_factor_samples": [
|
||||
0.907, 0.969, 0.988, 1.03, 1.043, 1.048, 1.05, 1.051, 1.052, 1.107, 1.123, 1.146
|
||||
],
|
||||
"small_ms": 2.078,
|
||||
"large_ms_4x": 9.197,
|
||||
"us_per_site": "1.36-1.53 small / 1.37-1.69 large",
|
||||
"reps": 15
|
||||
},
|
||||
"_measured_note": "Maxima over 12 runs on a box that was NOT idle, so the spread is an upper bound on real noise. small_ms / large_ms_4x / us_per_site are recorded for context only — NOTHING gates on them, because an absolute millisecond is exactly the gate this file avoids. `us_per_site` staying flat between the two arms is the same property linear_scaling_budget gates, read directly."
|
||||
}
|
||||
303
gitnexus/bench/value-ref-resolution/measure.mjs
Normal file
303
gitnexus/bench/value-ref-resolution/measure.mjs
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build-free scaling and correctness guard for callable-value reference
|
||||
* resolution (#3399).
|
||||
*
|
||||
* WHAT IS GUARDED. `resolveValueRefTarget` is the per-site half of
|
||||
* `emitPropertyDispatchCalls`: for every `value-ref` reference site it names the
|
||||
* callable the source handed over as a value. #3399 replaced a single lexical
|
||||
* walk with four channels, and each one reaches for a wider index than the last:
|
||||
*
|
||||
* 1. BARE `register(onTick)` — `findCallableBindingInScope`
|
||||
* 2. NAMESPACE `bridge.accessor(utils.compare, …)` — the file's own
|
||||
* namespace `@import` edges, then the target's module scope
|
||||
* 3. HUB `bridge.accessor(hub.compare, …)` — the same, through the
|
||||
* finalized/augmented channel a re-export publishes into
|
||||
* 4. CONTAINER `bridge.accessor(Element.getNamespaceUri, …)` —
|
||||
* `findClassBindingInScope`, whose miss path falls back to
|
||||
* `scopes.qualifiedNames`, a WORKSPACE-WIDE index
|
||||
*
|
||||
* Channel 4 is why this bench exists. A workspace-wide index consulted per site
|
||||
* is linear only while the lookup is keyed; make it a scan — or make any of the
|
||||
* three guards around it (`isOwnerNameShadowedBySomethingElse`,
|
||||
* `isNamespaceNameShadowed`, `findOwnedMember`) walk a collection that grows
|
||||
* with the repo — and a registration table that costs O(sites) today costs
|
||||
* O(sites x files) tomorrow. That regression is invisible on a fixture and
|
||||
* expensive on lightpanda-io/browser, where `bridge.{accessor,function,…}`
|
||||
* appears 2,047 times across 257 files.
|
||||
*
|
||||
* HOW. Two corpora of identical shape, 4x apart in file count, and the per-site
|
||||
* resolution loop is the ONLY thing timed — extraction, ownership reconciliation
|
||||
* and finalize are setup. `linear_factor` is `(t_large/t_small) / (N_large/N_small)`:
|
||||
* ~1.0 linear, ~4.x quadratic on this 4x step.
|
||||
*
|
||||
* A RATIO IS THE ONLY TIMING GATE — no millisecond ceiling, deliberately.
|
||||
* `min_ms` and `us_per_site` are printed for context and nothing compares them
|
||||
* to anything: a wall-clock budget measures the runner, and this repo has been
|
||||
* bitten by that twice already (`bench/callable-value-flow`'s `widening_overhead`
|
||||
* failed at 2.07 and 1.975 against a 1.9 budget on a shared runner while the
|
||||
* code was correct, both times on a sub-11ms measurement). Dividing the large
|
||||
* arm by the small one divides the machine out, which is what
|
||||
* `bench/parse-dispatch-rounds` settled on for the same reason.
|
||||
*
|
||||
* A timing gate alone would be satisfied by a fast wrong answer, so the
|
||||
* correctness half is exact and comes first: the site/resolved/declined counts
|
||||
* per arm, plus an order-independent sha256 over every (site -> resolved target)
|
||||
* pair. The fingerprint is a CORRECTNESS gate — drift means the resolved target
|
||||
* set moved, which is a behaviour change to be explained, never re-baselined to
|
||||
* make CI green.
|
||||
*
|
||||
* WHY A ZIG CORPUS for a language-neutral pass. Zig is the only language whose
|
||||
* provider sets `namespaceExportsIncludeImportedNames`, so it is the only one
|
||||
* that can exercise channel 3 at all; and the file-as-struct idiom puts channels
|
||||
* 2 and 4 in one file, which is the shape #3399 was filed over. The corpus also
|
||||
* carries two DECLINE controls — a non-callable namespace member and a
|
||||
* non-callable bare argument — so a change that widened the callable gate would
|
||||
* move `declined` rather than hiding inside the timing.
|
||||
*
|
||||
* Container naming is load-bearing in the corpus: a Zig file-as-struct is minted
|
||||
* under the FILE STEM, so `ElementN.zig` must write `const ElementN = @This();`
|
||||
* and register `ElementN.getJ`. Spelling the alias `Element` instead is the
|
||||
* documented `@This()`-alias limitation, every channel-4 site declines, and the
|
||||
* bench would time a corpus that resolves nothing.
|
||||
*
|
||||
* Usage:
|
||||
* node --import tsx bench/value-ref-resolution/measure.mjs
|
||||
* node --import tsx bench/value-ref-resolution/measure.mjs --check
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
import { extractParsedFile } from '../../src/core/ingestion/scope-extractor-bridge.ts';
|
||||
import { finalizeScopeModel } from '../../src/core/ingestion/finalize-orchestrator.ts';
|
||||
import { createSemanticModel } from '../../src/core/ingestion/model/semantic-model.ts';
|
||||
import { reconcileOwnership } from '../../src/core/ingestion/scope-resolution/pipeline/reconcile-ownership.ts';
|
||||
import { resolveValueRefTarget } from '../../src/core/ingestion/scope-resolution/passes/property-dispatch.ts';
|
||||
import { zigScopeResolver } from '../../src/core/ingestion/languages/zig/scope-resolver.ts';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const SMALL_MODULES = 80;
|
||||
const LARGE_MODULES = 320;
|
||||
/** Registrations per module through the CONTAINER channel. */
|
||||
const ACCESSORS_PER_MODULE = 12;
|
||||
/**
|
||||
* Min-of-N, and N is 15 rather than a handful: `bench/import-target` measured
|
||||
* N=5 tripping its own budget about one run in twenty while N=15 held every
|
||||
* language inside a 1.13-1.26x swing, and `bench/parse-dispatch-rounds` uses 15
|
||||
* on the same grounds. The whole run is ~5 s, so the reps are nearly free.
|
||||
*/
|
||||
const REPS = 15;
|
||||
|
||||
/**
|
||||
* One module = three files, mirroring `test/fixtures/lang-resolution/zig-idioms/
|
||||
* src/webapi/`: a namespace-only helper, a hub that re-exports one of its
|
||||
* members and declares nothing, and a file-as-struct carrying the binding table.
|
||||
*/
|
||||
function moduleFiles(i) {
|
||||
const accessors = Array.from(
|
||||
{ length: ACCESSORS_PER_MODULE },
|
||||
(_, j) => `pub fn get${j}(self: *Element${i}) u8 { return self._n; }`,
|
||||
).join('\n');
|
||||
const registrations = Array.from(
|
||||
{ length: ACCESSORS_PER_MODULE },
|
||||
(_, j) => ` pub const a${j} = bridge.accessor(Element${i}.get${j}, null, .{});`,
|
||||
).join('\n');
|
||||
return [
|
||||
{
|
||||
path: `src/dom_utils${i}.zig`,
|
||||
content: `pub const DEFAULT_NS: u8 = 7;\npub fn compare(a: u8, b: u8) u8 { return if (a > b) a else b; }\n`,
|
||||
},
|
||||
{
|
||||
path: `src/hub${i}.zig`,
|
||||
content: `pub const compare = @import("dom_utils${i}.zig").compare;\n`,
|
||||
},
|
||||
{
|
||||
path: `src/Element${i}.zig`,
|
||||
content: `const Element${i} = @This();
|
||||
const dom_utils = @import("dom_utils${i}.zig");
|
||||
const hub = @import("hub${i}.zig");
|
||||
|
||||
_n: u8 = 0,
|
||||
|
||||
${accessors}
|
||||
|
||||
fn onTick(self: *Element${i}) u8 { return self._n; }
|
||||
|
||||
pub const JsApi = struct {
|
||||
pub const bridge = Bridge(Element${i});
|
||||
${registrations}
|
||||
pub const comparator = bridge.accessor(dom_utils.compare, null, .{});
|
||||
pub const hubbed = bridge.accessor(hub.compare, null, .{});
|
||||
pub const defaultNs = bridge.accessor(dom_utils.DEFAULT_NS, null, .{});
|
||||
};
|
||||
|
||||
pub fn boot() void { register(onTick); }
|
||||
|
||||
pub fn register(comptime f: anytype) void { _ = f; }
|
||||
|
||||
fn Bridge(comptime T: type) type {
|
||||
_ = T;
|
||||
return struct {
|
||||
pub fn accessor(comptime g: anytype, comptime s: anytype, comptime o: anytype) u8 {
|
||||
_ = g;
|
||||
_ = s;
|
||||
_ = o;
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything `resolveValueRefTarget` reads, built the way the pipeline builds it
|
||||
* (`runScopeResolution` phases 1-2): real extraction through the Zig provider,
|
||||
* `populateOwners`, `reconcileOwnership` into the SemanticModel, then finalize.
|
||||
* Hand-assembling the indexes instead would pin this file's idea of their shape
|
||||
* rather than the code's.
|
||||
*/
|
||||
function buildCorpus(modules) {
|
||||
const parsedFiles = [];
|
||||
for (let i = 0; i < modules; i++) {
|
||||
for (const file of moduleFiles(i)) {
|
||||
const parsed = extractParsedFile(zigScopeResolver.languageProvider, file.content, file.path);
|
||||
if (parsed === undefined) {
|
||||
throw new Error(
|
||||
`scope extraction failed for ${file.path} — the vendored tree-sitter-zig ` +
|
||||
`grammar is unavailable on this host, so this bench cannot run`,
|
||||
);
|
||||
}
|
||||
zigScopeResolver.populateOwners(parsed);
|
||||
parsedFiles.push(parsed);
|
||||
}
|
||||
}
|
||||
const model = createSemanticModel();
|
||||
reconcileOwnership(parsedFiles, model);
|
||||
const allFilePaths = new Set(parsedFiles.map((p) => p.filePath));
|
||||
const scopes = finalizeScopeModel(parsedFiles, {
|
||||
hooks: {
|
||||
resolveImportTarget: (raw, from) =>
|
||||
zigScopeResolver.resolveImportTarget(raw, from, allFilePaths),
|
||||
mergeBindings: (existing, incoming, scopeId) =>
|
||||
zigScopeResolver.mergeBindings(existing, incoming, scopeId),
|
||||
expandsWildcardTo: (scope, files) => zigScopeResolver.expandsWildcardTo(scope, files),
|
||||
},
|
||||
});
|
||||
return { parsedFiles, scopes, model };
|
||||
}
|
||||
|
||||
/** The timed loop: every `value-ref` site, resolved exactly as the pass does. */
|
||||
function resolveAll({ parsedFiles, scopes, model }, pairs) {
|
||||
let sites = 0;
|
||||
let resolved = 0;
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const site of parsed.referenceSites) {
|
||||
if (site.kind !== 'value-ref') continue;
|
||||
sites++;
|
||||
const def = resolveValueRefTarget(
|
||||
site,
|
||||
parsed.filePath,
|
||||
scopes,
|
||||
model,
|
||||
// The provider hook the pass is handed in `run.ts`; Zig sets it.
|
||||
zigScopeResolver.namespaceExportsIncludeImportedNames === true,
|
||||
);
|
||||
if (def === undefined) continue;
|
||||
resolved++;
|
||||
pairs?.push(
|
||||
`${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}->${def.nodeId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { sites, resolved };
|
||||
}
|
||||
|
||||
function measure(modules) {
|
||||
const corpus = buildCorpus(modules);
|
||||
const pairs = [];
|
||||
const counts = resolveAll(corpus, pairs);
|
||||
let bestMs = Infinity;
|
||||
for (let i = 0; i < REPS; i++) {
|
||||
const start = performance.now();
|
||||
resolveAll(corpus, undefined);
|
||||
bestMs = Math.min(bestMs, performance.now() - start);
|
||||
}
|
||||
// Order-independent: the walk order is an implementation detail, the resolved
|
||||
// SET is the behaviour.
|
||||
pairs.sort();
|
||||
return {
|
||||
modules,
|
||||
files: corpus.parsedFiles.length,
|
||||
value_ref_sites: counts.sites,
|
||||
resolved: counts.resolved,
|
||||
declined: counts.sites - counts.resolved,
|
||||
fingerprint: createHash('sha256').update(pairs.join('\n')).digest('hex'),
|
||||
min_ms: Number(bestMs.toFixed(3)),
|
||||
us_per_site: Number(((bestMs * 1000) / Math.max(counts.sites, 1)).toFixed(3)),
|
||||
};
|
||||
}
|
||||
|
||||
const report = { small: measure(SMALL_MODULES), large: measure(LARGE_MODULES) };
|
||||
report.reps = REPS;
|
||||
report.workload_ratio = LARGE_MODULES / SMALL_MODULES;
|
||||
report.scaling_ratio = Number(
|
||||
(report.large.min_ms / Math.max(report.small.min_ms, 0.001)).toFixed(3),
|
||||
);
|
||||
report.linear_factor = Number((report.scaling_ratio / report.workload_ratio).toFixed(3));
|
||||
|
||||
if (!process.argv.includes('--check')) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const baseline = JSON.parse(readFileSync(join(HERE, 'baseline.json'), 'utf8'));
|
||||
const failures = [];
|
||||
const requirePositiveNumber = (path, value) => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
failures.push(`${path}: expected a finite positive number, got ${JSON.stringify(value)}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
for (const arm of ['small', 'large']) {
|
||||
// Correctness first: counts AND the resolved-target set.
|
||||
for (const key of [
|
||||
'modules',
|
||||
'files',
|
||||
'value_ref_sites',
|
||||
'resolved',
|
||||
'declined',
|
||||
'fingerprint',
|
||||
]) {
|
||||
if (report[arm][key] !== baseline[arm][key]) {
|
||||
failures.push(
|
||||
`${arm}.${key}: expected ${JSON.stringify(baseline[arm][key])}, got ${JSON.stringify(report[arm][key])}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// `min_ms` / `us_per_site` are reported, never gated — see the header.
|
||||
}
|
||||
if (
|
||||
requirePositiveNumber('linear_scaling_budget', baseline.linear_scaling_budget) &&
|
||||
report.linear_factor > baseline.linear_scaling_budget
|
||||
) {
|
||||
failures.push(
|
||||
`linear_factor ${report.linear_factor} exceeds budget ${baseline.linear_scaling_budget} ` +
|
||||
`(runtime ${report.scaling_ratio}x for ${report.workload_ratio}x work; ` +
|
||||
`~1.0 is linear). Re-run alone on an idle machine before investigating — ` +
|
||||
`this is the only arm a busy runner can move.`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (failures.length > 0) {
|
||||
console.error('[value-ref-resolution --check] FAIL');
|
||||
for (const failure of failures) console.error(` - ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('[value-ref-resolution --check] PASS');
|
||||
6
gitnexus/package-lock.json
generated
6
gitnexus/package-lock.json
generated
|
|
@ -3492,9 +3492,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "7.0.8",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz",
|
||||
"integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==",
|
||||
"version": "7.0.9",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz",
|
||||
"integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
|
|
|
|||
|
|
@ -36,9 +36,9 @@
|
|||
*
|
||||
* Only files heavy enough to matter are listed; everything else is carried by
|
||||
* {@link PER_FILE_OVERHEAD_SEC} alone. These are load-balancing hints, NOT
|
||||
* assertions — no
|
||||
* test asserts a runtime, and drift only makes the split slightly less even, so
|
||||
* a stale entry is harmless and refreshing them is optional. Deliberately not
|
||||
* assertions — no test measures elapsed time against this table. Missing or
|
||||
* stale heavy entries can still overload a shard; refresh them from failed
|
||||
* CI logs and replay that profile in the partition tests. Deliberately not
|
||||
* auto-generated: a committed table is reviewable and works offline, and the
|
||||
* alternative (timing files at CI runtime to decide the split) would make the
|
||||
* partition depend on the very machine load it is trying to protect against.
|
||||
|
|
@ -49,13 +49,15 @@ export const WINDOWS_WEIGHTS_SEC: Readonly<Record<string, number>> = {
|
|||
'test/integration/cli-e2e.test.ts': 621,
|
||||
'test/integration/worker-pool.test.ts': 222,
|
||||
'test/unit/incremental-vector-extension-ordering.test.ts': 87,
|
||||
// ESTIMATE, not a measurement (#2841): this suite drives more full
|
||||
// `runFullAnalysis` cycles than the VECTOR sibling above, so the 8 s
|
||||
// PER_FILE_OVERHEAD floor would badly under-charge it and skew the Windows
|
||||
// split — the failure mode that produced the job timeouts this table exists
|
||||
// to prevent. Scaled from the sibling's measured 87 s by analyze-run count.
|
||||
// Replace with a real figure after the first green Windows matrix run.
|
||||
'test/unit/incremental-index-extension-dml-gate.test.ts': 180,
|
||||
// Measured on Windows in run 34014266125 (#3190, 2026-09-06). These DB
|
||||
// suites landed together on shard 3: the old 180s estimate and missing
|
||||
// entries made a ~27-minute recorded load look like an ~12-minute shard.
|
||||
// Upstream speedups may reduce these figures; retaining conservative weights
|
||||
// keeps the expensive suites distributed without changing the watchdog.
|
||||
'test/unit/incremental-index-extension-dml-gate.test.ts': 414,
|
||||
'test/integration/skills-e2e.test.ts': 444,
|
||||
'test/integration/fts-extension-e2e.test.ts': 146,
|
||||
'test/integration/analyze-wal-checkpoint-failure.test.ts': 86,
|
||||
'test/integration/cli-limit-e2e.test.ts': 75,
|
||||
'test/unit/hooks.test.ts': 26,
|
||||
'test/integration/analyze-heap-oom-e2e.test.ts': 23,
|
||||
|
|
|
|||
|
|
@ -36,6 +36,18 @@ const PLATFORM_LOGIC = [
|
|||
// must exercise the Windows backslash branch, so run it on the OS matrix (#2394).
|
||||
'test/unit/cli-entry.test.ts',
|
||||
'test/unit/platform-capabilities.test.ts',
|
||||
// The tsconfig loader rebases `paths` targets through `path.resolve`, so the
|
||||
// wildcard suffix it must recognise is `/*` on POSIX and `\*` on Windows. It
|
||||
// only looked for `/*`, and every alias target came back as `src*` on
|
||||
// Windows while the Ubuntu run stayed green — so this file has to run where
|
||||
// the separator differs.
|
||||
'test/unit/tsconfig-index.test.ts',
|
||||
// The unit half of the same rebasing rule. Fixture-free and pathApi-injectable
|
||||
// (every separator assertion passes an explicit `path.win32` / `path.posix`),
|
||||
// so unlike the fixture suite above it fails on EVERY runner when the
|
||||
// normalisation is removed rather than only on windows-latest. Registered
|
||||
// beside its fixture sibling so the two halves stay discoverable as one group.
|
||||
'test/unit/tsconfig-rebase-target.test.ts',
|
||||
// The gitnexus-plan safe writer resolves every name through a per-platform
|
||||
// backend: Linux anchors through /proc/self/fd, macOS resolves lexically and
|
||||
// verifies each step against descriptors it holds open. Publication is link(2)
|
||||
|
|
@ -130,6 +142,7 @@ const PLATFORM_LOGIC = [
|
|||
// N-API addon which has known platform-specific behavior (Windows
|
||||
// file-lock lag after close, macOS N-API destructor segfaults)
|
||||
const LBUG_NATIVE = [
|
||||
'test/integration/skip-fts.test.ts',
|
||||
'test/integration/lbug-core-adapter.test.ts',
|
||||
'test/integration/lbug-vector-extension.test.ts',
|
||||
'test/integration/lbug-pool.test.ts',
|
||||
|
|
@ -277,6 +290,11 @@ const NATIVE_ADDON_SMOKE = [
|
|||
// Filesystem behavior tests — exercise operations that vary across
|
||||
// platforms (CRLF, symlinks, permissions, temp dirs)
|
||||
const FILESYSTEM = [
|
||||
// The durable ParsedFile store's prune tolerates a chunk directory it cannot
|
||||
// delete (#3204). The failures that motivate it — held handles, read-only
|
||||
// mounts — are Windows- and macOS-flavored, and the permission-based case
|
||||
// skips itself where chmod cannot block a delete, so run it everywhere.
|
||||
'test/unit/parsedfile-store.test.ts',
|
||||
'test/integration/filesystem-walker.test.ts',
|
||||
'test/integration/watch-filesystem.test.ts',
|
||||
'test/integration/markdown-processor-crlf.test.ts',
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export interface AnalyzeOptions {
|
|||
/** Commander negated flag: false only when --no-parse-cache is passed. */
|
||||
parseCache?: boolean;
|
||||
repairFts?: boolean;
|
||||
skipFts?: boolean;
|
||||
/**
|
||||
* Embedding generation toggle. Commander parses `--embeddings [limit]` as:
|
||||
* - `undefined` when the flag is omitted
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
type AnalyzeOptions as CoreAnalyzeOptions,
|
||||
type AnalyzeResult,
|
||||
} from '../core/run-analyze.js';
|
||||
import { isIndexLockGuardTimeout } from '../storage/index-lock.js';
|
||||
import { getGitRoot, hasGitDir } from '../storage/git.js';
|
||||
import type { AnalyzerRunnerIdentity } from '../storage/repo-manager.js';
|
||||
import { GITNEXUS_DIR } from '../storage/repo-meta.js';
|
||||
|
|
@ -176,6 +177,7 @@ export async function resolveWatchOptions(
|
|||
|
||||
return {
|
||||
pdg: merged.pdg,
|
||||
skipFts: merged.skipFts,
|
||||
branch,
|
||||
registryName: merged.name,
|
||||
allowDuplicateName: merged.allowDuplicateName,
|
||||
|
|
@ -241,6 +243,9 @@ export function shouldStopAfterWatchRefreshFailure(
|
|||
error: unknown,
|
||||
paths: readonly string[],
|
||||
): boolean {
|
||||
if (isIndexLockGuardTimeout(error)) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
paths.length > 0 &&
|
||||
!(error instanceof WatchControlReloadError) &&
|
||||
|
|
@ -516,9 +521,15 @@ export async function watchCommandWithRunnerIdentity(
|
|||
const detail = paths.length > 0 ? ` (${paths.length} queued path(s))` : '';
|
||||
if (shouldStopAfterWatchRefreshFailure(error, paths)) {
|
||||
fatalRefreshError = error;
|
||||
const guardTimeout = isIndexLockGuardTimeout(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.',
|
||||
(guardTimeout
|
||||
? 'Watch mode is stopping because the acquisition guard needs quiesced recovery; see RUNBOOK.md.'
|
||||
: 'Watch mode is stopping because the live index may have been updated in place.'),
|
||||
guardTimeout
|
||||
? { recoveryHint: 'index-lock-guard-recovery', guardPath: error.guardPath }
|
||||
: undefined,
|
||||
);
|
||||
stopWatching();
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import os from 'os';
|
|||
import { spawn } from 'child_process';
|
||||
import v8 from 'v8';
|
||||
import cliProgress from 'cli-progress';
|
||||
import { FTS_DISABLED_MESSAGE, isExplicitFtsDisablement } from '../core/search/fts-policy.js';
|
||||
import { isLbugReady, LbugWipeError } from '../core/lbug/lbug-adapter.js';
|
||||
import { boundedCheckpointBeforeExit } from '../core/lbug/shutdown-helpers.js';
|
||||
import { findUndeclaredRelationPairError } from '../core/lbug/rel-pair-routing.js';
|
||||
|
|
@ -42,7 +43,7 @@ import {
|
|||
selfCommitContextFiles,
|
||||
snapshotSelfCommitSafety,
|
||||
} from '../storage/git.js';
|
||||
import { IndexLockTimeoutError } from '../storage/index-lock.js';
|
||||
import { IndexLockTimeoutError, isIndexLockGuardTimeout } from '../storage/index-lock.js';
|
||||
import {
|
||||
loadAnalyzeConfig,
|
||||
mergeAnalyzeOptions,
|
||||
|
|
@ -1349,6 +1350,7 @@ const analyzeCommandImpl = async (
|
|||
force: options.force || options.skills || options.parseCache === false,
|
||||
useParseCache: options.parseCache !== false,
|
||||
repairFts: options.repairFts,
|
||||
skipFts: options.skipFts,
|
||||
embeddings: embeddingsEnabled,
|
||||
embeddingsNodeLimit,
|
||||
dropEmbeddings: options.dropEmbeddings,
|
||||
|
|
@ -1450,6 +1452,8 @@ const analyzeCommandImpl = async (
|
|||
console.error = origError;
|
||||
bar.stop();
|
||||
console.log(' Already up to date\n');
|
||||
if (isExplicitFtsDisablement(result.ftsSkipReason))
|
||||
console.log(` ${FTS_DISABLED_MESSAGE}\n`);
|
||||
if (runOptions.registryName) {
|
||||
console.log(` Registry name: ${result.repoName}\n`);
|
||||
}
|
||||
|
|
@ -1610,7 +1614,9 @@ const analyzeCommandImpl = async (
|
|||
if (result.ftsSkipped) {
|
||||
// #2658 review L2: a build/verify failure is NOT an extension-unavailable
|
||||
// problem — sending the user to install the extension is the wrong remedy.
|
||||
if (result.ftsSkipReason === 'build-failed') {
|
||||
if (isExplicitFtsDisablement(result.ftsSkipReason)) {
|
||||
console.log(`\n ${FTS_DISABLED_MESSAGE}`);
|
||||
} else if (result.ftsSkipReason === 'build-failed') {
|
||||
console.log(
|
||||
`\n Warning: full-text/BM25 search is disabled — the search index build failed this run.\n` +
|
||||
` The FTS extension is available; rerun \`gitnexus analyze --repair-fts\`. If it persists,\n` +
|
||||
|
|
@ -1669,6 +1675,14 @@ const analyzeCommandImpl = async (
|
|||
// refreshed by the holder — this is a clean, expected condition, not a
|
||||
// crash, so render the message without a stack trace.
|
||||
if (err instanceof IndexLockTimeoutError) {
|
||||
if (isIndexLockGuardTimeout(err)) {
|
||||
cliError(err.message, {
|
||||
recoveryHint: 'index-lock-guard-recovery',
|
||||
guardPath: err.guardPath,
|
||||
});
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
cliError(
|
||||
` Another gitnexus analyze (pid ${err.holder.pid} on ${err.holder.hostname}) is ` +
|
||||
`already refreshing this index and did not finish within the wait window.\n` +
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ export type RecoveryHint =
|
|||
| 'gitnexusrc-invalid'
|
||||
| 'default-branch-invalid'
|
||||
| 'index-lock-timeout'
|
||||
| 'index-lock-guard-recovery'
|
||||
| 'undeclared-relation-pair';
|
||||
|
||||
/**
|
||||
|
|
|
|||
197
gitnexus/src/cli/embeddings-sync.ts
Normal file
197
gitnexus/src/cli/embeddings-sync.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { lstat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { cliInfo } from './cli-message.js';
|
||||
import { getGitRoot } from '../storage/git.js';
|
||||
import { acquireIndexLock, requireExclusiveIndexLock } from '../storage/index-lock.js';
|
||||
import { getStoragePaths, loadMeta, saveMeta } from '../storage/repo-manager.js';
|
||||
import {
|
||||
closeLbug,
|
||||
executeQuery,
|
||||
executeWithReusedStatement,
|
||||
fetchExistingEmbeddingHashes,
|
||||
initLbug,
|
||||
} from '../core/lbug/lbug-adapter.js';
|
||||
import { runEmbeddingPipeline } from '../core/embeddings/embedding-pipeline.js';
|
||||
import { resolveEmbeddingIdentity } from '../core/embeddings/embedding-identity.js';
|
||||
import {
|
||||
decideEmbeddingResume,
|
||||
mintInterruptedCheckpoint,
|
||||
mintPartialCheckpoint,
|
||||
mintUnverifiedCountCheckpoint,
|
||||
type EmbeddingCheckpoint,
|
||||
type EmbeddingCheckpointProgress,
|
||||
} from '../core/embedding-checkpoint.js';
|
||||
import { EMBEDDING_DIMS, embeddingDimsMismatch } from '../core/lbug/schema.js';
|
||||
import type { RepoMeta } from '../storage/repo-meta.js';
|
||||
import {
|
||||
measurePersistedEmbeddingCount,
|
||||
persistedEmbeddingCountOrUndefined,
|
||||
} from '../core/embedding-count.js';
|
||||
|
||||
/** Add missing embeddings directly to a healthy index, checkpointing periodically. */
|
||||
export const embeddingsSyncCommand = async (inputPath?: string): Promise<void> => {
|
||||
const repoPath = inputPath ? path.resolve(inputPath) : getGitRoot(process.cwd());
|
||||
if (!repoPath) throw new Error('Not inside a git repository. Pass a repository path.');
|
||||
|
||||
const { lbugPath, metaPath } = getStoragePaths(repoPath);
|
||||
const metaDir = path.dirname(metaPath);
|
||||
const lock = await acquireIndexLock(metaDir);
|
||||
try {
|
||||
requireExclusiveIndexLock(
|
||||
lock,
|
||||
`Cannot acquire the index lock at ${metaDir}; refusing an unlocked embeddings sync.`,
|
||||
);
|
||||
const meta = await loadMeta(metaDir);
|
||||
if (!meta)
|
||||
throw new Error(`No GitNexus index found for ${repoPath}. Run gitnexus analyze first.`);
|
||||
if (meta.incrementalInProgress) {
|
||||
throw new Error('The structural index is incomplete. Run gitnexus analyze --force first.');
|
||||
}
|
||||
|
||||
let lbugStat;
|
||||
try {
|
||||
lbugStat = await lstat(lbugPath);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`The LadybugDB graph store at ${lbugPath} is missing. Run gitnexus analyze first.`,
|
||||
);
|
||||
}
|
||||
if (!lbugStat.isFile()) {
|
||||
throw new Error(
|
||||
`The LadybugDB graph store at ${lbugPath} is not a usable database file. Run gitnexus analyze first.`,
|
||||
);
|
||||
}
|
||||
|
||||
const identity = resolveEmbeddingIdentity();
|
||||
let forceReembedNodeIds: ReadonlySet<string> | undefined;
|
||||
let resumedFrom: EmbeddingCheckpoint | undefined;
|
||||
if (meta.embeddingCheckpoint) {
|
||||
const checkpoint = meta.embeddingCheckpoint;
|
||||
const decision = decideEmbeddingResume(checkpoint, identity);
|
||||
if (decision.action === 'abort') throw new Error(decision.error);
|
||||
const identityDiffers =
|
||||
checkpoint.provider !== identity.provider ||
|
||||
checkpoint.model !== identity.model ||
|
||||
checkpoint.dimensions !== identity.dimensions;
|
||||
// `abandon` on a foreign identity drops the pending set only. Existing
|
||||
// rows stay; sync would then embed the holes under the new identity and
|
||||
// mix vector spaces. Fail closed — rebuild via analyze.
|
||||
//
|
||||
// Every kind is gated, `unverified-count` included. Exempting it looked
|
||||
// safe because that kind only records "the count could not be read", but
|
||||
// `decideEmbeddingResume` returns `abandon` for it BEFORE comparing
|
||||
// identity, so the exemption was the only thing standing between a
|
||||
// foreign identity and a silently mixed table.
|
||||
if (identityDiffers) {
|
||||
throw new Error(
|
||||
`Cannot sync embeddings: the index checkpoint was written by ${checkpoint.model} ` +
|
||||
`(${checkpoint.provider}) at ${checkpoint.dimensions} dimensions, but this run ` +
|
||||
`resolves ${identity.model} (${identity.provider}) at ${identity.dimensions}. ` +
|
||||
'Run `gitnexus analyze --embeddings --force` to rebuild under the new identity.',
|
||||
);
|
||||
}
|
||||
cliInfo(decision.log);
|
||||
if (decision.action === 'resume') {
|
||||
forceReembedNodeIds = decision.pendingNodeIds;
|
||||
resumedFrom = decision.resumedFrom;
|
||||
}
|
||||
}
|
||||
|
||||
// The vector column is FLOAT[N] fixed when the index was built, and the
|
||||
// pipeline deletes each batch's stale rows immediately before inserting the
|
||||
// replacements — so a width change here deletes rows it cannot re-insert.
|
||||
// `analyze` forces a full rebuild on the same mismatch; only a rebuild can
|
||||
// retype the column, so this writer refuses instead. An absent recorded
|
||||
// width is not a mismatch (see `embeddingDimsMismatch`).
|
||||
if (embeddingDimsMismatch(meta.embeddingDims, EMBEDDING_DIMS)) {
|
||||
throw new Error(
|
||||
`Cannot sync embeddings: this index stores FLOAT[${meta.embeddingDims}] vectors, ` +
|
||||
`but this run embeds at ${EMBEDDING_DIMS} dimensions. ` +
|
||||
'Run `gitnexus analyze --embeddings --force` to rebuild the column at the new width.',
|
||||
);
|
||||
}
|
||||
|
||||
await initLbug(lbugPath);
|
||||
try {
|
||||
const existing = await fetchExistingEmbeddingHashes(executeQuery);
|
||||
let lastPercent = -1;
|
||||
|
||||
const countEmbeddings = async (): Promise<number | undefined> =>
|
||||
persistedEmbeddingCountOrUndefined(await measurePersistedEmbeddingCount(executeQuery));
|
||||
// One write path for every meta update this command makes. The re-read
|
||||
// happens immediately before each save so a concurrent writer's fields
|
||||
// survive. #2790 traced two production drifts to hand-copied writers of
|
||||
// these exact fields, so this file keeps one copy instead of three.
|
||||
const persistMeta = async (patch: (latest: RepoMeta) => Partial<RepoMeta>): Promise<void> => {
|
||||
const latest = (await loadMeta(metaDir)) ?? meta;
|
||||
await saveMeta(metaDir, { ...latest, ...patch(latest) });
|
||||
};
|
||||
const saveCheckpoint = async (
|
||||
checkpoint: EmbeddingCheckpointProgress,
|
||||
pendingNodeIds: string[],
|
||||
embeddings?: number,
|
||||
): Promise<void> =>
|
||||
persistMeta((latest) => ({
|
||||
...(embeddings === undefined ? {} : { stats: { ...latest.stats, embeddings } }),
|
||||
embeddingCheckpoint: mintInterruptedCheckpoint(identity, checkpoint, pendingNodeIds),
|
||||
}));
|
||||
|
||||
cliInfo(`Embedding ${repoPath}`);
|
||||
cliInfo(`Checkpointed nodes already present: ${existing?.size ?? 0}`);
|
||||
|
||||
const result = await runEmbeddingPipeline(
|
||||
executeQuery,
|
||||
executeWithReusedStatement,
|
||||
(progress) => {
|
||||
const percent = Math.floor(progress.percent);
|
||||
if (percent !== lastPercent && (percent % 5 === 0 || percent === 100)) {
|
||||
lastPercent = percent;
|
||||
cliInfo(
|
||||
` ${percent}% — ${progress.nodesProcessed ?? 0}/${progress.totalNodes ?? '?'} nodes`,
|
||||
);
|
||||
}
|
||||
},
|
||||
{},
|
||||
undefined,
|
||||
existing && existing.size ? existing : undefined,
|
||||
{
|
||||
forceReembedNodeIds,
|
||||
onCheckpointWindowStart: async ({ nodeIds, ...checkpoint }) => {
|
||||
await saveCheckpoint(checkpoint, nodeIds);
|
||||
},
|
||||
onCheckpoint: async (checkpoint) => {
|
||||
await saveCheckpoint(checkpoint, [], await countEmbeddings());
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const embeddings = await countEmbeddings();
|
||||
if (embeddings === undefined) {
|
||||
// Keep last-known stats.embeddings. An interrupted window marker would
|
||||
// fail the identity gate on the next run even though this run finished;
|
||||
// unverified-count is the recovery kind that forces a recount (#2790).
|
||||
await persistMeta(() => ({
|
||||
embeddingCheckpoint: result.failedNodeIds.length
|
||||
? mintPartialCheckpoint(identity, result, resumedFrom)
|
||||
: mintUnverifiedCountCheckpoint(identity, {
|
||||
nodesProcessed: result.nodesProcessed,
|
||||
totalNodes: result.nodesProcessed,
|
||||
chunksProcessed: result.chunksProcessed,
|
||||
}),
|
||||
}));
|
||||
throw new Error('Could not verify persisted embedding count.');
|
||||
}
|
||||
await persistMeta((latest) => ({
|
||||
stats: { ...latest.stats, embeddings },
|
||||
embeddingCheckpoint: result.failedNodeIds.length
|
||||
? mintPartialCheckpoint(identity, result, resumedFrom)
|
||||
: undefined,
|
||||
}));
|
||||
cliInfo(`Embeddings ready: ${embeddings}`);
|
||||
} finally {
|
||||
await closeLbug().catch(() => {});
|
||||
}
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
};
|
||||
|
|
@ -609,10 +609,20 @@ export function formatImpactResult(result: any): string {
|
|||
}
|
||||
// #1858 — an interface / indirection boundary on the path makes this a lower
|
||||
// bound; surface it so the count is not read as exhaustive.
|
||||
//
|
||||
// The header names no cause AND asserts no omitted caller, because it cannot
|
||||
// know either. DI / dynamic dispatch was the only producer of `lower-bound`
|
||||
// when this was written; #3399 added callables named in VALUE position (a
|
||||
// registration table, a callback argument), and one of its producers is a
|
||||
// probe that could not RUN — `callableValueReferenceBoundaries` hedges on a
|
||||
// failed query and says in so many words that whether the symbol is
|
||||
// registered is unknown. A header claiming "some callers are not traced"
|
||||
// would there assert an omission nothing established, and would contradict
|
||||
// the bullet printed directly under it. The bullets carry the cause — they
|
||||
// are generated per-cause by `computeEpistemicBoundary` — so the header only
|
||||
// has to say the count is a floor.
|
||||
if (result.epistemic === 'lower-bound') {
|
||||
lines.push(
|
||||
'⚠️ Lower bound — unresolved indirection on the path (callers binding via DI / dynamic dispatch are not traced; actual impact may be higher):',
|
||||
);
|
||||
lines.push('⚠️ Lower bound — impact may be incomplete and actual impact may be higher:');
|
||||
for (const b of result.boundaries || []) lines.push(` • ${b}`);
|
||||
}
|
||||
pushCallgraphRiskLines(lines, result);
|
||||
|
|
|
|||
27
gitnexus/src/cli/group-status-format.ts
Normal file
27
gitnexus/src/cli/group-status-format.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* Rendering for `gitnexus group status` rows, kept out of the Commander action
|
||||
* so it can be tested. Inline, the cell was only reachable by booting a backend
|
||||
* against a real group, which is how `STALE (-1 commits behind)` went unnoticed
|
||||
* (#3256).
|
||||
*/
|
||||
|
||||
/** The fields of a `groupStatus` repo row the index column reads. */
|
||||
export interface GroupRepoIndexRow {
|
||||
indexStale: boolean;
|
||||
commitsBehind?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The index column of a `group status` row. The output is unchanged except
|
||||
* for one case: a count that is not a real count renders as `?`.
|
||||
*
|
||||
* `group/service.ts` has always reported a repo with no recorded commit as
|
||||
* `{ indexStale: true, commitsBehind: -1 }`. The previous `?? '?'` fallback
|
||||
* never caught that, because `??` only falls back on `null` / `undefined`.
|
||||
*/
|
||||
export const formatIndexStatusCell = (row: GroupRepoIndexRow): string => {
|
||||
if (!row.indexStale) return 'OK ';
|
||||
const n = row.commitsBehind;
|
||||
const count = typeof n === 'number' && n >= 0 ? String(n) : '?';
|
||||
return `STALE (${count} commits behind)`;
|
||||
};
|
||||
|
|
@ -4,6 +4,7 @@ import type { Command } from 'commander';
|
|||
import type { RegistryWriteOutcome } from '../core/group/sync.js';
|
||||
import type { MatchType } from '../core/group/types.js';
|
||||
import { logger } from '../core/logger.js';
|
||||
import { formatIndexStatusCell } from './group-status-format.js';
|
||||
|
||||
const _require = createRequire(import.meta.url);
|
||||
const yaml = _require('js-yaml') as typeof import('js-yaml');
|
||||
|
|
@ -161,9 +162,7 @@ export function registerGroupCommands(program: Command): void {
|
|||
console.log(` ${repoPath.padEnd(25)} MISSING (no entry in the registry)`);
|
||||
continue;
|
||||
}
|
||||
const idx = row.indexStale
|
||||
? `STALE (${row.commitsBehind ?? '?'} commits behind)`
|
||||
: 'OK ';
|
||||
const idx = formatIndexStatusCell(row);
|
||||
const ctr = row.contractsStale ? ' CONTRACTS_STALE' : '';
|
||||
console.log(` ${repoPath.padEnd(25)} ${idx}${ctr}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -168,6 +168,8 @@ export const en = {
|
|||
'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.embeddings.sync.description':
|
||||
'Add missing embeddings to an existing index, checkpointing periodically for safe resume',
|
||||
'help.command.index.description':
|
||||
'Register an existing .gitnexus/ folder into the global registry (no re-analysis needed)',
|
||||
'help.command.serve.description': 'Start local HTTP server for web UI connection',
|
||||
|
|
@ -356,5 +358,5 @@ export const en = {
|
|||
'help.identityCache.environment':
|
||||
'\nAnalyzer identity cache:\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir\n Operator-trusted persistent cache for warm cross-process status. The directory must pre-exist, be outside the GitNexus package/build roots, and contain no symlink or junction components. Defaults remain fail-closed on platforms without POSIX ownership APIs.',
|
||||
'help.analyze.environment':
|
||||
'\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir Operator-trusted persistent analyzer identity cache; must pre-exist, be outside package/build roots, and contain no symlink/junction components.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N Max wait at pool shutdown for a retired worker still inside native code (terminated at its next safe point instead of aborting the process). Default 30000.\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning. Default 20000.\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for MCP, 0.5 elsewhere.\n\nFlags override the corresponding env vars when both are provided.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).',
|
||||
'\nEnvironment variables:\n GITNEXUS_NO_GITIGNORE=1 Skip .gitignore parsing (still reads .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N Override large-file skip threshold (KB). Default 512, max 32768.\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir Operator-trusted persistent analyzer identity cache; must pre-exist, be outside package/build roots, and contain no symlink/junction components.\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker idle timeout in milliseconds. Default 30000.\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL auto-checkpoint threshold in bytes (default 67108864 = 64 MiB; -1 keeps Ladybug stock ~16 MiB).\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker job byte budget. Default 8388608.\n GITNEXUS_WORKER_POOL_SIZE=N Parse worker count override. Default cores-1 capped at 16.\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N Concurrent in-flight parse chunks. Default 2.\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N Max replacement spawns per slot before drop. Default 3.\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N Total retry wall-time per job. Default 5x sub-batch timeout.\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N Per-slot deaths to trip circuit breaker. Default max(3, poolSize).\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N Max wait at pool shutdown for a retired worker still inside native code (terminated at its next safe point instead of aborting the process). Default 30000.\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N Per-file wall-clock budget for C++ capture extraction; on breach the file keeps partial captures with a warning. Default 20000.\n GITNEXUS_EMBEDDING_THREADS=N Limit local ONNX CPU threads for --embeddings.\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 Retry per-attempt HTTP embedding timeouts through GITNEXUS_EMBEDDING_MAX_ATTEMPTS (default off; timeouts stay terminal).\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N Max embedding chunks for exact-scan fallback. Default 10000.\n GITNEXUS_VECTOR_MAX_DISTANCE=N Max accepted semantic/vector cosine distance (0 < N <= 2; higher values clamp to 2). Default 0.6 for MCP, 0.5 elsewhere.\n\nFlags override the corresponding env vars when both are provided.\n\nTip: `.gitnexusignore` supports `.gitignore`-style negation. Add e.g.\n `!__tests__/` to index a directory that is auto-filtered by default (#771).',
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -164,6 +164,8 @@ export const zhCN = {
|
|||
'error.watch.ambiguous':
|
||||
'`gitnexus watch` 含义不明确。\n 本地工作区增量索引:gitnexus analyze --watch\n 定时远程 clone/pull 并分析:gitnexus auto-sync start\n',
|
||||
'help.command.analyze.description': '索引仓库(完整分析)',
|
||||
'help.command.embeddings.sync.description':
|
||||
'向现有索引添加缺失的嵌入,并定期保存检查点以安全续跑',
|
||||
'help.command.index.description': '将现有 .gitnexus/ 文件夹注册到全局注册表(无需重新分析)',
|
||||
'help.command.serve.description': '启动供 Web UI 连接的本地 HTTP 服务器',
|
||||
'help.command.mcp.description':
|
||||
|
|
@ -329,5 +331,5 @@ export const zhCN = {
|
|||
'help.identityCache.environment':
|
||||
'\n分析器身份缓存:\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir\n 由操作员明确信任的持久缓存,用于跨进程快速查询状态。目录必须预先存在、位于 GitNexus 包/构建根目录之外,且路径中不得包含符号链接或 junction。缺少 POSIX 所有权 API 的平台默认保持故障关闭。',
|
||||
'help.analyze.environment':
|
||||
'\n环境变量:\n GITNEXUS_NO_GITIGNORE=1 跳过 .gitignore 解析(仍读取 .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N 覆盖大文件跳过阈值(KB)。默认 512,最大 32768。\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir 由操作员明确信任的持久分析器身份缓存;目录必须预先存在、位于包/构建根目录之外,且路径中不得包含符号链接或 junction。\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker 空闲超时(毫秒)。默认 30000。\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL 自动 checkpoint 阈值(字节,默认 67108864 = 64 MiB;-1 保持 Ladybug 默认约 16 MiB)。\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker 作业字节预算。默认 8388608。\n GITNEXUS_WORKER_POOL_SIZE=N 解析 worker 数量覆盖值。默认 cores-1,最多 16。\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N 并发进行中的解析分块数。默认 2。\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N 每个 slot 丢弃前允许的最大替换进程数。默认 3。\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N 每个作业的总重试墙钟时间。默认 5 倍子批次超时。\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N 每个 slot 触发熔断的死亡次数。默认 max(3, poolSize)。\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N 线程池关闭时等待仍在原生代码中的已退役 worker 的最长时间(到达安全点后再终止,避免进程级 abort)。默认 30000。\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N C++ 捕获提取的每文件墙钟预算;超出后该文件保留部分捕获并输出警告。默认 20000。\n GITNEXUS_EMBEDDING_THREADS=N 限制 --embeddings 的本地 ONNX CPU 线程数。\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N exact-scan 回退的最大嵌入分块数。默认 10000。\n GITNEXUS_VECTOR_MAX_DISTANCE=N 语义/向量搜索接受的最大余弦距离(0 < N <= 2;超出则钳制为 2)。MCP 默认 0.6,其他路径默认 0.5。\n\n当参数和对应环境变量同时提供时,参数优先。\n\n提示:`.gitnexusignore` 支持 `.gitignore` 风格的取反。比如添加\n `!__tests__/` 可以索引默认自动过滤的目录(#771)。',
|
||||
'\n环境变量:\n GITNEXUS_NO_GITIGNORE=1 跳过 .gitignore 解析(仍读取 .gitnexusignore)\n GITNEXUS_MAX_FILE_SIZE=N 覆盖大文件跳过阈值(KB)。默认 512,最大 32768。\n GITNEXUS_ANALYZER_IDENTITY_CACHE_DIR=/absolute/protected/dir 由操作员明确信任的持久分析器身份缓存;目录必须预先存在、位于包/构建根目录之外,且路径中不得包含符号链接或 junction。\n GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=N Worker 空闲超时(毫秒)。默认 30000。\n GITNEXUS_WAL_CHECKPOINT_THRESHOLD=N LadybugDB WAL 自动 checkpoint 阈值(字节,默认 67108864 = 64 MiB;-1 保持 Ladybug 默认约 16 MiB)。\n GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES=N Worker 作业字节预算。默认 8388608。\n GITNEXUS_WORKER_POOL_SIZE=N 解析 worker 数量覆盖值。默认 cores-1,最多 16。\n GITNEXUS_PARSE_CHUNK_CONCURRENCY=N 并发进行中的解析分块数。默认 2。\n GITNEXUS_WORKER_MAX_RESPAWNS_PER_SLOT=N 每个 slot 丢弃前允许的最大替换进程数。默认 3。\n GITNEXUS_WORKER_MAX_CUMULATIVE_TIMEOUT_MS=N 每个作业的总重试墙钟时间。默认 5 倍子批次超时。\n GITNEXUS_WORKER_CONSECUTIVE_FAILURE_THRESHOLD=N 每个 slot 触发熔断的死亡次数。默认 max(3, poolSize)。\n GITNEXUS_WORKER_SHUTDOWN_DRAIN_MS=N 线程池关闭时等待仍在原生代码中的已退役 worker 的最长时间(到达安全点后再终止,避免进程级 abort)。默认 30000。\n GITNEXUS_CPP_CAPTURE_BUDGET_MS=N C++ 捕获提取的每文件墙钟预算;超出后该文件保留部分捕获并输出警告。默认 20000。\n GITNEXUS_EMBEDDING_THREADS=N 限制 --embeddings 的本地 ONNX CPU 线程数。\n GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1 将单次 HTTP 嵌入超时纳入 GITNEXUS_EMBEDDING_MAX_ATTEMPTS 重试(默认关闭,超时仍为终止错误)。\n GITNEXUS_SEMANTIC_EXACT_SCAN_LIMIT=N exact-scan 回退的最大嵌入分块数。默认 10000。\n GITNEXUS_VECTOR_MAX_DISTANCE=N 语义/向量搜索接受的最大余弦距离(0 < N <= 2;超出则钳制为 2)。MCP 默认 0.6,其他路径默认 0.5。\n\n当参数和对应环境变量同时提供时,参数优先。\n\n提示:`.gitnexusignore` 支持 `.gitignore` 风格的取反。比如添加\n `!__tests__/` 可以索引默认自动过滤的目录(#771)。',
|
||||
} satisfies EnglishMessages;
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ program
|
|||
'Re-parse every source file instead of replaying cached parser output',
|
||||
)
|
||||
.option('--repair-fts', 'Repair/rebuild search FTS indexes without full re-analysis')
|
||||
.option('--skip-fts', 'Skip FTS extension loading and keyword search indexes')
|
||||
.option(
|
||||
'--embeddings [limit]',
|
||||
'Enable embedding generation for semantic search (off by default). ' +
|
||||
|
|
@ -304,15 +305,13 @@ program
|
|||
.description('Install the latest published GitNexus globally (`npm i -g gitnexus@<x.y.z>`).')
|
||||
.action(createLazyAction(() => import('./update.js'), 'updateCommand'));
|
||||
|
||||
program
|
||||
const embeddings = program
|
||||
.command('embeddings')
|
||||
.description('Manage the on-demand local embedding runtime')
|
||||
.description(t('help.command.embeddings.description'));
|
||||
|
||||
embeddings
|
||||
.command('install')
|
||||
.description(
|
||||
'Install the local embedding stack (@huggingface/transformers + onnxruntime-node) on demand. ' +
|
||||
'Heals installs where npm skipped the optional packages (e.g. behind an HTTP proxy, #2370). ' +
|
||||
'Downloads only from your configured npm registry — mirrors and proxies apply.',
|
||||
)
|
||||
.description(t('help.command.embeddings.install.description'))
|
||||
.option(
|
||||
'--cuda',
|
||||
"Also download the CUDA GPU binaries (runs onnxruntime-node's NuGet postinstall; " +
|
||||
|
|
@ -321,6 +320,12 @@ program
|
|||
.option('--force', 'Install into the runtime prefix even when the stack already resolves')
|
||||
.action(createLazyAction(() => import('./embeddings.js'), 'embeddingsInstallCommand'));
|
||||
|
||||
embeddings
|
||||
.command('sync [path]')
|
||||
.description(t('help.command.embeddings.sync.description'))
|
||||
.addHelpText('after', () => t('help.analyze.environment'))
|
||||
.action(createLbugLazyAction(() => import('./embeddings-sync.js'), 'embeddingsSyncCommand'));
|
||||
|
||||
program
|
||||
.command('clean')
|
||||
.description('Delete GitNexus index for current repo')
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
resolveAnalyzerRunnerIdentity,
|
||||
} from '../core/analyzer-identity.js';
|
||||
import { getIndexIncompleteReasons } from '../core/index-freshness.js';
|
||||
import { getFtsDisabledReason, FTS_DISABLED_MESSAGE } from '../core/search/fts-policy.js';
|
||||
import { detectIndexContentDrift, type IndexContentDrift } from '../core/index-content-drift.js';
|
||||
import { t } from './i18n/index.js';
|
||||
|
||||
|
|
@ -186,6 +187,7 @@ export const statusCommand = async (options: StatusOptions = {}) => {
|
|||
index: {
|
||||
indexedAt: activeMeta.indexedAt,
|
||||
commit: activeMeta.lastCommit,
|
||||
...(activeMeta.capabilities ? { capabilities: activeMeta.capabilities } : {}),
|
||||
runnerIdentity: activeMeta.runnerIdentity ?? null,
|
||||
runnerIdentityStatus: runnerIdentityIsCurrent ? 'current' : 'stale-or-unknown',
|
||||
incompleteReasons,
|
||||
|
|
@ -211,6 +213,7 @@ export const statusCommand = async (options: StatusOptions = {}) => {
|
|||
console.log(`${t('status.indexed')}: ${new Date(activeMeta.indexedAt).toLocaleString()}`);
|
||||
console.log(`${t('status.indexedCommit')}: ${activeMeta.lastCommit?.slice(0, 7)}`);
|
||||
console.log(`${t('status.currentCommit')}: ${currentCommit?.slice(0, 7)}`);
|
||||
if (getFtsDisabledReason(activeMeta.capabilities?.fts)) console.log(FTS_DISABLED_MESSAGE);
|
||||
// Emit the complete, versioned receipt as JSON so humans can inspect it and
|
||||
// automation can compare it without reverse-engineering a display string.
|
||||
// `null` is the backward-compatible signal for pre-receipt metadata.
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
*/
|
||||
|
||||
import { chunk } from '../../lib/utils.js';
|
||||
import { parseTruthyEnv } from '../ingestion/utils/env.js';
|
||||
import {
|
||||
CircuitOpenError,
|
||||
ResilientFetchExhaustedError,
|
||||
|
|
@ -39,6 +40,7 @@ interface HttpConfig {
|
|||
retryCapMs: number;
|
||||
minIntervalMs: number;
|
||||
timeoutMs: number;
|
||||
retryTimeouts: boolean;
|
||||
requestDimensions?: number;
|
||||
}
|
||||
|
||||
|
|
@ -202,6 +204,12 @@ const readConfig = (): HttpConfig | null => {
|
|||
DEFAULT_HTTP_TIMEOUT_MS,
|
||||
MAX_HTTP_TIMEOUT_MS,
|
||||
),
|
||||
// A boolean toggle, so it takes the repo's truthy convention (`1`/`true`/
|
||||
// `yes`) and falls back to the documented default on anything else. The
|
||||
// integer parser this used throws on a non-digit, which turned the
|
||||
// conventional `=true` into a hard failure of every embedding call rather
|
||||
// than either enabling the flag or leaving it off.
|
||||
retryTimeouts: parseTruthyEnv(process.env.GITNEXUS_EMBEDDING_RETRY_TIMEOUTS),
|
||||
requestDimensions,
|
||||
};
|
||||
};
|
||||
|
|
@ -338,6 +346,36 @@ class RetryableEmbeddingBodyError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
class RetryableEmbeddingTimeoutError extends Error {
|
||||
constructor(
|
||||
readonly timeoutMs: number,
|
||||
options?: { cause?: unknown },
|
||||
) {
|
||||
super(
|
||||
`Embedding request timed out after ${timeoutMs}ms`,
|
||||
options?.cause !== undefined ? { cause: options.cause } : undefined,
|
||||
);
|
||||
this.name = 'RetryableEmbeddingTimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-wrap an opt-in TimeoutError so `resilientFetch` retries it. Abort stays terminal. */
|
||||
const throwIfRetryableTimeout = (
|
||||
err: unknown,
|
||||
retryTimeouts: boolean,
|
||||
callerAborted: boolean | undefined,
|
||||
timeoutMs: number,
|
||||
): void => {
|
||||
if (
|
||||
retryTimeouts &&
|
||||
!callerAborted &&
|
||||
isTerminalNetworkError(err) &&
|
||||
err.name === 'TimeoutError'
|
||||
) {
|
||||
throw new RetryableEmbeddingTimeoutError(timeoutMs, { cause: err });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the message for a 2xx body carrying the wrong number of vectors.
|
||||
*
|
||||
|
|
@ -384,6 +422,7 @@ const httpEmbedBatch = async (
|
|||
retryCapMs = HTTP_RETRY_CAP_MS,
|
||||
minIntervalMs = 0,
|
||||
timeoutMs = DEFAULT_HTTP_TIMEOUT_MS,
|
||||
retryTimeouts = false,
|
||||
): Promise<EmbeddingItem[]> => {
|
||||
const requestBody: { input: string[]; model: string; dimensions?: number } = {
|
||||
input: batch,
|
||||
|
|
@ -428,7 +467,13 @@ const httpEmbedBatch = async (
|
|||
const signal = requestOptions.signal
|
||||
? AbortSignal.any([requestOptions.signal, timeoutSignal])
|
||||
: timeoutSignal;
|
||||
const attemptResp = await globalThis.fetch(input, { ...init, signal });
|
||||
let attemptResp: Response;
|
||||
try {
|
||||
attemptResp = await globalThis.fetch(input, { ...init, signal });
|
||||
} catch (err) {
|
||||
throwIfRetryableTimeout(err, retryTimeouts, requestOptions.signal?.aborted, timeoutMs);
|
||||
throw err;
|
||||
}
|
||||
// Non-OK bodies are none of our business: hand the response straight
|
||||
// back so `resilientFetch` keeps classifying 4xx/5xx/429 unchanged.
|
||||
if (!attemptResp.ok) return attemptResp;
|
||||
|
|
@ -447,15 +492,19 @@ const httpEmbedBatch = async (
|
|||
// Not every `.json()` rejection is a parse error: the per-attempt
|
||||
// signal (`AbortSignal.any([caller, AbortSignal.timeout(...)])`) is
|
||||
// wired to the body stream, so a stalled body rejects with the abort
|
||||
// reason. Re-raise those untouched — `isTerminalNetworkError` is
|
||||
// `resilientFetch`'s own predicate, so this test agrees with
|
||||
// `classifyOutcome` by construction. Wrapping one would flip its
|
||||
// verdict from `terminal-network` (returned without retry AND
|
||||
// without touching the breaker, via `recordNeutral()`) to
|
||||
// `retryable-network` (retried, then `breaker.recordFailure()`): the
|
||||
// same timeout would take 3 attempts instead of 1, count toward the
|
||||
// process-global `embeddings-http` breaker, and reach the operator as
|
||||
// "unparseable response" so they never reach for the timeout knob.
|
||||
// reason. Re-raise AbortError (and TimeoutError when retry is off)
|
||||
// untouched — `isTerminalNetworkError` is `resilientFetch`'s own
|
||||
// predicate, so this test agrees with `classifyOutcome` by
|
||||
// construction. Wrapping one would flip its verdict from
|
||||
// `terminal-network` (returned without retry AND without touching
|
||||
// the breaker, via `recordNeutral()`) to `retryable-network`
|
||||
// (retried, then `breaker.recordFailure()`): the same timeout would
|
||||
// take 3 attempts instead of 1, count toward the process-global
|
||||
// `embeddings-http` breaker, and reach the operator as "unparseable
|
||||
// response" so they never reach for the timeout knob.
|
||||
// Opt-in `GITNEXUS_EMBEDDING_RETRY_TIMEOUTS=1` is the exception:
|
||||
// TimeoutError is re-wrapped so the existing retry loop can retry it.
|
||||
throwIfRetryableTimeout(err, retryTimeouts, requestOptions.signal?.aborted, timeoutMs);
|
||||
if (isTerminalNetworkError(err)) throw err;
|
||||
throw new RetryableEmbeddingBodyError(unparseableMessage(), { cause: err });
|
||||
}
|
||||
|
|
@ -503,6 +552,12 @@ const httpEmbedBatch = async (
|
|||
if (err instanceof RetryableEmbeddingBodyError) {
|
||||
throw new HttpEmbeddingError(err.terminalMessage, { cause: err.cause });
|
||||
}
|
||||
if (err instanceof RetryableEmbeddingTimeoutError) {
|
||||
throw new HttpEmbeddingError(
|
||||
`${err.message} after ${maxAttempts} attempt(s) (${safeUrl(url)}, batch ${batchIndex})`,
|
||||
{ cause: err.cause },
|
||||
);
|
||||
}
|
||||
if (err instanceof CircuitOpenError) {
|
||||
throw new HttpEmbeddingError(
|
||||
`Embedding endpoint circuit open (${safeUrl(url)}, batch ${batchIndex}): retry in ${Math.ceil(err.retryAfterMs / 1000)}s`,
|
||||
|
|
@ -580,6 +635,7 @@ export const httpEmbed = async (
|
|||
config.retryCapMs,
|
||||
config.minIntervalMs,
|
||||
config.timeoutMs,
|
||||
config.retryTimeouts,
|
||||
);
|
||||
|
||||
// Defensive backstop, deliberately kept: `httpEmbedBatch` now rejects a
|
||||
|
|
@ -655,6 +711,7 @@ export const httpEmbedQuery = async (
|
|||
config.retryCapMs,
|
||||
config.minIntervalMs,
|
||||
config.timeoutMs,
|
||||
config.retryTimeouts,
|
||||
);
|
||||
// Defensive backstop like the `httpEmbed` one above: an empty `data` array is
|
||||
// now a cardinality mismatch (0 vectors for 1 text) rejected and retried
|
||||
|
|
|
|||
|
|
@ -8,19 +8,86 @@ import { promisify } from 'node:util';
|
|||
import path from 'path';
|
||||
import { readRegistry, type RegistryEntry, type CwdMatch } from '../storage/repo-manager.js';
|
||||
import { findGitRootByDotGit, getCurrentCommit, getRemoteUrl } from '../storage/git.js';
|
||||
import type { StalenessInfo } from './staleness-status.js';
|
||||
|
||||
// The status/payload types and helpers live in the pure `staleness-status.ts`
|
||||
// (#3256); the types are re-exported here for existing importers.
|
||||
export type { StalenessInfo, StalenessStatus } from './staleness-status.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface StalenessInfo {
|
||||
isStale: boolean;
|
||||
commitsBehind: number;
|
||||
hint?: string;
|
||||
}
|
||||
/**
|
||||
* Ceiling for one `git rev-list` staleness probe. Generous for the local
|
||||
* history walk this is, and short enough that an unresponsive working tree
|
||||
* degrades to "not stale" quickly rather than holding a request open.
|
||||
*/
|
||||
const STALENESS_TIMEOUT_MS = 5_000;
|
||||
|
||||
const behindHint = (n: number): string =>
|
||||
`⚠️ Index is ${n} commit${n > 1 ? 's' : ''} behind HEAD. Run analyze tool to update.`;
|
||||
|
||||
// Says only what a failed count plus a resolved HEAD establish: the index is not
|
||||
// at HEAD and the gap is uncountable. Reaching here does NOT prove the indexed
|
||||
// commit left history — that is the usual cause (a pruned `fetch --depth 1`),
|
||||
// but any other `rev-list` failure lands here too, so the cause is hedged.
|
||||
const DIVERGED_HINT =
|
||||
"⚠️ Index is not at HEAD and the commit gap could not be counted — the recorded commit may no longer be in this clone's history. Run analyze tool to update.";
|
||||
|
||||
const unknown = (): StalenessInfo => ({ isStale: false, commitsBehind: 0, status: 'unknown' });
|
||||
|
||||
const fromCount = (commitsBehind: number): StalenessInfo =>
|
||||
commitsBehind > 0
|
||||
? { isStale: true, commitsBehind, hint: behindHint(commitsBehind), status: 'behind' }
|
||||
: { isStale: false, commitsBehind: 0, status: 'current' };
|
||||
|
||||
/**
|
||||
* `rev-list` could not answer. Asking for HEAD alone needs no history walk and
|
||||
* still separates all three answers: HEAD unreadable is `unknown`, HEAD past the
|
||||
* indexed commit is `diverged`, and HEAD still *at* it is `current` — the ref
|
||||
* prints the indexed SHA, so the index is at HEAD however `rev-list` failed. The
|
||||
* historical fail-open values are kept either way; only `status` differs.
|
||||
*/
|
||||
const fromHead = (head: string | null, lastCommit: string): StalenessInfo => {
|
||||
if (!head) return unknown();
|
||||
if (head === lastCommit) return { isStale: false, commitsBehind: 0, status: 'current' };
|
||||
return { isStale: false, commitsBehind: 0, hint: DIVERGED_HINT, status: 'diverged' };
|
||||
};
|
||||
|
||||
const readHeadSync = (repoPath: string): string | null => {
|
||||
try {
|
||||
return (
|
||||
execFileSync('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: repoPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
}).trim() || null
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const readHeadAsync = async (repoPath: string): Promise<string | null> => {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: repoPath,
|
||||
encoding: 'utf-8',
|
||||
windowsHide: true,
|
||||
timeout: STALENESS_TIMEOUT_MS,
|
||||
});
|
||||
return stdout.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check how many commits the index is behind HEAD (synchronous; uses git CLI).
|
||||
*/
|
||||
export function checkStaleness(repoPath: string, lastCommit: string): StalenessInfo {
|
||||
// No recorded commit is not "at HEAD": there is nothing to measure against.
|
||||
if (!lastCommit) return unknown();
|
||||
try {
|
||||
const result = execFileSync('git', ['rev-list', '--count', `${lastCommit}..HEAD`], {
|
||||
cwd: repoPath,
|
||||
|
|
@ -29,19 +96,9 @@ export function checkStaleness(repoPath: string, lastCommit: string): StalenessI
|
|||
windowsHide: true,
|
||||
}).trim();
|
||||
|
||||
const commitsBehind = parseInt(result, 10) || 0;
|
||||
|
||||
if (commitsBehind > 0) {
|
||||
return {
|
||||
isStale: true,
|
||||
commitsBehind,
|
||||
hint: `⚠️ Index is ${commitsBehind} commit${commitsBehind > 1 ? 's' : ''} behind HEAD. Run analyze tool to update.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { isStale: false, commitsBehind: 0 };
|
||||
return fromCount(parseInt(result, 10) || 0);
|
||||
} catch {
|
||||
return { isStale: false, commitsBehind: 0 };
|
||||
return fromHead(readHeadSync(repoPath), lastCommit);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -54,6 +111,7 @@ export async function checkStalenessAsync(
|
|||
repoPath: string,
|
||||
lastCommit: string,
|
||||
): Promise<StalenessInfo> {
|
||||
if (!lastCommit) return unknown();
|
||||
try {
|
||||
// Note: promisified execFile captures stdout/stderr by default (no stdio option needed,
|
||||
// unlike the sync variant which requires explicit stdio: ['pipe','pipe','pipe']).
|
||||
|
|
@ -61,21 +119,22 @@ export async function checkStalenessAsync(
|
|||
cwd: repoPath,
|
||||
encoding: 'utf-8',
|
||||
windowsHide: true,
|
||||
// The catch below fails closed on every git ERROR, but a hang is not an
|
||||
// error — it is silence, and without a bound this await never settles.
|
||||
// A working tree on a disconnected network mount or behind a stuck lock
|
||||
// does exactly that, and `/api/repos` fans this out once per registered
|
||||
// repo, so one unreachable mount could hold the whole listing open
|
||||
// (#3232 review). The timeout kills the child and rejects, and the catch
|
||||
// below reports it as `unknown` — still the fail-closed `isStale: false`.
|
||||
timeout: STALENESS_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const commitsBehind = parseInt(stdout.trim(), 10) || 0;
|
||||
|
||||
if (commitsBehind > 0) {
|
||||
return {
|
||||
isStale: true,
|
||||
commitsBehind,
|
||||
hint: `⚠️ Index is ${commitsBehind} commit${commitsBehind > 1 ? 's' : ''} behind HEAD. Run analyze tool to update.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { isStale: false, commitsBehind: 0 };
|
||||
} catch {
|
||||
return { isStale: false, commitsBehind: 0 };
|
||||
return fromCount(parseInt(stdout.trim(), 10) || 0);
|
||||
} catch (err) {
|
||||
// A rev-list that timed out means the working tree is not answering. Asking
|
||||
// it again for HEAD would only double the bound #3232 put on a hung mount.
|
||||
if ((err as { killed?: boolean }).killed) return unknown();
|
||||
return fromHead(await readHeadAsync(repoPath), lastCommit);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
38
gitnexus/src/core/graph/edge-reasons.ts
Normal file
38
gitnexus/src/core/graph/edge-reasons.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* Edge `reason` values that mark a CALLS edge as a HEURISTIC GUESS rather than
|
||||
* a resolution.
|
||||
*
|
||||
* The distinction exists because an edge's `confidence` number cannot carry it.
|
||||
* `GLOBAL_NAME_FALLBACK_REASON` edges are emitted at exactly 0.5 — the same
|
||||
* number as `process-processor`'s `MIN_TRACE_CONFIDENCE` and
|
||||
* `community-processor`'s `MIN_CONFIDENCE_LARGE` — so a `confidence < 0.5`
|
||||
* gate does NOT exclude them. Anything that must exclude guesses has to read
|
||||
* the reason, which is why `KnowledgeGraph.forEachRelationshipFields` passes it
|
||||
* and why `GraphEmitSink` retains a reason column.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The target was chosen because its SIMPLE NAME is unique in the workspace —
|
||||
* not because any import, scope chain, or type binding led to it.
|
||||
*
|
||||
* Emitted by the unique-simple-name tiers of the free-call fallback
|
||||
* (`pickUniqueGlobalCallable` and constructor-form `pickUniqueGlobalClass`),
|
||||
* and only for the languages that opt into `allowGlobalFreeCallFallback`. It
|
||||
* is a name collision away from being wrong and must never be presented as
|
||||
* an import-resolved edge: a reader who cannot tell the two apart has no way
|
||||
* to discount the guess.
|
||||
*/
|
||||
export const GLOBAL_NAME_FALLBACK_REASON = 'global-name-fallback';
|
||||
|
||||
/**
|
||||
* Reasons excluded from process tracing and large-graph community detection.
|
||||
*
|
||||
* Both walks exist to describe how the program actually flows. Seeding a flow
|
||||
* from a unique-name guess produces a confident-looking trace through code that
|
||||
* may never call each other, which is worse than a shorter honest trace.
|
||||
*/
|
||||
const HEURISTIC_EDGE_REASONS: ReadonlySet<string> = new Set([GLOBAL_NAME_FALLBACK_REASON]);
|
||||
|
||||
/** True when this edge's target was guessed by name rather than resolved. */
|
||||
export const isHeuristicEdgeReason = (reason: string): boolean =>
|
||||
HEURISTIC_EDGE_REASONS.has(reason);
|
||||
|
|
@ -163,9 +163,17 @@ export const createKnowledgeGraph = (): KnowledgeGraph => {
|
|||
relationshipMap.forEach(fn);
|
||||
},
|
||||
forEachRelationshipFields(
|
||||
fn: (sourceId: string, targetId: string, type: RelationshipType, confidence: number) => void,
|
||||
fn: (
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
type: RelationshipType,
|
||||
confidence: number,
|
||||
reason: string,
|
||||
) => void,
|
||||
) {
|
||||
relationshipMap.forEach((rel) => fn(rel.sourceId, rel.targetId, rel.type, rel.confidence));
|
||||
relationshipMap.forEach((rel) =>
|
||||
fn(rel.sourceId, rel.targetId, rel.type, rel.confidence, rel.reason),
|
||||
);
|
||||
},
|
||||
getNode: (id: string) => nodeMap.get(id),
|
||||
|
||||
|
|
|
|||
|
|
@ -31,14 +31,26 @@ export interface KnowledgeGraph {
|
|||
* Zero-allocation relationship scan: fields, not objects (#2680).
|
||||
*
|
||||
* The whole-graph scans (the local-symbol pruner, community detection,
|
||||
* process extraction) read only these four fields, and materializing a
|
||||
* process extraction) read only these five fields, and materializing a
|
||||
* `GraphRelationship` per edge just to read them dominates iteration cost once
|
||||
* relationships are held columnar — measured at ~90 ms per analyze on a
|
||||
* million-edge graph. Prefer this over `forEachRelationship` in any pass that
|
||||
* walks every edge and needs no other field.
|
||||
*
|
||||
* `reason` is passed because confidence alone cannot separate a heuristic
|
||||
* name guess from a resolved edge that happens to sit at the same number:
|
||||
* the global-name fallback emits at exactly the process/community threshold
|
||||
* (0.5), so the walks that must exclude it have to read the reason. See
|
||||
* `GraphEmitSink`'s reason column, added for this consumer.
|
||||
*/
|
||||
forEachRelationshipFields: (
|
||||
fn: (sourceId: string, targetId: string, type: RelationshipType, confidence: number) => void,
|
||||
fn: (
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
type: RelationshipType,
|
||||
confidence: number,
|
||||
reason: string,
|
||||
) => void,
|
||||
) => void;
|
||||
getNode: (id: string) => GraphNode | undefined;
|
||||
nodeCount: number;
|
||||
|
|
|
|||
|
|
@ -92,6 +92,134 @@ function unquote(text: string): string | null {
|
|||
return value.includes('${') ? null : value;
|
||||
}
|
||||
|
||||
const SIMPLE_TEMPLATE_ESCAPES: Record<string, string> = {
|
||||
b: '\b',
|
||||
f: '\f',
|
||||
n: '\n',
|
||||
r: '\r',
|
||||
t: '\t',
|
||||
v: '\v',
|
||||
'0': '\0',
|
||||
"'": "'",
|
||||
'"': '"',
|
||||
'\\': '\\',
|
||||
'`': '`',
|
||||
};
|
||||
|
||||
/** Graph Method `startLine` is the 0-based wrapper row (see line-base.ts). */
|
||||
function providerMemberStartLine(member: Parser.SyntaxNode): number {
|
||||
// Class-field arrows are `@declaration.property`; parse-worker falls back to
|
||||
// the `public_field_definition` wrapper (decorator row when it is a child),
|
||||
// not the initializer. Do not probe the `value` child.
|
||||
return member.startPosition.row;
|
||||
}
|
||||
|
||||
/** tree-sitter `escape_sequence.text` is the source spelling (`\\n`), not the JS value. */
|
||||
function decodeEscapeSequence(text: string): string | null {
|
||||
if (!text.startsWith('\\') || text.length < 2) return null;
|
||||
const escaped = text.slice(1);
|
||||
// Tagged-template cooked value: LineContinuation is empty; `\8`/`\9` and
|
||||
// LegacyOctalEscapeSequence (`\1`–`\7`, `\00`…) make cooked undefined.
|
||||
if (/^[\n\r\u2028\u2029]$/.test(escaped) || escaped === '\r\n') return '';
|
||||
if (/^[1-9]$/.test(escaped) || /^[0-7]{2,3}$/.test(escaped)) return null;
|
||||
if (escaped.length === 1) return SIMPLE_TEMPLATE_ESCAPES[escaped] ?? escaped;
|
||||
if (escaped[0] === 'x' && /^[0-9A-Fa-f]{2}$/.test(escaped.slice(1))) {
|
||||
return String.fromCharCode(Number.parseInt(escaped.slice(1), 16));
|
||||
}
|
||||
if (escaped.startsWith('u{') && escaped.endsWith('}')) {
|
||||
const hex = escaped.slice(2, -1);
|
||||
if (!/^[0-9A-Fa-f]{1,6}$/.test(hex)) return null;
|
||||
const codePoint = Number.parseInt(hex, 16);
|
||||
if (codePoint > 0x10ffff) return null;
|
||||
return String.fromCodePoint(codePoint);
|
||||
}
|
||||
if (escaped[0] === 'u' && /^[0-9A-Fa-f]{4}$/.test(escaped.slice(1))) {
|
||||
return String.fromCharCode(Number.parseInt(escaped.slice(1), 16));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function substitutionIdentifier(substitution: Parser.SyntaxNode): string | null {
|
||||
if (substitution.namedChildren.length !== 1) return null;
|
||||
const expr = unwrapExpression(substitution.namedChildren[0]);
|
||||
return expr.type === 'identifier' ? expr.text : null;
|
||||
}
|
||||
|
||||
function isGraphqlTagCall(call: Parser.SyntaxNode): boolean {
|
||||
const callee = call.childForFieldName('function');
|
||||
return callee?.type === 'identifier' && callee.text === 'gql';
|
||||
}
|
||||
|
||||
function pascalCaseGraphqlName(name: string): string {
|
||||
return name
|
||||
.split(/[^A-Za-z0-9]+/)
|
||||
.filter((part) => part.length > 0)
|
||||
.map((part) => `${part[0]!.toUpperCase()}${part.slice(1)}`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
type InterpolationCache = Map<string, string | null>;
|
||||
|
||||
function uniqueStaticSource(
|
||||
name: string,
|
||||
declarators: GeneratedSymbolIndex,
|
||||
resolving: Set<string>,
|
||||
cache: InterpolationCache,
|
||||
): string | null {
|
||||
if (cache.has(name)) return cache.get(name) ?? null;
|
||||
if (resolving.has(name) || resolving.size >= MAX_GRAPHQL_TRAVERSAL_DEPTH) return null;
|
||||
const values = declarators.get(name) ?? [];
|
||||
if (values.length === 0) {
|
||||
cache.set(name, null);
|
||||
return null;
|
||||
}
|
||||
resolving.add(name);
|
||||
const sources = new Set<string>();
|
||||
for (const value of values) {
|
||||
const source = staticGraphqlSource(value, declarators, resolving, cache);
|
||||
// A dynamic or unprovable sibling makes the name ambiguous — do not pick
|
||||
// the one static spelling and ignore the rest.
|
||||
if (source === null) {
|
||||
resolving.delete(name);
|
||||
cache.set(name, null);
|
||||
return null;
|
||||
}
|
||||
sources.add(source);
|
||||
}
|
||||
resolving.delete(name);
|
||||
const unique = sources.size === 1 ? [...sources][0]! : null;
|
||||
cache.set(name, unique);
|
||||
return unique;
|
||||
}
|
||||
|
||||
function interpolatedTemplateSource(
|
||||
template: Parser.SyntaxNode,
|
||||
declarators: GeneratedSymbolIndex,
|
||||
resolving: Set<string>,
|
||||
cache: InterpolationCache,
|
||||
): string | null {
|
||||
let out = '';
|
||||
for (const child of template.namedChildren) {
|
||||
if (child.type === 'string_fragment') {
|
||||
out += child.text;
|
||||
} else if (child.type === 'escape_sequence') {
|
||||
const decoded = decodeEscapeSequence(child.text);
|
||||
if (decoded === null) return null;
|
||||
out += decoded;
|
||||
} else if (child.type === 'template_substitution') {
|
||||
const name = substitutionIdentifier(child);
|
||||
if (!name) return null;
|
||||
const inlined = uniqueStaticSource(name, declarators, resolving, cache);
|
||||
if (inlined === null) return null;
|
||||
out += inlined;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
if (out.length > MAX_GRAPHQL_TOKENS) return null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function unwrapExpression(node: Parser.SyntaxNode): Parser.SyntaxNode {
|
||||
let current = node;
|
||||
while (
|
||||
|
|
@ -214,7 +342,12 @@ function parsedDocumentProof(
|
|||
return false;
|
||||
}
|
||||
|
||||
function staticGraphqlSource(initializer: Parser.SyntaxNode): string | null {
|
||||
function staticGraphqlSource(
|
||||
initializer: Parser.SyntaxNode,
|
||||
declarators?: GeneratedSymbolIndex,
|
||||
resolving: Set<string> = new Set(),
|
||||
cache: InterpolationCache = new Map(),
|
||||
): string | null {
|
||||
const value = unwrapExpression(initializer);
|
||||
if (value.type === 'string') {
|
||||
if (value.text.startsWith('"')) {
|
||||
|
|
@ -226,11 +359,24 @@ function staticGraphqlSource(initializer: Parser.SyntaxNode): string | null {
|
|||
}
|
||||
return unquote(value.text);
|
||||
}
|
||||
if (value.type === 'template_string') return unquote(value.text);
|
||||
if (value.type === 'template_string') {
|
||||
const hasSubstitution = value.namedChildren.some(
|
||||
(child) => child.type === 'template_substitution',
|
||||
);
|
||||
if (!hasSubstitution) return unquote(value.text);
|
||||
return declarators ? interpolatedTemplateSource(value, declarators, resolving, cache) : null;
|
||||
}
|
||||
|
||||
if (value.type === 'call_expression') {
|
||||
const template = value.namedChildren.find((child) => child.type === 'template_string');
|
||||
return template ? unquote(template.text) : null;
|
||||
if (!template) return null;
|
||||
const hasSubstitution = template.namedChildren.some(
|
||||
(child) => child.type === 'template_substitution',
|
||||
);
|
||||
// Interpolated reconstruction is the cooked template. Only `gql` is
|
||||
// treated as preserving that source; String.raw / unknown tags stay fail-closed.
|
||||
if (hasSubstitution && !isGraphqlTagCall(value)) return null;
|
||||
return staticGraphqlSource(template, declarators, resolving, cache);
|
||||
}
|
||||
|
||||
if (value.type !== 'new_expression') return null;
|
||||
|
|
@ -238,7 +384,7 @@ function staticGraphqlSource(initializer: Parser.SyntaxNode): string | null {
|
|||
if (!constructor || !constructor.text.endsWith('TypedDocumentString')) return null;
|
||||
const args = value.childForFieldName('arguments');
|
||||
const first = args?.namedChildren[0];
|
||||
return first ? staticGraphqlSource(first) : null;
|
||||
return first ? staticGraphqlSource(first, declarators, resolving, cache) : null;
|
||||
}
|
||||
|
||||
export function hasGeneratedDocumentProof(
|
||||
|
|
@ -246,9 +392,10 @@ export function hasGeneratedDocumentProof(
|
|||
operationKind: GraphqlOperationKind,
|
||||
operationName: string,
|
||||
requiredFields: readonly string[],
|
||||
declarators?: GeneratedSymbolIndex,
|
||||
): boolean {
|
||||
if (!withinGeneratedAstBudget(initializer)) return false;
|
||||
const staticSource = staticGraphqlSource(initializer);
|
||||
const staticSource = staticGraphqlSource(initializer, declarators);
|
||||
if (staticSource !== null) {
|
||||
return parsedDocumentProof(staticSource, operationKind, operationName, requiredFields);
|
||||
}
|
||||
|
|
@ -430,7 +577,10 @@ function rootFields(
|
|||
|
||||
function generatedCandidates(operation: OperationDefinitionNode): string[] {
|
||||
const name = operation.name?.value;
|
||||
return name ? [`${name}Document`] : [];
|
||||
if (!name) return [];
|
||||
const exact = `${name}Document`;
|
||||
const pascal = `${pascalCaseGraphqlName(name)}Document`;
|
||||
return exact === pascal ? [exact] : [exact, pascal];
|
||||
}
|
||||
|
||||
async function generatedDocumentMatches(
|
||||
|
|
@ -450,7 +600,13 @@ async function generatedDocumentMatches(
|
|||
const index = await pendingIndex;
|
||||
const values = index?.get(symbol.name) ?? [];
|
||||
return values.some((value) =>
|
||||
hasGeneratedDocumentProof(value, operationKind, operationName, requiredFields),
|
||||
hasGeneratedDocumentProof(
|
||||
value,
|
||||
operationKind,
|
||||
operationName,
|
||||
requiredFields,
|
||||
index ?? undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -593,11 +749,7 @@ export class GraphqlExtractor implements ContractExtractor {
|
|||
await dbExecutor(RESOLVE_METHOD_QUERY, {
|
||||
name: methodName,
|
||||
filePath,
|
||||
startLine:
|
||||
member.type === 'public_field_definition'
|
||||
? (member.childForFieldName('value')?.startPosition.row ??
|
||||
member.startPosition.row) + 1
|
||||
: member.startPosition.row + 1,
|
||||
startLine: providerMemberStartLine(member),
|
||||
}),
|
||||
);
|
||||
if (!symbol) continue;
|
||||
|
|
|
|||
|
|
@ -2,13 +2,22 @@ import fs from 'node:fs/promises';
|
|||
import path from 'node:path';
|
||||
import type { CypherExecutor } from '../contract-extractor.js';
|
||||
import type { GroupManifestLink, ContractRole } from '../types.js';
|
||||
import { getPythonParser } from '../../ingestion/languages/python/query.js';
|
||||
import { getMaxFileSizeBytes } from '../../ingestion/utils/max-file-size.js';
|
||||
import {
|
||||
ParseTimeoutError,
|
||||
parseHadErrors,
|
||||
parseSourceSafe,
|
||||
} from '../../tree-sitter/safe-parse.js';
|
||||
import {
|
||||
shouldIgnorePath,
|
||||
loadIgnoreRules,
|
||||
isHardcodedIgnoredDirectoryAtPath,
|
||||
} from '../../../config/ignore-service.js';
|
||||
import { readSafeBounded } from './fs-utils.js';
|
||||
|
||||
import { logger } from '../../logger.js';
|
||||
|
||||
interface PythonPackageMeta {
|
||||
name: string;
|
||||
importName: string;
|
||||
|
|
@ -18,9 +27,8 @@ interface PythonPackageMeta {
|
|||
}
|
||||
|
||||
interface ImportedSymbol {
|
||||
packageName: string;
|
||||
importName: string;
|
||||
symbolName: string;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
async function parsePythonManifest(
|
||||
|
|
@ -51,7 +59,7 @@ function parsePyproject(
|
|||
const nameMatch = content.match(/^\[project\]\s*\n(?:[^\n\[]*\n)*?name\s*=\s*"([^"]+)"/m);
|
||||
if (!nameMatch) return null;
|
||||
const name = nameMatch[1];
|
||||
const importName = name.replace(/-/g, '_');
|
||||
const importName = toPythonImportName(name);
|
||||
|
||||
const deps: string[] = [];
|
||||
const depsMatch = content.match(/^\[project\]\s*\n[\s\S]*?dependencies\s*=\s*\[([\s\S]*?)\]/m);
|
||||
|
|
@ -79,7 +87,7 @@ function parseSetupPy(
|
|||
const nameMatch = content.match(/name\s*=\s*['"]([^'"]+)['"]/);
|
||||
if (!nameMatch) return null;
|
||||
const name = nameMatch[1];
|
||||
const importName = name.replace(/-/g, '_');
|
||||
const importName = toPythonImportName(name);
|
||||
|
||||
const deps: string[] = [];
|
||||
const installMatch = content.match(/install_requires\s*=\s*\[([\s\S]*?)\]/);
|
||||
|
|
@ -97,51 +105,95 @@ function extractPepName(spec: string): string {
|
|||
return spec.split(/[><=!~;\[]/)[0].trim();
|
||||
}
|
||||
|
||||
function toPythonImportName(name: string): string {
|
||||
return name.replace(/-/g, '_');
|
||||
}
|
||||
|
||||
function collectFromImportNames(
|
||||
node: {
|
||||
namedChildCount: number;
|
||||
namedChild(index: number): {
|
||||
id: number;
|
||||
type: string;
|
||||
text: string;
|
||||
childForFieldName(name: string): { text: string } | null;
|
||||
} | null;
|
||||
},
|
||||
moduleNodeId: number,
|
||||
): string[] {
|
||||
const symbols: string[] = [];
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (!child || child.id === moduleNodeId) continue;
|
||||
if (child.type === 'dotted_name') {
|
||||
symbols.push(child.text);
|
||||
} else if (child.type === 'aliased_import') {
|
||||
const imported = child.childForFieldName('name');
|
||||
if (imported) symbols.push(imported.text);
|
||||
}
|
||||
}
|
||||
return symbols;
|
||||
}
|
||||
|
||||
/** Cheap skip before tree-sitter: real `from <mod>` tokens, not `fromage`. */
|
||||
function hasFromImportToken(content: string): boolean {
|
||||
return /(?:^|[\s;])from\s+\S/.test(content);
|
||||
}
|
||||
|
||||
async function scanPythonImports(
|
||||
repoPath: string,
|
||||
knownPackages: Map<string, string>,
|
||||
knownPackages: Set<string>,
|
||||
): Promise<ImportedSymbol[]> {
|
||||
const results: ImportedSymbol[] = [];
|
||||
const sourceFiles = await findPythonFiles(repoPath);
|
||||
const parser = getPythonParser();
|
||||
const maxFileSizeBytes = getMaxFileSizeBytes();
|
||||
|
||||
for (const relFile of sourceFiles) {
|
||||
const absPath = path.join(repoPath, relFile);
|
||||
let content: string;
|
||||
const content = await readSafeBounded(repoPath, relFile, maxFileSizeBytes);
|
||||
if (content == null || !hasFromImportToken(content)) continue;
|
||||
|
||||
let tree;
|
||||
try {
|
||||
content = await fs.readFile(absPath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
tree = parseSourceSafe(parser, content, undefined, undefined, relFile);
|
||||
} catch (error) {
|
||||
if (error instanceof ParseTimeoutError) {
|
||||
logger.warn(
|
||||
{ file: relFile },
|
||||
'python-workspace-extractor: parse timed out, skipping file',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const degraded = parseHadErrors(tree);
|
||||
const visit = (node: (typeof tree)['rootNode']): void => {
|
||||
if (node.type !== 'import_from_statement') {
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child) visit(child);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// from <pkg> import Foo, Bar
|
||||
// from <pkg>.module import Foo
|
||||
const fromImportRegex = /^from\s+(\w[\w.]*)\s+import\s+(.+)/gm;
|
||||
let match;
|
||||
while ((match = fromImportRegex.exec(content)) !== null) {
|
||||
const modulePath = match[1];
|
||||
const importClause = match[2];
|
||||
// Error recovery can promote unclosed-docstring lookalikes into real
|
||||
// import_from_statement nodes. Keep column-0 imports; drop indented ones
|
||||
// on a degraded tree so function-local discovery stays on clean parses.
|
||||
if (degraded && node.startPosition.column > 0) return;
|
||||
|
||||
const moduleNode = node.childForFieldName('module_name');
|
||||
const modulePath = moduleNode?.text;
|
||||
if (!modulePath) return;
|
||||
const rootModule = modulePath.split('.')[0];
|
||||
const originalName = knownPackages.get(rootModule);
|
||||
if (!originalName) continue;
|
||||
if (!knownPackages.has(rootModule)) return;
|
||||
|
||||
if (importClause.trim() === '(') continue;
|
||||
|
||||
const symbols = importClause
|
||||
.replace(/\(|\)/g, '')
|
||||
.split(',')
|
||||
.map((s) => {
|
||||
const trimmed = s.trim();
|
||||
const asMatch = trimmed.match(/^(\S+)\s+as\s+/);
|
||||
return asMatch ? asMatch[1] : trimmed;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
for (const sym of symbols) {
|
||||
for (const sym of collectFromImportNames(node, moduleNode.id)) {
|
||||
if (isPascalCase(sym)) {
|
||||
results.push({ packageName: originalName, symbolName: sym, filePath: relFile });
|
||||
results.push({ importName: rootModule, symbolName: sym });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(tree.rootNode);
|
||||
}
|
||||
|
||||
return results;
|
||||
|
|
@ -224,21 +276,14 @@ export async function extractPythonWorkspaceLinks(
|
|||
const seen = new Set<string>();
|
||||
|
||||
for (const [, pkg] of packagesByGroupPath) {
|
||||
const normalizedDeps = pkg.workspaceDeps.map((d) => d.replace(/-/g, '_'));
|
||||
const normalizedDeps = pkg.workspaceDeps.map(toPythonImportName);
|
||||
const groupPkgDeps = normalizedDeps.filter((d) => packagesByImportName.has(d));
|
||||
if (groupPkgDeps.length === 0) continue;
|
||||
|
||||
const knownPackages = new Map<string, string>();
|
||||
for (const dep of groupPkgDeps) {
|
||||
const meta = packagesByImportName.get(dep);
|
||||
if (meta) knownPackages.set(dep, meta.name);
|
||||
}
|
||||
|
||||
const imports = await scanPythonImports(pkg.repoPath, knownPackages);
|
||||
const imports = await scanPythonImports(pkg.repoPath, new Set(groupPkgDeps));
|
||||
|
||||
for (const imp of imports) {
|
||||
const providerImportName = imp.packageName.replace(/-/g, '_');
|
||||
const providerPkg = packagesByImportName.get(providerImportName);
|
||||
const providerPkg = packagesByImportName.get(imp.importName);
|
||||
if (!providerPkg) continue;
|
||||
|
||||
const qualifiedContract = `${providerPkg.name}::${imp.symbolName}`;
|
||||
|
|
|
|||
|
|
@ -30,7 +30,9 @@
|
|||
* distinct ways it can fail to be protected; all three throw
|
||||
* {@link GroupSyncLockError}:
|
||||
*
|
||||
* 1. TIMEOUT — the holder is still alive when the ceiling elapses.
|
||||
* 1. TIMEOUT — a live holder still held the lock when the ceiling
|
||||
* elapsed, or an unrecoverable acquisition/reclaim guard leftover
|
||||
* exhausted the guard wait (see RUNBOOK.md).
|
||||
* 2. LOCK-FREE DEGRADATION — `acquireIndexLock` answers a read-only or
|
||||
* permission-denied filesystem with a no-op handle that is byte-identical
|
||||
* to a real one at the API boundary. That is a deliberate tolerance for
|
||||
|
|
@ -72,6 +74,7 @@ import path from 'node:path';
|
|||
import {
|
||||
acquireIndexLock,
|
||||
IndexLockTimeoutError,
|
||||
isIndexLockGuardTimeout,
|
||||
type IndexLockHandle,
|
||||
} from '../../storage/index-lock.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
|
@ -123,7 +126,7 @@ export const withGroupSyncLock = async <T>(
|
|||
): Promise<T> => {
|
||||
let handle: IndexLockHandle;
|
||||
// The wrapper times the acquisition itself. `IndexLockTimeoutError` carries
|
||||
// `holder` and `holderKnown` and nothing else — the elapsed wait exists only
|
||||
// holder/guard identity but no elapsed-time field — the elapsed wait exists only
|
||||
// inside its inherited message string, so the figure has to be measured here
|
||||
// to be reported without that message. `Date.now()` matches how the primitive
|
||||
// measures its own wait.
|
||||
|
|
@ -147,6 +150,16 @@ export const withGroupSyncLock = async <T>(
|
|||
// socket backend the holder is not identifiable at all. Re-word it around
|
||||
// what IS known: which group, which operation, and how long we waited.
|
||||
if (err instanceof IndexLockTimeoutError) {
|
||||
if (isIndexLockGuardTimeout(err)) {
|
||||
throw new GroupSyncLockError(
|
||||
'timeout',
|
||||
groupDir,
|
||||
`Could not acquire the sync lock for group "${path.basename(groupDir)}" ` +
|
||||
`(${getGroupSyncLockDir(groupDir)}). ${err.message} ` +
|
||||
`Nothing was written and this group was not synced.`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
throw new GroupSyncLockError(
|
||||
'timeout',
|
||||
groupDir,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { checkStaleness } from '../git-staleness.js';
|
||||
import { stalenessStatus, type StalenessInfo, type StalenessStatus } from '../staleness-status.js';
|
||||
import {
|
||||
canonicalizePath,
|
||||
loadMeta,
|
||||
|
|
@ -843,6 +844,17 @@ export class GroupService {
|
|||
/** Set only when `unresolvable`; says what could not be resolved. */
|
||||
unresolvableReason?: string;
|
||||
commitsBehind?: number;
|
||||
/**
|
||||
* What the staleness check could establish (#3256). Additive:
|
||||
* `indexStale` and `commitsBehind` keep their meaning. Two rows report
|
||||
* `unknown` and they do NOT agree on those two fields, so read them
|
||||
* together with this one: no commit was recorded (`indexStale: true`,
|
||||
* `commitsBehind: -1`, the sentinel that case has always used), or the
|
||||
* git probe could not answer (`indexStale: false`, `commitsBehind: 0`,
|
||||
* straight from `checkStaleness`). MCP/HTTP `stalenessPayload` reports
|
||||
* neither number — it omits `commitsBehind` for `unknown` entirely.
|
||||
*/
|
||||
status?: StalenessStatus;
|
||||
}
|
||||
> = {};
|
||||
|
||||
|
|
@ -872,9 +884,9 @@ export class GroupService {
|
|||
const meta: Partial<Pick<RepoMeta, 'lastCommit' | 'indexedAt'>> =
|
||||
(await loadMeta(repoObj.storagePath)) ?? {};
|
||||
|
||||
const staleness = meta.lastCommit
|
||||
const staleness: StalenessInfo = meta.lastCommit
|
||||
? checkStaleness(repoObj.repoPath, meta.lastCommit)
|
||||
: { isStale: true, commitsBehind: -1 };
|
||||
: { isStale: true, commitsBehind: -1, status: 'unknown' };
|
||||
|
||||
const snapshot = registry?.repoSnapshots?.[repoPath];
|
||||
const contractsStale =
|
||||
|
|
@ -886,6 +898,7 @@ export class GroupService {
|
|||
missing: false,
|
||||
unresolvable: false,
|
||||
commitsBehind: staleness.commitsBehind,
|
||||
status: stalenessStatus(staleness),
|
||||
};
|
||||
} catch (err) {
|
||||
// The registry read succeeded, so its answer about this row is
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@
|
|||
* does its own extraction, and writes directly to the graph.
|
||||
*
|
||||
* Pipeline:
|
||||
* 1. Separate programs from copybooks
|
||||
* 2. Build copybook map (name -> content)
|
||||
* 1. Separate programs from copybooks and JCL
|
||||
* 2. Index file content by path for COPY expansion
|
||||
* 3. For each program: expand COPY statements, then run regex extraction
|
||||
* 4. Map CobolRegexResults to graph nodes and relationships
|
||||
* 5. Optionally process JCL files for job-step cross-references
|
||||
|
|
@ -25,6 +25,7 @@ import {
|
|||
} from './cobol/cobol-preprocessor.js';
|
||||
import { expandCopies } from './cobol/cobol-copy-expander.js';
|
||||
import { processJclFiles } from './cobol/jcl-processor.js';
|
||||
import { resolveCobolCopyTarget } from './languages/cobol/copy-target.js';
|
||||
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
|
|
@ -123,38 +124,23 @@ export const processCobol = (
|
|||
|
||||
// ── 1. Separate programs, copybooks, and JCL ───────────────────────
|
||||
const programs: CobolFile[] = [];
|
||||
const copybooks: CobolFile[] = [];
|
||||
const jclFiles: CobolFile[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const ext = path.extname(file.path).toLowerCase();
|
||||
if (JCL_EXTENSIONS.has(ext)) {
|
||||
jclFiles.push(file);
|
||||
} else if (isCopybook(file.path)) {
|
||||
copybooks.push(file);
|
||||
} else if (COBOL_EXTENSIONS.has(ext)) {
|
||||
} else if (COBOL_EXTENSIONS.has(ext) && !isCopybook(file.path)) {
|
||||
programs.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Build copybook map (uppercase name -> content) ──────────────
|
||||
const copybookMap = new Map<string, { content: string; path: string }>();
|
||||
for (const cb of copybooks) {
|
||||
const name = path.basename(cb.path, path.extname(cb.path)).toUpperCase();
|
||||
copybookMap.set(name, { content: cb.content, path: cb.path });
|
||||
}
|
||||
|
||||
// Build reverse lookup: path -> content for O(1) readCopy
|
||||
// Path → content for every COBOL/JCL file we ingested. Resolution picks
|
||||
// the path (#2967 / census lockstep); this map only supplies the body.
|
||||
const copybookByPath = new Map<string, string>();
|
||||
for (const [, entry] of copybookMap) {
|
||||
copybookByPath.set(entry.path, entry.content);
|
||||
for (const f of files) {
|
||||
copybookByPath.set(f.path, f.content);
|
||||
}
|
||||
|
||||
// Resolve and read callbacks for expandCopies
|
||||
const resolveCopy = (name: string): string | null => {
|
||||
const entry = copybookMap.get(name.toUpperCase());
|
||||
return entry ? entry.path : null;
|
||||
};
|
||||
// Memoize preprocessed copybook content for the duration of this
|
||||
// processCobol call. A single copybook is COPYed by many programs (and at
|
||||
// many COPY sites within a program); without this cache
|
||||
|
|
@ -196,6 +182,11 @@ export const processCobol = (
|
|||
// Preprocess: clean patch markers
|
||||
const cleaned = preprocessCobolSource(file.content);
|
||||
|
||||
// Per-program so COPY EXTERNAL in src/PROG.cbl cannot land on
|
||||
// vendor/EXTERNAL.cpy when a copybooks/ dir is present (#2967).
|
||||
const resolveCopy = (name: string): string | null =>
|
||||
resolveCobolCopyTarget(name, file.path, allPathSet);
|
||||
|
||||
// Expand COPY statements
|
||||
const { expandedContent, copyResolutions } = expandCopies(
|
||||
cleaned,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { dirname, resolve } from 'node:path';
|
|||
import { Worker } from 'node:worker_threads';
|
||||
import type { GraphNode, NodeLabel } from 'gitnexus-shared';
|
||||
import { KnowledgeGraph } from '../graph/types.js';
|
||||
import { isHeuristicEdgeReason } from '../graph/edge-reasons.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
|
@ -131,6 +132,18 @@ const LEIDEN_TIMEOUT_MS = 60_000;
|
|||
const ICEBUG_TIMEOUT_MS = 60_000;
|
||||
const MIN_CONFIDENCE_LARGE = 0.5;
|
||||
|
||||
/**
|
||||
* Whether a large graph's projection may include this edge.
|
||||
*
|
||||
* The confidence floor alone lets every global-name-fallback edge through — it
|
||||
* is emitted at exactly `MIN_CONFIDENCE_LARGE`, so `confidence <` never
|
||||
* excludes it. Clustering on unique-name guesses fuses unrelated areas into one
|
||||
* community, which is precisely the noise the large-graph floor exists to
|
||||
* remove, so the reason is checked too — see `graph/edge-reasons.ts`.
|
||||
*/
|
||||
const isLargeGraphEligible = (confidence: number, reason: string): boolean =>
|
||||
confidence >= MIN_CONFIDENCE_LARGE && !isHeuristicEdgeReason(reason);
|
||||
|
||||
export const resolveCommunityDetectionEngine = (
|
||||
raw = process.env[COMMUNITY_ENGINE_ENV],
|
||||
): CommunityDetectionEngine => {
|
||||
|
|
@ -300,11 +313,16 @@ export const buildCommunityProjection = (knowledgeGraph: KnowledgeGraph): Commun
|
|||
const connectedNodes = new Set<string>();
|
||||
const nodeDegree = new Map<string, number>();
|
||||
|
||||
// Field-wise scan (#2680): this walks every edge and reads only these four,
|
||||
// Field-wise scan (#2680): this walks every edge and reads only these five,
|
||||
// so taking objects would allocate one per edge for nothing.
|
||||
knowledgeGraph.forEachRelationshipFields((sourceId, targetId, type, confidence) => {
|
||||
knowledgeGraph.forEachRelationshipFields((sourceId, targetId, type, confidence, reason) => {
|
||||
if (!isClusteringRelationship(type) || sourceId === targetId) return;
|
||||
if (isLarge && confidence < MIN_CONFIDENCE_LARGE) return;
|
||||
// A name-guessed edge must not join two nodes into a community at ANY graph
|
||||
// size — the rationale in `graph/edge-reasons.ts` is about what the edge
|
||||
// claims, not about how many symbols surround it. Process tracing applies
|
||||
// the same reason gate unconditionally (`process-processor.ts`).
|
||||
if (isHeuristicEdgeReason(reason)) return;
|
||||
if (isLarge && !isLargeGraphEligible(confidence, reason)) return;
|
||||
|
||||
connectedNodes.add(sourceId);
|
||||
connectedNodes.add(targetId);
|
||||
|
|
@ -340,9 +358,10 @@ export const buildCommunityProjection = (knowledgeGraph: KnowledgeGraph): Commun
|
|||
const seenEdges = new Set<string>();
|
||||
const edges: Array<readonly [number, number]> = [];
|
||||
|
||||
knowledgeGraph.forEachRelationshipFields((sourceId, targetId, type, confidence) => {
|
||||
knowledgeGraph.forEachRelationshipFields((sourceId, targetId, type, confidence, reason) => {
|
||||
if (!isClusteringRelationship(type) || sourceId === targetId) return;
|
||||
if (isLarge && confidence < MIN_CONFIDENCE_LARGE) return;
|
||||
if (isHeuristicEdgeReason(reason)) return;
|
||||
if (isLarge && !isLargeGraphEligible(confidence, reason)) return;
|
||||
|
||||
const sourceIndex = nodeIndexById.get(sourceId);
|
||||
const targetIndex = nodeIndexById.get(targetId);
|
||||
|
|
|
|||
|
|
@ -200,8 +200,11 @@ function collectReferenceSites(parsedFiles: readonly ParsedFile[]) {
|
|||
*/
|
||||
function withDefaultHooks(partial: Partial<FinalizeHooks>): FinalizeHooks {
|
||||
return {
|
||||
importsBindAtLexicalScope: partial.importsBindAtLexicalScope === true,
|
||||
resolveImportTarget: partial.resolveImportTarget ?? (() => null),
|
||||
isNamespaceImport: partial.isNamespaceImport,
|
||||
wildcardCollisionIsAmbiguous: partial.wildcardCollisionIsAmbiguous === true,
|
||||
namedImportsBindTopLevelOnly: partial.namedImportsBindTopLevelOnly === true,
|
||||
expandsWildcardTo: partial.expandsWildcardTo ?? (() => []),
|
||||
mergeBindings:
|
||||
partial.mergeBindings ??
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { createRequire } from 'node:module';
|
||||
import type Parser from 'tree-sitter';
|
||||
|
||||
import { isHardcodedIgnoredDirectoryAtPath } from '../../../config/ignore-service.js';
|
||||
import { logger } from '../../logger.js';
|
||||
|
|
@ -214,6 +215,8 @@ interface WorkspaceScope {
|
|||
readonly include: readonly string[];
|
||||
/** `!`-prefixed patterns, with the `!` stripped. */
|
||||
readonly exclude: readonly string[];
|
||||
readonly includeRe: readonly RegExp[];
|
||||
readonly excludeRe: readonly RegExp[];
|
||||
}
|
||||
|
||||
/** Whether `dir` (repo-relative, `''` for the root) is an admitted package. */
|
||||
|
|
@ -221,8 +224,21 @@ function admits(scope: WorkspaceScope | null, dir: string): boolean {
|
|||
// The root package is always itself, workspace or not.
|
||||
if (dir === '') return true;
|
||||
if (scope === null) return false;
|
||||
if (scope.exclude.some((pattern) => globToRegExp(pattern).test(dir))) return false;
|
||||
return scope.include.some((pattern) => globToRegExp(pattern).test(dir));
|
||||
// An exclusion covers the directory AND everything under it: `!packages/legacy`
|
||||
// must keep `packages/legacy/foo` out even when a nested workspace root inside
|
||||
// the excluded subtree re-declares `packages/*`.
|
||||
if (scope.excludeRe.some((re) => matchesDirOrAncestor(re, dir))) return false;
|
||||
return scope.includeRe.some((re) => re.test(dir));
|
||||
}
|
||||
|
||||
function matchesDirOrAncestor(re: RegExp, dir: string): boolean {
|
||||
let current = dir;
|
||||
while (current !== '') {
|
||||
if (re.test(current)) return true;
|
||||
const slash = current.lastIndexOf('/');
|
||||
current = slash === -1 ? '' : current.slice(0, slash);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -272,9 +288,69 @@ function globToRegExp(pattern: string): RegExp {
|
|||
* tooling that does not read pnpm's file).
|
||||
*/
|
||||
async function loadWorkspaceScope(repoRoot: string): Promise<WorkspaceScope | null> {
|
||||
const patterns: string[] = [];
|
||||
// Every workspace ROOT in the repo, not just the top level. keycloak keeps its
|
||||
// JavaScript workspace at `js/pnpm-workspace.yaml`; reading only the repo root
|
||||
// found no workspace, admitted no package, and `@keycloak/keycloak-ui-shared`
|
||||
// (780–845 raw calls per SHA) resolved 7 times. Patterns from a nested root
|
||||
// are rebased onto that root so `packages/*` under `js/` admits `js/packages/x`.
|
||||
//
|
||||
// Gated, though: a nested root counts only when the repo root declares NO
|
||||
// workspace (keycloak), or when the nested root's directory is itself admitted
|
||||
// by the root's scope. Unioning every nested root unconditionally let an
|
||||
// `examples/*/package.json` starter (turborepo/vite/nuxt templates carry
|
||||
// `workspaces`) admit its example packages — and, being shallow, outrank the
|
||||
// real package of the same name — and let a root inside an excluded subtree
|
||||
// re-admit what the outer `!exclusion` had removed.
|
||||
const [rootPatterns, workspaceRoots] = await Promise.all([
|
||||
readWorkspacePatternsAt(repoRoot),
|
||||
findWorkspaceRoots(repoRoot),
|
||||
]);
|
||||
const rootScope = rootPatterns.length === 0 ? null : toScope(rootPatterns);
|
||||
const patterns: string[] = [...rootPatterns];
|
||||
const admitted: { root: string; prefix: string }[] = [];
|
||||
for (const root of workspaceRoots) {
|
||||
if (root === repoRoot) continue;
|
||||
const prefix = repoRelativeDir(repoRoot, root);
|
||||
if (rootScope !== null && !admits(rootScope, prefix)) continue;
|
||||
admitted.push({ root, prefix });
|
||||
}
|
||||
const nested = await Promise.all(
|
||||
admitted.map(async ({ root, prefix }) => {
|
||||
const rebase = (p: string): string => {
|
||||
const negated = p.startsWith('!');
|
||||
const body = negated ? p.slice(1) : p;
|
||||
const joined = prefix === '' ? body : `${prefix}/${body.replace(/^\.\//, '')}`;
|
||||
return negated ? `!${joined}` : joined;
|
||||
};
|
||||
return (await readWorkspacePatternsAt(root)).map(rebase);
|
||||
}),
|
||||
);
|
||||
for (const batch of nested) patterns.push(...batch);
|
||||
|
||||
const rootManifest = await readJsonFile(path.join(repoRoot, 'package.json'));
|
||||
if (patterns.length === 0) return null;
|
||||
return toScope(patterns);
|
||||
}
|
||||
|
||||
function toScope(patterns: readonly string[]): WorkspaceScope {
|
||||
const include = patterns.filter((p) => !p.startsWith('!'));
|
||||
const exclude = patterns.filter((p) => p.startsWith('!')).map((p) => p.slice(1));
|
||||
return {
|
||||
include,
|
||||
exclude,
|
||||
includeRe: include.map(globToRegExp),
|
||||
excludeRe: exclude.map(globToRegExp),
|
||||
};
|
||||
}
|
||||
|
||||
/** The workspace patterns declared at ONE directory, all three spellings merged. */
|
||||
async function readWorkspacePatternsAt(root: string): Promise<string[]> {
|
||||
const [rootManifest, yamlPkgs, ymlPkgs, lerna] = await Promise.all([
|
||||
readJsonFile(path.join(root, 'package.json')),
|
||||
readYamlPackages(path.join(root, 'pnpm-workspace.yaml')),
|
||||
readYamlPackages(path.join(root, 'pnpm-workspace.yml')),
|
||||
readJsonFile(path.join(root, 'lerna.json')),
|
||||
]);
|
||||
const patterns: string[] = [];
|
||||
const workspaces = rootManifest?.workspaces;
|
||||
if (Array.isArray(workspaces)) {
|
||||
patterns.push(...workspaces.filter((w): w is string => typeof w === 'string'));
|
||||
|
|
@ -285,20 +361,114 @@ async function loadWorkspaceScope(repoRoot: string): Promise<WorkspaceScope | nu
|
|||
patterns.push(...nested.filter((w): w is string => typeof w === 'string'));
|
||||
}
|
||||
}
|
||||
|
||||
patterns.push(...(await readYamlPackages(path.join(repoRoot, 'pnpm-workspace.yaml'))));
|
||||
patterns.push(...(await readYamlPackages(path.join(repoRoot, 'pnpm-workspace.yml'))));
|
||||
|
||||
const lerna = await readJsonFile(path.join(repoRoot, 'lerna.json'));
|
||||
patterns.push(...yamlPkgs, ...ymlPkgs);
|
||||
if (Array.isArray(lerna?.packages)) {
|
||||
patterns.push(...lerna.packages.filter((w): w is string => typeof w === 'string'));
|
||||
}
|
||||
return patterns;
|
||||
}
|
||||
|
||||
if (patterns.length === 0) return null;
|
||||
return {
|
||||
include: patterns.filter((p) => !p.startsWith('!')),
|
||||
exclude: patterns.filter((p) => p.startsWith('!')).map((p) => p.slice(1)),
|
||||
};
|
||||
/**
|
||||
* Directories that declare a workspace: the repo root plus any directory (to a
|
||||
* shallow depth — workspace roots sit near the top) holding a
|
||||
* `pnpm-workspace.yaml`, a `lerna.json`, or a `package.json` with `workspaces`.
|
||||
*/
|
||||
const WORKSPACE_ROOT_MAX_DEPTH = 4;
|
||||
/**
|
||||
* Bound on the depth-≤4 directory walk. Generous on purpose: the previous 2,000
|
||||
* cap tripped silently in readdir order, so WHICH packages existed — and which
|
||||
* imports resolved — varied between two checkouts of the same commit. Tripping
|
||||
* it now warns, so a truncated scan is a logged fact rather than a quiet one.
|
||||
*/
|
||||
const WORKSPACE_ROOT_SCAN_MAX_DIRS = 50_000;
|
||||
/**
|
||||
* Directory names whose nested `workspaces` are starters, fixtures, or test
|
||||
* trees — never members. A leftover `test/pnpm-workspace.yaml` must not become
|
||||
* a workspace root when the repo root declares none (the #2953 first-wins
|
||||
* class). A root `workspaces` glob that lists `test/*` still admits those
|
||||
* packages through the package.json walk; this set only stops nested-root
|
||||
* discovery from descending into those names.
|
||||
*/
|
||||
const NON_MEMBER_ROOT_DIRS = new Set([
|
||||
'example',
|
||||
'examples',
|
||||
'fixture',
|
||||
'fixtures',
|
||||
'template',
|
||||
'templates',
|
||||
'sample',
|
||||
'samples',
|
||||
'test',
|
||||
'tests',
|
||||
'e2e',
|
||||
'__tests__',
|
||||
'spec',
|
||||
'specs',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Vite's `DEFAULT_CONFIG_FILES` order. The first filename that exists is the
|
||||
* config Vite loads; leftover siblings are not a second live entry.
|
||||
*/
|
||||
const VITE_CONFIG_FILES = [
|
||||
'vite.config.js',
|
||||
'vite.config.mjs',
|
||||
'vite.config.ts',
|
||||
'vite.config.cjs',
|
||||
'vite.config.mts',
|
||||
'vite.config.cts',
|
||||
] as const;
|
||||
|
||||
function isMissingPath(err: unknown): boolean {
|
||||
return (err as NodeJS.ErrnoException).code === 'ENOENT';
|
||||
}
|
||||
|
||||
async function readDirSorted(dir: string): Promise<import('fs').Dirent[] | null> {
|
||||
try {
|
||||
return (await fs.readdir(dir, { withFileTypes: true })).sort((a, b) =>
|
||||
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function findWorkspaceRoots(repoRoot: string): Promise<string[]> {
|
||||
const roots: string[] = [repoRoot];
|
||||
const queue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }];
|
||||
let scanned = 0;
|
||||
while (scanned < queue.length) {
|
||||
if (scanned >= WORKSPACE_ROOT_SCAN_MAX_DIRS) {
|
||||
logger.warn(
|
||||
`[node] workspace-root scan of ${repoRoot} hit the ${WORKSPACE_ROOT_SCAN_MAX_DIRS}-directory cap; nested workspace roots below it were not considered`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
const { dir, depth } = queue[scanned++]!;
|
||||
const entries = await readDirSorted(dir);
|
||||
if (entries === null) continue;
|
||||
if (dir !== repoRoot) {
|
||||
const names = new Set(entries.filter((e) => e.isFile()).map((e) => e.name));
|
||||
let declares =
|
||||
names.has('pnpm-workspace.yaml') ||
|
||||
names.has('pnpm-workspace.yml') ||
|
||||
names.has('lerna.json');
|
||||
if (!declares && names.has('package.json')) {
|
||||
const manifest = await readJsonFile(path.join(dir, 'package.json'));
|
||||
declares = manifest?.workspaces !== undefined && manifest.workspaces !== null;
|
||||
}
|
||||
if (declares) roots.push(dir);
|
||||
}
|
||||
if (depth >= WORKSPACE_ROOT_MAX_DEPTH) continue;
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (NON_MEMBER_ROOT_DIRS.has(entry.name.toLowerCase())) continue;
|
||||
const child = path.join(dir, entry.name);
|
||||
if (isHardcodedIgnoredDirectoryAtPath(repoRoot, child)) continue;
|
||||
queue.push({ dir: child, depth: depth + 1 });
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
async function readJsonFile(filePath: string): Promise<Record<string, unknown> | null> {
|
||||
|
|
@ -327,6 +497,39 @@ async function readYamlPackages(filePath: string): Promise<string[]> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-repo memo. The TS/JS/Vue scope resolvers and the unresolved-call ledger
|
||||
* classifier each ask for the same map during one analyze; without this the
|
||||
* 20k-directory walk ran once per asker (four times on a full run, one of them
|
||||
* inside the index lock). Invalidated at the start of every `runFullAnalysis`
|
||||
* so a long-lived server never serves a stale package map across runs.
|
||||
*/
|
||||
const workspacePackagesMemo = new Map<string, Promise<NodeWorkspacePackages | null>>();
|
||||
|
||||
export function invalidateNodeWorkspacePackages(repoRoot: string): void {
|
||||
workspacePackagesMemo.delete(path.resolve(repoRoot));
|
||||
}
|
||||
|
||||
export async function loadNodeWorkspacePackages(
|
||||
repoRoot: string,
|
||||
): Promise<NodeWorkspacePackages | null> {
|
||||
const key = path.resolve(repoRoot);
|
||||
const cached = workspacePackagesMemo.get(key);
|
||||
if (cached !== undefined) return cached;
|
||||
const pending: Promise<NodeWorkspacePackages | null> = loadNodeWorkspacePackagesUncached(
|
||||
repoRoot,
|
||||
).catch((err: unknown) => {
|
||||
// Evict only OUR entry. If this load was invalidated while in flight and a
|
||||
// newer load has since been installed under the same key, deleting by key
|
||||
// alone would evict that one, and every later caller would start another
|
||||
// full scan instead of joining it.
|
||||
if (workspacePackagesMemo.get(key) === pending) workspacePackagesMemo.delete(key);
|
||||
throw err;
|
||||
});
|
||||
workspacePackagesMemo.set(key, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the `package.json` of every ADMITTED workspace package.
|
||||
*
|
||||
|
|
@ -334,30 +537,29 @@ async function readYamlPackages(filePath: string): Promise<string[]> {
|
|||
* declaration, so this is far cheaper than the C# namespace scan next door,
|
||||
* which reads every `.cs` file.
|
||||
*/
|
||||
export async function loadNodeWorkspacePackages(
|
||||
async function loadNodeWorkspacePackagesUncached(
|
||||
repoRoot: string,
|
||||
): Promise<NodeWorkspacePackages | null> {
|
||||
const scope = await loadWorkspaceScope(repoRoot);
|
||||
const byName = new Map<string, NodeWorkspacePackage>();
|
||||
const queue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }];
|
||||
let queueHead = 0;
|
||||
let dirsScanned = 0;
|
||||
|
||||
while (queue.length > 0) {
|
||||
while (queueHead < queue.length) {
|
||||
if (dirsScanned >= SCAN_MAX_DIRS) {
|
||||
logger.warn(
|
||||
`[node] package.json scan of ${repoRoot} hit the ${SCAN_MAX_DIRS}-directory cap; workspace packages below it will not resolve`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
const { dir, depth } = queue.shift()!;
|
||||
const { dir, depth } = queue[queueHead++]!;
|
||||
dirsScanned++;
|
||||
|
||||
let entries: import('fs').Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
// Sorted: same-depth name collisions resolve first-wins, and readdir order
|
||||
// is filesystem-dependent.
|
||||
const entries = await readDirSorted(dir);
|
||||
if (entries === null) continue;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
|
|
@ -420,10 +622,34 @@ async function readManifest(
|
|||
const value = parsed[field];
|
||||
if (typeof value === 'string') push(entries, rebase(value));
|
||||
}
|
||||
}
|
||||
// Entry points that name BUILD OUTPUT (`main: dist/x.js`, `exports: ./dist/…`)
|
||||
// never match an indexed source file — the package's real source entry has to
|
||||
// be discovered. keycloak's `@keycloak/keycloak-ui-shared` publishes
|
||||
// `dist/keycloak-ui-shared.js` and keeps its entry in `vite.config.ts`
|
||||
// (`build.lib.entry: 'src/main.ts'`); with only the declared fields it had 7
|
||||
// resolved calls against ~845 raw. Tried in a fixed order, and ONLY appended
|
||||
// (declared entries keep precedence): `source` / `publishConfig.source`, the
|
||||
// vite `lib.entry`, then `src/main` / `src/index`. More than one candidate
|
||||
// that exists on disk is recorded as ambiguous rather than picked — a wrong
|
||||
// entry binds every import of the package to the wrong file.
|
||||
// A package whose `exports` map has no `"."` (only subpaths) refuses the bare
|
||||
// specifier outright; discovery must not manufacture a `src/index` root for it.
|
||||
const rootlessExports = declaresExports && rootExports.length === 0;
|
||||
const discovered = rootlessExports
|
||||
? { entries: [], ambiguous: [], allowConventional: false }
|
||||
: await discoverSourceEntries(parsed, dir, repoRoot);
|
||||
for (const entry of discovered.entries) push(entries, entry);
|
||||
if (!declaresExports && discovered.allowConventional) {
|
||||
for (const conventional of ['src/index', 'index', 'lib/index']) {
|
||||
push(entries, joinRepoPath(packageDir, conventional));
|
||||
}
|
||||
}
|
||||
if (discovered.ambiguous.length > 0) {
|
||||
logger.warn(
|
||||
`[node] package ${name}: ${discovered.ambiguous.length} candidate source entries (${discovered.ambiguous.join(', ')}) — none adopted; declare \`source\` or a single lib entry`,
|
||||
);
|
||||
}
|
||||
|
||||
const subpathImports = new Map<string, readonly string[]>();
|
||||
collectImports(parsed.imports, subpathImports, rebase);
|
||||
|
|
@ -507,6 +733,195 @@ function collectImports(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Does a declared entry point at build output rather than source? Output
|
||||
* directories and minified bundles. A plain `.js` is NOT build output on its
|
||||
* own — `main: "src/index.js"` / `index.js` is a JavaScript package's source,
|
||||
* and treating every `.js` as output ran discovery for essentially every CJS
|
||||
* package and could adopt a `src/main` beside the real entry.
|
||||
*/
|
||||
function looksLikeBuildOutput(entry: string): boolean {
|
||||
return /(^|\/)(dist|build|lib|out|esm|cjs|umd)\//.test(entry) || /\.min\.[cm]?js$/.test(entry);
|
||||
}
|
||||
|
||||
function declaredRootExportStrings(exportsRoot: unknown): string[] {
|
||||
if (typeof exportsRoot === 'string') return [exportsRoot];
|
||||
if (exportsRoot === null || typeof exportsRoot !== 'object') return [];
|
||||
const dot = (exportsRoot as Record<string, unknown>)['.'];
|
||||
if (typeof dot === 'string') return [dot];
|
||||
if (dot === null || typeof dot !== 'object') return [];
|
||||
return Object.values(dot).filter((v): v is string => typeof v === 'string');
|
||||
}
|
||||
|
||||
async function existingStems(repoRoot: string, stems: readonly string[]): Promise<string[]> {
|
||||
const present = await Promise.all(stems.map((stem) => stemExists(repoRoot, stem)));
|
||||
const existing: string[] = [];
|
||||
for (let i = 0; i < stems.length; i++) {
|
||||
if (present[i]) push(existing, stems[i]!);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
async function discoverSourceEntries(
|
||||
parsed: Record<string, unknown>,
|
||||
dir: string,
|
||||
repoRoot: string,
|
||||
): Promise<{ entries: string[]; ambiguous: string[]; allowConventional: boolean }> {
|
||||
const packageDir = repoRelativeDir(repoRoot, dir);
|
||||
const rebase = (raw: string): string => joinRepoPath(packageDir, stripEntryPrefixes(raw));
|
||||
const declared: string[] = declaredRootExportStrings(parsed.exports);
|
||||
for (const field of ['module', 'main']) {
|
||||
const value = parsed[field];
|
||||
if (typeof value === 'string') declared.push(value);
|
||||
}
|
||||
// Only when every declared entry is build output (or nothing is declared and
|
||||
// the conventional stems are absent) does discovery run at all.
|
||||
if (declared.length > 0 && !declared.every(looksLikeBuildOutput))
|
||||
return { entries: [], ambiguous: [], allowConventional: true };
|
||||
|
||||
const candidates: string[] = [];
|
||||
const source = parsed.source;
|
||||
if (typeof source === 'string') candidates.push(rebase(source));
|
||||
const publishConfig = parsed.publishConfig;
|
||||
if (publishConfig !== null && typeof publishConfig === 'object') {
|
||||
const ps = (publishConfig as Record<string, unknown>).source;
|
||||
if (typeof ps === 'string') candidates.push(rebase(ps));
|
||||
}
|
||||
let hasViteConfig = false;
|
||||
for (const cfg of VITE_CONFIG_FILES) {
|
||||
let text: string;
|
||||
try {
|
||||
text = await fs.readFile(path.join(dir, cfg), 'utf-8');
|
||||
} catch (err) {
|
||||
if (isMissingPath(err)) continue;
|
||||
// Present but unreadable: Vite still selected this filename.
|
||||
hasViteConfig = true;
|
||||
break;
|
||||
}
|
||||
hasViteConfig = true;
|
||||
try {
|
||||
const entry = await staticViteEntry(text);
|
||||
if (entry !== null) push(candidates, rebase(entry));
|
||||
} catch {
|
||||
// Parse/load failure is not a missing file — do not fall through to a leftover sibling.
|
||||
}
|
||||
break;
|
||||
}
|
||||
let existing = await existingStems(repoRoot, candidates);
|
||||
// A config we cannot establish is not evidence for a conventional entry.
|
||||
if (existing.length === 0 && !hasViteConfig) {
|
||||
existing = await existingStems(
|
||||
repoRoot,
|
||||
['src/main', 'src/index'].map((conventional) => joinRepoPath(packageDir, conventional)),
|
||||
);
|
||||
}
|
||||
const allowConventional = !hasViteConfig && candidates.length === 0;
|
||||
if (existing.length > 1) return { entries: [], ambiguous: existing, allowConventional };
|
||||
return { entries: existing, ambiguous: [], allowConventional };
|
||||
}
|
||||
|
||||
/** Read a literal exported build.lib.entry without evaluating repository code. */
|
||||
async function staticViteEntry(text: string): Promise<string | null> {
|
||||
// Most manifests need no Vite discovery. Keep native grammars off that path.
|
||||
const [{ default: TreeSitter }, { default: grammar }, { parseSourceSafe }] = await Promise.all([
|
||||
import('tree-sitter'),
|
||||
import('tree-sitter-typescript'),
|
||||
import('../../tree-sitter/safe-parse.js'),
|
||||
]);
|
||||
const parser = new TreeSitter();
|
||||
parser.setLanguage(grammar.typescript);
|
||||
const tree = parseSourceSafe(parser, text);
|
||||
if (tree.rootNode.hasError) return null;
|
||||
const exports = tree.rootNode.namedChildren.filter(
|
||||
(node) => node.type === 'export_statement' && node.children.some((c) => c.type === 'default'),
|
||||
);
|
||||
if (exports.length !== 1) return null;
|
||||
let config = unwrapViteDefineConfig(tree, exports[0]!.childForFieldName('value'));
|
||||
if (config === null) return null;
|
||||
for (const key of ['build', 'lib', 'entry']) config = staticObjectProperty(config, key);
|
||||
return staticStringValue(config);
|
||||
}
|
||||
|
||||
/** Unwrap `defineConfig({...})` from Vite; refuse local or re-exported helpers. */
|
||||
function unwrapViteDefineConfig(
|
||||
tree: { rootNode: Parser.SyntaxNode },
|
||||
config: Parser.SyntaxNode | null,
|
||||
): Parser.SyntaxNode | null {
|
||||
if (config?.type !== 'call_expression') return config;
|
||||
if (config.childForFieldName('function')?.text !== 'defineConfig') return null;
|
||||
// A locally defined helper need not preserve its argument like Vite does.
|
||||
if (
|
||||
tree.rootNode
|
||||
.descendantsOfType(['function_declaration', 'variable_declarator'])
|
||||
.some((node) => node.childForFieldName('name')?.text === 'defineConfig')
|
||||
)
|
||||
return null;
|
||||
for (const statement of tree.rootNode.namedChildren) {
|
||||
if (statement.type !== 'import_statement') continue;
|
||||
if (!statement.descendantsOfType('identifier').some((n) => n.text === 'defineConfig')) continue;
|
||||
if (staticStringValue(statement.childForFieldName('source')) !== 'vite') return null;
|
||||
const identityImport = statement.descendantsOfType('import_specifier').some((specifier) => {
|
||||
const imported = specifier.childForFieldName('name')?.text;
|
||||
const local = specifier.childForFieldName('alias')?.text ?? imported;
|
||||
return imported === 'defineConfig' && local === 'defineConfig';
|
||||
});
|
||||
if (!identityImport) return null;
|
||||
}
|
||||
const args = config
|
||||
.childForFieldName('arguments')
|
||||
?.namedChildren.filter((n) => n.type !== 'comment');
|
||||
if (args?.length !== 1) return null;
|
||||
return args[0]!;
|
||||
}
|
||||
|
||||
function staticStringValue(node: Parser.SyntaxNode | null): string | null {
|
||||
if (node?.type !== 'string' || node.namedChildren.some((n) => n.type === 'escape_sequence'))
|
||||
return null;
|
||||
return node.text.slice(1, -1);
|
||||
}
|
||||
|
||||
/** Reject computed keys, spreads and duplicate properties that could override a value. */
|
||||
function staticObjectProperty(
|
||||
node: Parser.SyntaxNode | null,
|
||||
key: string,
|
||||
): Parser.SyntaxNode | null {
|
||||
if (node?.type !== 'object') return null;
|
||||
let value: Parser.SyntaxNode | null = null;
|
||||
for (const member of node.namedChildren) {
|
||||
if (member.type === 'comment') continue;
|
||||
if (member.type !== 'pair') return null;
|
||||
const name = member.childForFieldName('key');
|
||||
if (name?.type !== 'property_identifier' && name?.type !== 'string') return null;
|
||||
if (name.type === 'string') {
|
||||
const property = staticStringValue(name);
|
||||
if (property === null) return null;
|
||||
if (property !== key) continue;
|
||||
} else if (name.text !== key) {
|
||||
continue;
|
||||
}
|
||||
if (value !== null) return null;
|
||||
value = member.childForFieldName('value');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** A repo-relative stem exists as a source file (with any TS/JS extension). */
|
||||
// The root is threaded explicitly: a module-level "current root" clobbered
|
||||
// under two concurrent scans and turned an ambiguity refusal into a confident
|
||||
// wrong entry (the second repo's root made one candidate "not exist").
|
||||
async function stemExists(repoRoot: string, stem: string): Promise<boolean> {
|
||||
const abs = path.join(repoRoot, stem);
|
||||
for (const ext of ['', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']) {
|
||||
try {
|
||||
const st = await fs.stat(abs + ext);
|
||||
if (st.isFile()) return true;
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** `"./src/index.ts"` -> `"src/index"`; leaves an extension-less path alone. */
|
||||
function stripEntryPrefixes(entry: string): string {
|
||||
const withoutDot = entry.replace(/^\.\//, '').replace(/^\//, '');
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import path from 'path';
|
|||
import type { CsharpStructureLineScanner } from './languages/csharp/namespace-siblings.js';
|
||||
|
||||
import { isDev } from './utils/env.js';
|
||||
import { isHardcodedIgnoredDirectoryAtPath } from '../../config/ignore-service.js';
|
||||
|
||||
import { mapConcurrent } from '../../lib/utils.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
|
@ -182,13 +183,24 @@ export interface SwiftPackageConfig {
|
|||
/** 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.
|
||||
* Map of dependency name -> the dep's directory, in one of two spellings
|
||||
* depending on which package this config describes:
|
||||
*
|
||||
* - ROOT package (`pkg === ''`): the raw `.path = "..."` value, exactly as
|
||||
* written in build.zig.zon (relative to the repo root, and possibly
|
||||
* escaping it: `../local_dep`). This is what `parseZigBuildZon` promises
|
||||
* and what its tests pin.
|
||||
* - NESTED package: repo-relative and already normalized, because a nested
|
||||
* package's `.path` is written relative to ITS directory and means
|
||||
* nothing against the repo-relative keys consumers match on
|
||||
* (`packages/app`'s `../core` is stored as `packages/core`). A dep
|
||||
* escaping the REPO root is dropped rather than stored.
|
||||
*
|
||||
* Either spelling is safe to hand to `normalizeZigDepPath` below — it rejects
|
||||
* absolute and repo-escaping values and is idempotent on an already
|
||||
* normalized one, which is what `resolveZigImportInternal` relies on.
|
||||
* `.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>;
|
||||
/**
|
||||
|
|
@ -228,6 +240,29 @@ export interface ZigBuildZonConfig {
|
|||
buildModules?: readonly ZigBuildModule[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One Zig build package: the directory whose `build.zig` / `build.zig.zon`
|
||||
* declare the config, and that config with every path REPO-relative.
|
||||
*
|
||||
* A Zig module's import table is declared by the `build.zig` of the package it
|
||||
* belongs to, so a repo holding several packages holds several import tables —
|
||||
* the same shape a TypeScript monorepo has with a `tsconfig.json` per package.
|
||||
*/
|
||||
export interface ZigPackageScope {
|
||||
/** Repo-relative directory the package governs (`''` for the repo root). */
|
||||
readonly dir: string;
|
||||
readonly config: ZigBuildZonConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every Zig build package in the repo, indexed so the nearest one to a file
|
||||
* wins — the `TsconfigIndex` analogue, and for the same reason.
|
||||
*/
|
||||
export interface ZigWorkspaceIndex {
|
||||
/** Deepest-first, so the first `dir` that prefixes a file path governs it. */
|
||||
readonly packages: readonly ZigPackageScope[];
|
||||
}
|
||||
|
||||
/** One build module of the root `build.zig` — see `ZigBuildZonConfig.buildModules`. */
|
||||
export interface ZigBuildModule {
|
||||
/** The `addModule("<name>", …)` name; absent for `createModule` bindings
|
||||
|
|
@ -646,10 +681,21 @@ export async function loadSwiftPackageConfig(repoRoot: string): Promise<SwiftPac
|
|||
* 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> {
|
||||
export async function loadZigBuildConfig(
|
||||
repoRoot: string,
|
||||
packageDir = '',
|
||||
): Promise<ZigBuildZonConfig | null> {
|
||||
// Every path this function returns is REPO-relative, because that is the
|
||||
// keyspace `allFilePaths` uses. The parsers below answer package-relative, so
|
||||
// a nested package rebases them through `inPackage`. For the root package
|
||||
// (`packageDir === ''`) the prefix is empty and every value is byte-identical
|
||||
// to what this function returned before nested packages existed.
|
||||
const pkg = packageDir === '' ? '' : `${packageDir}/`;
|
||||
const inPackage = (relToPackage: string): string => `${pkg}${relToPackage}`;
|
||||
const packageFile = (name: string): string => path.join(repoRoot, packageDir, name);
|
||||
let config: ZigBuildZonConfig | null = null;
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(repoRoot, 'build.zig.zon'), 'utf-8');
|
||||
const raw = await fs.readFile(packageFile('build.zig.zon'), 'utf-8');
|
||||
config = parseZigBuildZon(raw);
|
||||
} catch {
|
||||
// No zon (or unreadable): the root build.zig may still declare modules.
|
||||
|
|
@ -661,9 +707,11 @@ export async function loadZigBuildConfig(repoRoot: string): Promise<ZigBuildZonC
|
|||
let rootModules: Map<string, string> | undefined;
|
||||
let rootBuildZig: string | null = null;
|
||||
try {
|
||||
rootBuildZig = await fs.readFile(path.join(repoRoot, 'build.zig'), 'utf-8');
|
||||
rootBuildZig = await fs.readFile(packageFile('build.zig'), 'utf-8');
|
||||
const parsed = parseZigRootModules(rootBuildZig);
|
||||
if (parsed.size > 0) rootModules = parsed;
|
||||
if (parsed.size > 0) {
|
||||
rootModules = new Map(Array.from(parsed, ([name, root]) => [name, inPackage(root)]));
|
||||
}
|
||||
} catch {
|
||||
// No root build.zig — nothing to declare.
|
||||
}
|
||||
|
|
@ -671,7 +719,7 @@ export async function loadZigBuildConfig(repoRoot: string): Promise<ZigBuildZonC
|
|||
if (config === null) {
|
||||
if (rootBuildZig === null) return null;
|
||||
// No zon: no path deps, so `dep.module(…)` operands resolve to nothing.
|
||||
const buildModules = parseZigBuildModules(rootBuildZig);
|
||||
const buildModules = rebaseZigBuildModules(parseZigBuildModules(rootBuildZig), inPackage);
|
||||
if (!rootModules && buildModules.length === 0) return null;
|
||||
return {
|
||||
pathDeps: new Map(),
|
||||
|
|
@ -688,9 +736,22 @@ export async function loadZigBuildConfig(repoRoot: string): Promise<ZigBuildZonC
|
|||
// 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>>();
|
||||
// A nested package's `.path` values are written relative to ITS directory, so
|
||||
// they are rebased here and stored repo-relative; `resolveZigImportInternal`
|
||||
// then reads them through the same `normalizeZigDepPath`, which is idempotent
|
||||
// on an already-normalized value. A dep that escapes the REPO root (not merely
|
||||
// the package) resolves to nothing and is dropped. The root package keeps its
|
||||
// raw spelling, which is what `parseZigBuildZon` promises and its tests pin.
|
||||
const pathDeps = pkg === '' ? config.pathDeps : new Map<string, string>();
|
||||
for (const [depName, depPath] of config.pathDeps) {
|
||||
const rel = normalizeZigDepPath(depPath);
|
||||
// Asked of the value AS WRITTEN, before the package prefix goes on: an
|
||||
// absolute `.path` points outside the repository whichever package declared
|
||||
// it, and prefixing hides that from `normalizeZigDepPath`. See
|
||||
// `isAbsoluteZigDepPath`.
|
||||
if (isAbsoluteZigDepPath(depPath)) continue;
|
||||
const rel = normalizeZigDepPath(`${pkg}${depPath}`);
|
||||
if (rel === null) continue;
|
||||
if (pkg !== '') pathDeps.set(depName, rel);
|
||||
let buildZig: string;
|
||||
try {
|
||||
buildZig = await fs.readFile(path.join(repoRoot, rel, 'build.zig'), 'utf-8');
|
||||
|
|
@ -706,15 +767,177 @@ export async function loadZigBuildConfig(repoRoot: string): Promise<ZigBuildZonC
|
|||
}
|
||||
if (named.size > 0) depModules.set(depName, named);
|
||||
}
|
||||
const buildModules = rootBuildZig === null ? [] : parseZigBuildModules(rootBuildZig, depModules);
|
||||
const buildModules =
|
||||
rootBuildZig === null
|
||||
? []
|
||||
: rebaseZigBuildModules(
|
||||
parseZigBuildModules(rootBuildZig, depModules),
|
||||
inPackage,
|
||||
depModules,
|
||||
);
|
||||
return {
|
||||
...config,
|
||||
pathDeps,
|
||||
...(moduleRoots.size > 0 ? { moduleRoots } : {}),
|
||||
...(rootModules ? { rootModules } : {}),
|
||||
...(buildModules.length > 0 ? { buildModules } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebase a package's own build modules to repo-relative paths.
|
||||
*
|
||||
* `parseZigBuildModules` answers package-relative for everything it read out of
|
||||
* the `build.zig` it was handed, with one exception: an alias resolved through
|
||||
* `depModules` (`addImport("api", dep.module("core"))`) is already repo-relative,
|
||||
* because `depModules` was built that way. Prefixing that a second time would
|
||||
* point the alias at a path no file has. The already-repo-relative values are
|
||||
* therefore identified by membership in `depModules`, not guessed at from their
|
||||
* shape.
|
||||
*/
|
||||
function rebaseZigBuildModules(
|
||||
modules: readonly ZigBuildModule[],
|
||||
inPackage: (relToPackage: string) => string,
|
||||
depModules?: ReadonlyMap<string, ReadonlyMap<string, string>>,
|
||||
): ZigBuildModule[] {
|
||||
if (inPackage('') === '') return [...modules];
|
||||
const fromDep = new Set<string>();
|
||||
for (const named of depModules?.values() ?? [])
|
||||
for (const root of named.values()) fromDep.add(root);
|
||||
return modules.map((mod) => ({
|
||||
...(mod.name !== undefined ? { name: mod.name } : {}),
|
||||
root: inPackage(mod.root),
|
||||
imports: new Map(
|
||||
Array.from(mod.imports, ([alias, root]) => [
|
||||
alias,
|
||||
fromDep.has(root) ? root : inPackage(root),
|
||||
]),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Bounds for the package walk, mirroring the tsconfig scan. */
|
||||
const ZIG_SCAN_MAX_DIRS = 20_000;
|
||||
const ZIG_SCAN_MAX_DEPTH = 24;
|
||||
|
||||
/**
|
||||
* The Zig build package governing `filePath` — the nearest one at or above it.
|
||||
*
|
||||
* A Zig module's import table is declared by the `build.zig` of the package the
|
||||
* file belongs to, so the nearest enclosing package is the faithful reading of
|
||||
* `@import("name")` at that site, exactly as `tsconfigFor` reads a non-relative
|
||||
* specifier against the nearest enclosing project.
|
||||
*
|
||||
* There is deliberately NO fall-through to an enclosing package when the nearest
|
||||
* one does not bind the name. Falling through is how a vendored dependency's
|
||||
* `@import("config")` silently resolved to the outer repo's `config` module —
|
||||
* the same failure `loadTsconfigIndex` documents for a package whose own
|
||||
* tsconfig declares no `baseUrl`, and the same failure the per-module import
|
||||
* tables in `resolveZigImportInternal` already exist to prevent one level down.
|
||||
*/
|
||||
export function zigPackageFor(
|
||||
index: ZigWorkspaceIndex | null | undefined,
|
||||
filePath: string,
|
||||
): ZigBuildZonConfig | null {
|
||||
if (index === null || index === undefined) return null;
|
||||
for (const scope of index.packages) {
|
||||
if (scope.dir === '') return scope.config;
|
||||
if (filePath.startsWith(`${scope.dir}/`)) return scope.config;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load every Zig build package in the repo, nearest-first.
|
||||
*
|
||||
* Called with no `packageDir` — which is how every call site read it before
|
||||
* this function existed — `loadZigBuildConfig` reads the ROOT `build.zig` /
|
||||
* `build.zig.zon` and nothing else. That is the whole configuration of a
|
||||
* single-package repo and none of the configuration of a monorepo: a repo
|
||||
* laying its packages out as `packages/<name>/build.zig` has no root build
|
||||
* files at all, so the loader answers `null` and EVERY bare
|
||||
* `@import("<module>")` in it goes unresolved — cross-file resolution silently
|
||||
* degrades to relative imports only. Measured on a two-package fixture:
|
||||
* `config = null`, `@import("core")` → `null`.
|
||||
*
|
||||
* The loader itself is not root-bound any more: this function is what supplies
|
||||
* it a `packageDir`, one per package below.
|
||||
*
|
||||
* So the packages are discovered the way tsconfigs are (`findTsconfigFiles`):
|
||||
* one bounded breadth-first walk that skips the hardcoded ignore set, then
|
||||
* deepest-first ordering so `zigPackageFor` can take the first match.
|
||||
*
|
||||
* Called from `ScopeResolver.loadResolutionConfig`, which the orchestrator runs
|
||||
* once per LANGUAGE workspace pass — so the walk happens only for repos that
|
||||
* actually contain Zig. `loadImportConfigs`, which runs unconditionally for
|
||||
* every repo, keeps calling `loadZigBuildConfig` for the root package alone;
|
||||
* that is the same split TypeScript already has between the cheap
|
||||
* `loadTsconfigPaths` and the repo-walking `loadTsconfigIndex`.
|
||||
*/
|
||||
export async function loadZigWorkspaceIndex(repoRoot: string): Promise<ZigWorkspaceIndex | null> {
|
||||
const dirs = await findZigPackageDirs(repoRoot);
|
||||
if (dirs.length === 0) return null;
|
||||
const packages: ZigPackageScope[] = [];
|
||||
for (const dir of dirs) {
|
||||
const config = await loadZigBuildConfig(repoRoot, dir);
|
||||
// A `build.zig` that declares no module and no path dep contributes nothing
|
||||
// a lookup could answer with. Keeping it as an empty scope would be worse
|
||||
// than dropping it: it would shadow an enclosing package that DOES declare
|
||||
// the name, and answer nothing in its place.
|
||||
if (config !== null) packages.push({ dir, config });
|
||||
}
|
||||
if (packages.length === 0) return null;
|
||||
// Deepest first, so `zigPackageFor` takes the most specific package rather
|
||||
// than whichever the walk reached first.
|
||||
packages.sort((a, b) => b.dir.length - a.dir.length || a.dir.localeCompare(b.dir));
|
||||
return { packages };
|
||||
}
|
||||
|
||||
/** Repo-relative directories holding a `build.zig` and/or a `build.zig.zon`. */
|
||||
async function findZigPackageDirs(repoRoot: string): Promise<string[]> {
|
||||
const found: string[] = [];
|
||||
const queue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }];
|
||||
// A HEAD INDEX rather than `queue.shift()`. The queue is pushed to while it is
|
||||
// drained, which keeps the array in a mode where `shift()` memmoves the whole
|
||||
// remainder instead of taking V8's left-trimming fast path — so the walk is
|
||||
// quadratic in the frontier, and `ZIG_SCAN_MAX_DIRS` is the bound on how bad
|
||||
// that gets. Measured at that bound (20,000 dequeues): 53 ms at fan-out 4 and
|
||||
// 81 ms at fan-out 20, against 0.8 ms here — 66-106x, paid before any config
|
||||
// is read. Memory is unchanged: entries were already retained by the pushes,
|
||||
// `shift()` only dropped the head.
|
||||
let queueHead = 0;
|
||||
let dirsScanned = 0;
|
||||
|
||||
while (queueHead < queue.length && dirsScanned < ZIG_SCAN_MAX_DIRS) {
|
||||
const { dir, depth } = queue[queueHead++]!;
|
||||
dirsScanned++;
|
||||
let entries: import('fs').Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
let isPackage = false;
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const childDir = path.join(dir, entry.name);
|
||||
if (isHardcodedIgnoredDirectoryAtPath(repoRoot, childDir)) continue;
|
||||
if (depth < ZIG_SCAN_MAX_DEPTH) queue.push({ dir: childDir, depth: depth + 1 });
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
// Either marker declares a package: a `build.zig` with no zon still names
|
||||
// modules, and a zon with no build.zig still names path deps.
|
||||
if (entry.name === 'build.zig' || entry.name === 'build.zig.zon') isPackage = true;
|
||||
}
|
||||
if (isPackage) {
|
||||
const rel = path.relative(repoRoot, dir).split(path.sep).join('/');
|
||||
found.push(rel === '.' || rel === '' ? '' : rel);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
|
@ -722,13 +945,38 @@ export async function loadZigBuildConfig(repoRoot: string): Promise<ZigBuildZonC
|
|||
* 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.
|
||||
/**
|
||||
* Does this `.path` value point outside the repository BY ITS SPELLING —
|
||||
* POSIX absolute (`/dep`), Windows drive-qualified (`C:\dep`, `C:/dep`),
|
||||
* root-relative (`\dep`) or UNC (`\\server\share`)?
|
||||
*
|
||||
* Separators are normalized first so every Windows spelling is visible to the
|
||||
* one test. Exported-in-spirit rather than inlined because it must be asked in
|
||||
* TWO places and the two must not drift: `normalizeZigDepPath` asks it of the
|
||||
* value it is given, and `loadZigBuildConfig` asks it of a NESTED package's
|
||||
* value BEFORE prefixing the package directory. That second call is the whole
|
||||
* point — prefixing turns `/dep` into `packages/app//dep`, which is relative by
|
||||
* inspection, so the check inside `normalizeZigDepPath` no longer sees an
|
||||
* absolute path and the empty segment is simply dropped, mapping an
|
||||
* out-of-repo dependency onto a real in-repo directory if one happens to exist.
|
||||
*
|
||||
* `path.posix.join` is NOT a substitute: it strips the leading slash too
|
||||
* (`join('packages/app/', '/dep')` is `packages/app/dep`), so it produces the
|
||||
* same fabricated path without ever rejecting anything.
|
||||
*
|
||||
* A `..` prefix is deliberately NOT handled here. `../core` escapes the
|
||||
* package but not necessarily the repo, and rebasing it is exactly what the
|
||||
* nested-package branch exists to do; `normalizeZigDepPath` rejects the ones
|
||||
* that still escape the ROOT after rebasing.
|
||||
*/
|
||||
function isAbsoluteZigDepPath(depPath: string): boolean {
|
||||
const normalized = depPath.replace(/\\/g, '/');
|
||||
if (normalized.startsWith('/') || /^[A-Za-z]:\//.test(normalized)) return null;
|
||||
return normalized.startsWith('/') || /^[A-Za-z]:\//.test(normalized);
|
||||
}
|
||||
|
||||
export function normalizeZigDepPath(depPath: string): string | null {
|
||||
const normalized = depPath.replace(/\\/g, '/');
|
||||
if (isAbsoluteZigDepPath(depPath)) return null;
|
||||
const parts: string[] = [];
|
||||
for (const part of normalized.split('/')) {
|
||||
if (part === '' || part === '.') continue;
|
||||
|
|
|
|||
114
gitnexus/src/core/ingestion/languages/cobol/copy-target.ts
Normal file
114
gitnexus/src/core/ingestion/languages/cobol/copy-target.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* Shared COBOL `COPY` target resolution (#2967 / #2908).
|
||||
*
|
||||
* Both the census (`cobolScopeResolver.resolveImportTarget`) and the live
|
||||
* regex processor (`resolveCopy` → `cobol-copy` IMPORTS) must answer the
|
||||
* same path for the same `(name, fromFile, allFilePaths)`. Greening only
|
||||
* the census would leave analyze fabricating an IMPORTS edge onto a vendor
|
||||
* decoy the compiler would never have searched.
|
||||
*
|
||||
* Well-known copybook directory segments (`copybooks`, `COPYBOOKS`, `cpy`,
|
||||
* `copy`) plus the importer's own directory are the preferred class. When
|
||||
* the file set contains at least one of those well-known segments, a `COPY`
|
||||
* name resolves only inside that class (first in Set-iteration order).
|
||||
* `vendor/EXTERNAL.cpy` therefore misses while `copybooks/CUSTREC.cpy` hits.
|
||||
*
|
||||
* When the file set has no such directory, fail-open to today's two-tier
|
||||
* first-wins basename index — shops whose copybooks *are* the tree have
|
||||
* nothing to prefer. The importer directory is *not* enough on its own to
|
||||
* leave fail-open (`{vendor/EXTERNAL.cpy, src/PROG.cbl}` must still answer
|
||||
* `vendor/EXTERNAL.cpy`).
|
||||
*
|
||||
* The two-tier index is still memoized on the `allFilePaths` Set identity
|
||||
* (#2908). Preferred-class filtering happens at pick time from the stored
|
||||
* per-name arrays, so a pass still scans the set once.
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import { perFileSet } from '../../import-resolvers/per-file-set.js';
|
||||
|
||||
const COPYBOOK_EXTENSIONS = new Set(['.cpy', '.copybook']);
|
||||
const COBOL_SOURCE_EXTENSIONS = new Set(['.cbl', '.cob', '.cobol']);
|
||||
|
||||
/** Path-component names that mark a conventional copybook directory. */
|
||||
const PREFERRED_DIR_NAMES = new Set(['copybooks', 'COPYBOOKS', 'cpy', 'copy']);
|
||||
|
||||
interface CobolCopyIndex {
|
||||
/** `.cpy` / `.copybook` files — tier 1, Set-iteration order per stem. */
|
||||
readonly copybooks: ReadonlyMap<string, readonly string[]>;
|
||||
/** `.cbl` / `.cob` / `.cobol` files — tier 2, Set-iteration order per stem. */
|
||||
readonly sources: ReadonlyMap<string, readonly string[]>;
|
||||
/** True iff any file path has a well-known copybook directory segment. */
|
||||
readonly hasPreferredDir: boolean;
|
||||
}
|
||||
|
||||
function pathHasPreferredDir(fp: string): boolean {
|
||||
const parts = fp.split('/');
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (PREFERRED_DIR_NAMES.has(parts[i])) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function pushUnique(map: Map<string, string[]>, key: string, fp: string): void {
|
||||
const list = map.get(key);
|
||||
if (list === undefined) {
|
||||
map.set(key, [fp]);
|
||||
return;
|
||||
}
|
||||
list.push(fp);
|
||||
}
|
||||
|
||||
const getCobolCopyIndex = perFileSet((allFilePaths: ReadonlySet<string>): CobolCopyIndex => {
|
||||
const copybooks = new Map<string, string[]>();
|
||||
const sources = new Map<string, string[]>();
|
||||
let hasPreferredDir = false;
|
||||
for (const fp of allFilePaths) {
|
||||
const extRaw = path.extname(fp);
|
||||
const extLower = extRaw.toLowerCase();
|
||||
const tier = COPYBOOK_EXTENSIONS.has(extLower)
|
||||
? copybooks
|
||||
: COBOL_SOURCE_EXTENSIONS.has(extLower)
|
||||
? sources
|
||||
: undefined;
|
||||
if (tier === undefined) continue;
|
||||
if (!hasPreferredDir && pathHasPreferredDir(fp)) hasPreferredDir = true;
|
||||
const basename = path.basename(fp, extRaw).toUpperCase();
|
||||
pushUnique(tier, basename, fp);
|
||||
}
|
||||
return { copybooks, sources, hasPreferredDir };
|
||||
});
|
||||
|
||||
function pickCopyPath(
|
||||
paths: readonly string[] | undefined,
|
||||
fromFile: string,
|
||||
hasPreferredDir: boolean,
|
||||
): string | null {
|
||||
if (paths === undefined || paths.length === 0) return null;
|
||||
if (!hasPreferredDir) return paths[0] ?? null;
|
||||
const fromDir = path.dirname(fromFile);
|
||||
for (const fp of paths) {
|
||||
if (pathHasPreferredDir(fp) || path.dirname(fp) === fromDir) return fp;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a COBOL `COPY` member name against the workspace file set.
|
||||
*
|
||||
* `fromFile` is the importing program (census) or the program currently
|
||||
* being expanded (processor). It is used only to include the importer's
|
||||
* directory in the preferred class when a well-known copybook dir exists.
|
||||
*/
|
||||
export function resolveCobolCopyTarget(
|
||||
targetRaw: string,
|
||||
fromFile: string,
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
): string | null {
|
||||
const upper = targetRaw.toUpperCase();
|
||||
const index = getCobolCopyIndex(allFilePaths);
|
||||
return (
|
||||
pickCopyPath(index.copybooks.get(upper), fromFile, index.hasPreferredDir) ??
|
||||
pickCopyPath(index.sources.get(upper), fromFile, index.hasPreferredDir) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
|
@ -9,74 +9,12 @@
|
|||
* Reference: `languages/python/scope-resolver.ts`.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { perFileSet } from '../../import-resolvers/per-file-set.js';
|
||||
import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
|
||||
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
|
||||
import { cobolProvider } from '../cobol.js';
|
||||
|
||||
// Copybook file extensions for COPY name resolution
|
||||
const COPYBOOK_EXTENSIONS = new Set(['.cpy', '.copybook']);
|
||||
// COBOL source files, searched only after every copybook has missed.
|
||||
const COBOL_SOURCE_EXTENSIONS = new Set(['.cbl', '.cob', '.cobol']);
|
||||
|
||||
/**
|
||||
* Uppercased-basename → first file carrying it, one map PER TIER, memoized on
|
||||
* the `allFilePaths` Set identity (#2908).
|
||||
*
|
||||
* `resolveImportTarget` used to run two full workspace scans per `COPY` — one
|
||||
* for the copybook tier, one for the source tier — each calling `path.extname`
|
||||
* + `path.basename` + `toUpperCase` on every entry. A `COPY` of a member that
|
||||
* lives outside the repo (the common case: vendor and system copybooks) missed
|
||||
* in both, so both scans always ran to completion, making resolution
|
||||
* O(copies × files). The orchestrator passes the SAME Set to every import in a
|
||||
* pass (`pipeline/run.ts` builds it once), so a `WeakMap` keyed on that Set
|
||||
* turns the scans into one build per run.
|
||||
*
|
||||
* Two tiers rather than one map is the tie-break, not a stylistic choice: a
|
||||
* `.cpy`/`.copybook` hit beats a `.cbl`/`.cob`/`.cobol` hit even when the source
|
||||
* file comes FIRST in Set-iteration order, which is exactly what collapsing the
|
||||
* tiers into a single first-wins map would silently discard. Within a tier the
|
||||
* first file in Set-iteration order wins, mirroring the `return` on first match
|
||||
* in the scans this replaces.
|
||||
*
|
||||
* The per-file key is derived with the same `path.extname(fp).toLowerCase()` →
|
||||
* `path.basename(fp, ext)` → `toUpperCase()` sequence the scans used, including
|
||||
* its quirk: `path.basename` strips the suffix only on an exact, case-sensitive
|
||||
* match, so `Foo.CPY` indexes under `FOO.CPY` rather than `FOO`. Node's `path`
|
||||
* stays in the loop for the same reason — on POSIX it does not treat `\` as a
|
||||
* separator, and hand-rolled slicing on `/` would start resolving backslash
|
||||
* paths the scans never resolved.
|
||||
*/
|
||||
interface CobolCopyIndex {
|
||||
/** `.cpy` / `.copybook` files — tier 1. */
|
||||
readonly copybooks: ReadonlyMap<string, string>;
|
||||
/** `.cbl` / `.cob` / `.cobol` files — tier 2. */
|
||||
readonly sources: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
const getCobolCopyIndex = perFileSet((allFilePaths: ReadonlySet<string>): CobolCopyIndex => {
|
||||
const copybooks = new Map<string, string>();
|
||||
const sources = new Map<string, string>();
|
||||
// One pass builds both tiers: the two scans walked the same files and
|
||||
// classified each by the same extension test.
|
||||
for (const fp of allFilePaths) {
|
||||
const ext = path.extname(fp).toLowerCase();
|
||||
const tier = COPYBOOK_EXTENSIONS.has(ext)
|
||||
? copybooks
|
||||
: COBOL_SOURCE_EXTENSIONS.has(ext)
|
||||
? sources
|
||||
: undefined;
|
||||
if (tier === undefined) continue;
|
||||
const basename = path.basename(fp, ext).toUpperCase();
|
||||
// First in Set-iteration order wins, as the scans' first-match `return` did.
|
||||
if (!tier.has(basename)) tier.set(basename, fp);
|
||||
}
|
||||
|
||||
return { copybooks, sources };
|
||||
});
|
||||
import { resolveCobolCopyTarget } from './copy-target.js';
|
||||
|
||||
const cobolScopeResolver: ScopeResolver = {
|
||||
language: SupportedLanguages.Cobol,
|
||||
|
|
@ -84,11 +22,9 @@ const cobolScopeResolver: ScopeResolver = {
|
|||
importEdgeReason: 'cobol-scope: copy',
|
||||
|
||||
// ── Resolve COPY bookname to file path ─────────────────────────────
|
||||
resolveImportTarget: (targetRaw, _fromFile, allFilePaths) => {
|
||||
const upper = targetRaw.toUpperCase();
|
||||
const index = getCobolCopyIndex(allFilePaths);
|
||||
// Copybooks first, then COBOL sources — the tier order IS the tie-break.
|
||||
return index.copybooks.get(upper) ?? index.sources.get(upper) ?? null;
|
||||
// Shared with the regex processor's resolveCopy (#2967 lockstep).
|
||||
resolveImportTarget: (targetRaw, fromFile, allFilePaths) => {
|
||||
return resolveCobolCopyTarget(targetRaw, fromFile, allFilePaths);
|
||||
},
|
||||
|
||||
// COBOL has no binding-merge rules beyond the default (local-first-then-imports).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* Dart's veto on the global-name fallback — see
|
||||
* `ScopeResolver.isGlobalNameFallbackPlausible`.
|
||||
*
|
||||
* Dart's privacy is LIBRARY-scoped and marked in the identifier itself: a name
|
||||
* beginning with `_` is visible only inside its own library and cannot be
|
||||
* imported by any spelling. So a `_`-prefixed candidate in another file is an
|
||||
* impossible call, not an unlikely one.
|
||||
*
|
||||
* The exact boundary is `part` / `part of` — one library spanning several
|
||||
* files, with `_` names shared between them — and the extractor does not
|
||||
* surface `part` directives yet (the Dart query captures only `library_import`;
|
||||
* nothing in `languages/dart/` reads `part`). Without them a cross-file `_`
|
||||
* candidate is UNDECIDABLE, not impossible: refusing on "different file" would
|
||||
* delete real edges on Flutter's dominant generated-code idiom (`factory
|
||||
* Foo.fromJson(j) => _$FooFromJson(j)` calls into `foo.g.dart`, a `part` beside
|
||||
* it), and refusing on "different directory" is wrong too — a `part` URI is a
|
||||
* relative URI and legally traverses directories (`part '../shared/gen.dart';`).
|
||||
* An earlier version refused the cross-directory case as "no `part` layout can
|
||||
* make this legal"; that claim was false, so the hook now REFUSES NOTHING and
|
||||
* every cross-file `_` candidate stays a LABELED edge (0.5 /
|
||||
* `global-name-fallback`), which is the honest answer until `part` is
|
||||
* extracted. A caller that names the candidate's file in a directive is
|
||||
* recognized already, for the day the extractor surfaces `part` as an import
|
||||
* target; at that point "not the same library" becomes decidable and the
|
||||
* refusal can return.
|
||||
*
|
||||
* Public names are left to the labeled-edge path. Dart does require an import
|
||||
* for a cross-library public name, but the fallback exists partly to recover
|
||||
* edges where the import chain was not reconstructed, and refusing every
|
||||
* cross-file public call would delete real edges to buy a rule the `_` marker
|
||||
* already gives for free.
|
||||
*/
|
||||
|
||||
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
|
||||
import {
|
||||
modulePathReaches,
|
||||
stripExtension,
|
||||
} from '../../scope-resolution/utils/name-fallback-visibility.js';
|
||||
|
||||
/** Dart privacy marker: a leading underscore on the declared identifier. Read
|
||||
* from the last `qualifiedName` segment, so `_Foo.bar` is public `bar` on a
|
||||
* private class and `Foo._bar` is the private member. */
|
||||
function isPrivateDartName(candidate: SymbolDefinition): boolean {
|
||||
const qualified = candidate.qualifiedName ?? '';
|
||||
const dot = qualified.lastIndexOf('.');
|
||||
const simple = dot === -1 ? qualified : qualified.slice(dot + 1);
|
||||
return simple.startsWith('_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Are these two files parts of one library? True when the caller names the
|
||||
* candidate's file in a `part` / `part of` directive, which the extractor
|
||||
* surfaces as an ordinary import target.
|
||||
*/
|
||||
function sharesLibrary(callerParsed: ParsedFile, candidateFilePath: string): boolean {
|
||||
const candidateModule = stripExtension(candidateFilePath);
|
||||
for (const imp of callerParsed.parsedImports) {
|
||||
if (modulePathReaches(stripExtension(imp.targetRaw), candidateModule)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function dartIsGlobalNameFallbackPlausible(ctx: {
|
||||
readonly callerParsed: ParsedFile;
|
||||
readonly candidate: SymbolDefinition;
|
||||
}): boolean {
|
||||
if (ctx.candidate.filePath === ctx.callerParsed.filePath) return true;
|
||||
if (!isPrivateDartName(ctx.candidate)) return true;
|
||||
// A directive naming the candidate's file is positive evidence of one library.
|
||||
if (sharesLibrary(ctx.callerParsed, ctx.candidate.filePath)) return true;
|
||||
// Any other file may be a `part` of the caller's library — a sibling or, via
|
||||
// a relative `part` URI, a file in another directory. Undecidable without
|
||||
// `part` extraction (see the header), so allowed as a labeled guess, never
|
||||
// refused.
|
||||
return true;
|
||||
}
|
||||
|
|
@ -41,6 +41,7 @@ import { decodeMarker } from '../../utils/heritage-marker.js';
|
|||
import { typeApplicationArguments } from '../../utils/template-arguments.js';
|
||||
import type { HeritageTypeArgumentSink } from '../../scope-resolution/utils/generic-instantiation.js';
|
||||
import { expandDartWildcardNames } from './expand-wildcards.js';
|
||||
import { dartIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js';
|
||||
|
||||
interface ClassDefRef {
|
||||
readonly graphId: string;
|
||||
|
|
@ -233,5 +234,6 @@ export const dartScopeResolver: ScopeResolver = {
|
|||
// No `new`: bare `Foo()` resolves to the type; with cross-file imports the
|
||||
// callee is reachable workspace-wide.
|
||||
allowGlobalFreeCallFallback: true,
|
||||
isGlobalNameFallbackPlausible: dartIsGlobalNameFallbackPlausible,
|
||||
constructorCallTargetsClass: true,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -508,11 +508,24 @@ function normalizeGenericConstructorCapture(
|
|||
if (referenceNode !== undefined && referenceNode.type === 'generic_type') {
|
||||
const base = referenceNode.childForFieldName('type');
|
||||
if (base !== null) {
|
||||
grouped['@reference.name'] = syntheticCapture(
|
||||
'@reference.name',
|
||||
base,
|
||||
extractSimpleTypeNameText(base),
|
||||
);
|
||||
const simple = extractSimpleTypeNameText(base);
|
||||
// Keep the written `pkg.Box[T]` spelling so constructor resolution
|
||||
// can tell a package-qualified type from a bare unique-name guess.
|
||||
// `Box[T]` is also `text !== simple` (`Box[T]` vs `Box`) — that is
|
||||
// not a qualifier. Only a `qualified_type` base (`pkg.Box`) is.
|
||||
if (
|
||||
grouped['@reference.qualified-name'] === undefined &&
|
||||
base.type === 'qualified_type' &&
|
||||
referenceNode.text.length > 0 &&
|
||||
referenceNode.text !== simple
|
||||
) {
|
||||
grouped['@reference.qualified-name'] = syntheticCapture(
|
||||
'@reference.qualified-name',
|
||||
referenceNode,
|
||||
referenceNode.text,
|
||||
);
|
||||
}
|
||||
grouped['@reference.name'] = syntheticCapture('@reference.name', base, simple);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* Go's veto on the global-name fallback — see
|
||||
* `ScopeResolver.isGlobalNameFallbackPlausible`.
|
||||
*
|
||||
* Go's visibility rules are unusually decidable from a file path and an
|
||||
* identifier, which is why this is the language the guard is most complete for:
|
||||
*
|
||||
* 1. A package IS a directory. Two files in the same directory see each
|
||||
* other's identifiers with no import and no qualification, so a same-
|
||||
* directory candidate is always plausible.
|
||||
* 2. An identifier is exported iff it begins with an upper-case letter. An
|
||||
* UNEXPORTED identifier is invisible outside its own package — no import
|
||||
* makes it reachable, so a cross-directory candidate with a lower-case
|
||||
* initial is not unlikely, it is IMPOSSIBLE.
|
||||
* 3. A bare exported identifier from another package requires a DOT import.
|
||||
* Ordinary, alias, and blank imports never introduce a bare callable.
|
||||
*
|
||||
* Rule 2 is the one that matters most in practice: `uniqueHelperXyz` defined
|
||||
* once in package `a` used to acquire a caller in package `b` purely because
|
||||
* the name was unique in the repo, and the resulting edge was published as
|
||||
* `import-resolved` — a caller Go itself would reject.
|
||||
*
|
||||
* Methods require a receiver and never qualify for this bare-name tier.
|
||||
*/
|
||||
|
||||
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
|
||||
import {
|
||||
modulePathReaches,
|
||||
directoryOf,
|
||||
} from '../../scope-resolution/utils/name-fallback-visibility.js';
|
||||
import { inferGoPackageName } from './package-clause.js';
|
||||
|
||||
/**
|
||||
* Compare actual package clauses, not a guessed `_test` suffix convention:
|
||||
* `foo_test` can itself be a production package name. Missing source remains
|
||||
* undecidable, while the test-file boundary is always available from the path.
|
||||
*/
|
||||
function classifyGoFile(
|
||||
filePath: string,
|
||||
sourceTextOf: ((p: string) => string | undefined) | undefined,
|
||||
): { isTest: boolean; declared: string | undefined } {
|
||||
const isTest = filePath.endsWith('_test.go');
|
||||
const text = sourceTextOf?.(filePath);
|
||||
return {
|
||||
isTest,
|
||||
declared: text === undefined ? undefined : (inferGoPackageName(text) ?? undefined),
|
||||
};
|
||||
}
|
||||
|
||||
/** Go export rule: an upper-case initial, by Unicode letter case. */
|
||||
function isExportedGoName(name: string): boolean {
|
||||
return /^\p{Lu}/u.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* The simple identifier a Go declaration contributes to its package scope: the
|
||||
* last segment of `qualifiedName`, which is `Type.method` for a method and the
|
||||
* bare identifier for a function. Either way the LAST segment is the identifier
|
||||
* whose case decides export.
|
||||
*/
|
||||
function goSimpleName(candidate: SymbolDefinition): string {
|
||||
const qualified = candidate.qualifiedName ?? '';
|
||||
const dot = qualified.lastIndexOf('.');
|
||||
return dot === -1 ? qualified : qualified.slice(dot + 1);
|
||||
}
|
||||
|
||||
export function goIsGlobalNameFallbackPlausible(ctx: {
|
||||
readonly sourceTextOf?: (filePath: string) => string | undefined;
|
||||
readonly callerParsed: ParsedFile;
|
||||
readonly candidate: SymbolDefinition;
|
||||
readonly site?: { readonly rawQualifiedName?: string };
|
||||
}): boolean {
|
||||
// A PATH-QUALIFIED call (`pkg.Export()`) reached this tier because the
|
||||
// qualifier could not be followed. Qualification removes the bare-name
|
||||
// "only a dot import introduces this identifier" rule — not export or
|
||||
// test-package visibility. `other.private()` is still impossible.
|
||||
const isPackageQualified = ctx.site?.rawQualifiedName !== undefined;
|
||||
// Methods require a receiver, even within their own package or a dot import.
|
||||
if (ctx.candidate.type === 'Method' || ctx.candidate.qualifiedName?.includes('.')) return false;
|
||||
const callerDir = directoryOf(ctx.callerParsed.filePath);
|
||||
const candidateDir = directoryOf(ctx.candidate.filePath);
|
||||
// Same directory is NOT the same package (rule 1, refined): a `_test.go`
|
||||
// file may declare `foo_test`, and test-only declarations are invisible to
|
||||
// non-test files. Apply the split `package-siblings.ts` uses for the
|
||||
// confident tier, so the heuristic tier cannot reopen what it closed.
|
||||
if (callerDir === candidateDir) {
|
||||
const caller = classifyGoFile(ctx.callerParsed.filePath, ctx.sourceTextOf);
|
||||
const cand = classifyGoFile(ctx.candidate.filePath, ctx.sourceTextOf);
|
||||
// Non-test files never see test-only declarations.
|
||||
if (cand.isTest && !caller.isTest) return false;
|
||||
// Different clauses require an explicit dot import, even in one directory.
|
||||
// Fall through to the exported/import checks for external test packages.
|
||||
// Missing source leaves package identity undecidable.
|
||||
if (
|
||||
caller.declared === undefined ||
|
||||
cand.declared === undefined ||
|
||||
caller.declared === cand.declared
|
||||
)
|
||||
return true;
|
||||
}
|
||||
|
||||
// Different package — and a `_test.go` file's
|
||||
// declarations are compiled only into ITS OWN package's test binary. No other
|
||||
// package, test or not, can see them, exported or not. Decidable from the
|
||||
// path alone, so it comes before every exception below (the module-root
|
||||
// exception in particular used to accept a root `helper_test.go` export).
|
||||
if (classifyGoFile(ctx.candidate.filePath, ctx.sourceTextOf).isTest) return false;
|
||||
|
||||
const simpleName = goSimpleName(ctx.candidate);
|
||||
// No identifier to read the case of — an unanswered question, not a refusal.
|
||||
if (simpleName === '') return true;
|
||||
// Unexported across a package boundary (rule 2): no import can reach it.
|
||||
if (!isExportedGoName(simpleName)) return false;
|
||||
if (isPackageQualified) return true;
|
||||
|
||||
// Only a dot import introduces a bare name. A candidate in the module ROOT package has an empty
|
||||
// directory, which `modulePathReaches` cannot align against any import path
|
||||
// (the root package is imported by the module path alone, which the repo-
|
||||
// relative layout does not carry). That is an unanswered question, not a
|
||||
// refusal — a dot import is plausible even if its path cannot be aligned.
|
||||
return ctx.callerParsed.parsedImports.some(
|
||||
(imp) =>
|
||||
imp.kind === 'wildcard' &&
|
||||
(candidateDir === '' || modulePathReaches(imp.targetRaw, candidateDir)),
|
||||
);
|
||||
}
|
||||
|
|
@ -4,92 +4,189 @@ import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexe
|
|||
import { expandGoDotImports } from './expand-wildcards.js';
|
||||
import { goPackageDir, inferGoPackageName } from './package-clause.js';
|
||||
|
||||
function isGoTestFile(filePath: string): boolean {
|
||||
return filePath.endsWith('_test.go');
|
||||
}
|
||||
|
||||
/**
|
||||
* O(n²×d) where n = files per package, d = defs per file.
|
||||
* Acceptable for V1 since Go packages are typically small (< 20 files).
|
||||
* Future optimization: build a name→def inverted index per package to reduce
|
||||
* to O(n×d).
|
||||
* The package a `_test.go` file's clause belongs to, given the package names
|
||||
* the directory's NON-test files declare.
|
||||
*
|
||||
* `package foo_test` is the external-test convention ONLY when the directory's
|
||||
* real package is `foo`; the `_test` suffix is otherwise a legal identifier
|
||||
* (`package foo_test` in a directory whose non-test files also say `foo_test`).
|
||||
* Stripping it unconditionally keyed such a package's own internal tests as
|
||||
* external tests of a non-existent `foo`, so they saw no sibling at all — a
|
||||
* resolution miss on every same-package call. Strip only when the stripped
|
||||
* name is what the non-test siblings declare; with no non-test sibling to ask
|
||||
* (a test-only directory) the convention is assumed, as before.
|
||||
*/
|
||||
function testFilePackageOf(
|
||||
declared: string,
|
||||
nonTestPackagesInDir: ReadonlySet<string> | undefined,
|
||||
): { readonly pkg: string; readonly external: boolean } {
|
||||
if (!declared.endsWith('_test') || declared.length <= '_test'.length) {
|
||||
return { pkg: declared, external: false };
|
||||
}
|
||||
const stripped = declared.slice(0, -'_test'.length);
|
||||
if (nonTestPackagesInDir !== undefined && nonTestPackagesInDir.has(declared)) {
|
||||
return { pkg: declared, external: false };
|
||||
}
|
||||
if (nonTestPackagesInDir === undefined || nonTestPackagesInDir.has(stripped)) {
|
||||
return { pkg: stripped, external: true };
|
||||
}
|
||||
// Neither name is declared by a non-test sibling: keep the clause as written.
|
||||
return { pkg: declared, external: false };
|
||||
}
|
||||
|
||||
interface IndexedDef {
|
||||
readonly filePath: string;
|
||||
readonly ref: BindingRef;
|
||||
}
|
||||
|
||||
/** name → defs, in package file order. */
|
||||
type NameIndex = Map<string, IndexedDef[]>;
|
||||
|
||||
function defBareName(def: SymbolDefinition): string {
|
||||
return def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
|
||||
}
|
||||
|
||||
function appendToIndex(
|
||||
index: NameIndex,
|
||||
filePath: string,
|
||||
defs: readonly SymbolDefinition[],
|
||||
): void {
|
||||
for (const def of defs) {
|
||||
const name = defBareName(def);
|
||||
if (name === '') continue;
|
||||
const list = index.get(name) ?? [];
|
||||
list.push({ filePath, ref: { def, origin: 'namespace' } });
|
||||
index.set(name, list);
|
||||
}
|
||||
}
|
||||
|
||||
function publishIndex(
|
||||
augmentations: Map<ScopeId, Map<string, BindingRef[]>>,
|
||||
index: NameIndex,
|
||||
receiverPath: string,
|
||||
receiverModule: ScopeId,
|
||||
): void {
|
||||
if (index.size === 0) return;
|
||||
let scopeBindings = augmentations.get(receiverModule);
|
||||
if (scopeBindings === undefined) {
|
||||
scopeBindings = new Map<string, BindingRef[]>();
|
||||
augmentations.set(receiverModule, scopeBindings);
|
||||
}
|
||||
for (const [name, entries] of index) {
|
||||
let bucket = scopeBindings.get(name);
|
||||
const seen =
|
||||
bucket === undefined ? new Set<string>() : new Set(bucket.map((b) => b.def.nodeId));
|
||||
for (const entry of entries) {
|
||||
if (entry.filePath === receiverPath) continue;
|
||||
if (seen.has(entry.ref.def.nodeId)) continue;
|
||||
if (bucket === undefined) {
|
||||
bucket = [];
|
||||
scopeBindings.set(name, bucket);
|
||||
}
|
||||
bucket.push(entry.ref);
|
||||
seen.add(entry.ref.def.nodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish same-package sibling bindings, including `_test.go` files.
|
||||
*
|
||||
* Internal tests (`package foo`) see production and other internal-test names.
|
||||
* External tests (`package foo_test`) get no bare-name bindings across that
|
||||
* partition — qualified `foo.X` and `import .` stay on the import resolver.
|
||||
* Non-test files never see test-only helpers (`go build` does not compile them).
|
||||
*
|
||||
* Per-package name→def indexes are built in O(n×d). Each receiver walks only
|
||||
* the partitions it can see, so defs are not re-scanned against every sibling
|
||||
* file. Binding refs are allocated once and reused across receivers.
|
||||
*/
|
||||
export function populateGoPackageSiblings(
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
indexes: ScopeResolutionIndexes,
|
||||
ctx: { readonly fileContents: ReadonlyMap<string, string> },
|
||||
): void {
|
||||
// 0. Filter out test files — Go _test.go files should not contribute
|
||||
// same-package sibling bindings to non-test files.
|
||||
const nonTestFiles = parsedFiles.filter((f) => !f.filePath.endsWith('_test.go'));
|
||||
|
||||
// 1. Expand dot imports first so subsequent same-package sibling
|
||||
// augmentation can also see dot-imported names.
|
||||
expandGoDotImports(nonTestFiles, indexes);
|
||||
// augmentation can also see dot-imported names. Test files dot-import too.
|
||||
expandGoDotImports(parsedFiles, indexes);
|
||||
|
||||
// 2. Group files by package directory plus package name. Go package
|
||||
// identity is directory-scoped; repeated `package main` directories
|
||||
// must not see each other's unqualified names.
|
||||
const packageByFile = new Map<string, string>();
|
||||
for (const parsed of nonTestFiles) {
|
||||
// Same derivation as `populateGoWorkspaceOwners` — one shared resolver, so
|
||||
// the two passes cannot disagree about a file's package (#2837). The
|
||||
// no-clause case is reported there; warning twice for one fact would be
|
||||
// noise.
|
||||
const pkgName = inferGoPackageName(ctx.fileContents.get(parsed.filePath) ?? '');
|
||||
if (pkgName !== null) {
|
||||
packageByFile.set(parsed.filePath, `${goPackageDir(parsed.filePath)}\0${pkgName}`);
|
||||
}
|
||||
//
|
||||
// `_test.go` files join the INTERNAL package's bucket (a `foo_test`
|
||||
// external test is keyed by `foo`, its `external` flag marking the
|
||||
// partition so no bare-name bindings cross it), so one bucket holds
|
||||
// everything the test binary compiles together, and the visibility
|
||||
// rules below decide who sees whom.
|
||||
interface SiblingFile {
|
||||
readonly filePath: string;
|
||||
readonly defs: readonly SymbolDefinition[];
|
||||
readonly isTest: boolean;
|
||||
readonly external: boolean;
|
||||
}
|
||||
const filesByPackage = new Map<string, SiblingFile[]>();
|
||||
// Same derivation as `populateGoWorkspaceOwners` — one shared resolver, so
|
||||
// the two passes cannot disagree about a file's package (#2837). The
|
||||
// no-clause case is reported there; warning twice for one fact would be
|
||||
// noise.
|
||||
const declaredByFile = new Map<string, string>();
|
||||
const nonTestPackagesByDir = new Map<string, Set<string>>();
|
||||
for (const parsed of parsedFiles) {
|
||||
const declared = inferGoPackageName(ctx.fileContents.get(parsed.filePath) ?? '');
|
||||
if (declared === null) continue;
|
||||
declaredByFile.set(parsed.filePath, declared);
|
||||
if (isGoTestFile(parsed.filePath)) continue;
|
||||
const dir = goPackageDir(parsed.filePath);
|
||||
const names = nonTestPackagesByDir.get(dir) ?? new Set<string>();
|
||||
names.add(declared);
|
||||
nonTestPackagesByDir.set(dir, names);
|
||||
}
|
||||
for (const parsed of parsedFiles) {
|
||||
const declared = declaredByFile.get(parsed.filePath);
|
||||
if (declared === undefined) continue;
|
||||
const isTest = isGoTestFile(parsed.filePath);
|
||||
const dir = goPackageDir(parsed.filePath);
|
||||
const { pkg, external } = isTest
|
||||
? testFilePackageOf(declared, nonTestPackagesByDir.get(dir))
|
||||
: { pkg: declared, external: false };
|
||||
const key = `${dir}\0${pkg}`;
|
||||
const list = filesByPackage.get(key) ?? [];
|
||||
list.push({ filePath: parsed.filePath, defs: parsed.localDefs, isTest, external });
|
||||
filesByPackage.set(key, list);
|
||||
}
|
||||
|
||||
const filesByPackage = new Map<string, { filePath: string; defs: SymbolDefinition[] }[]>();
|
||||
for (const parsed of nonTestFiles) {
|
||||
const pkgName = packageByFile.get(parsed.filePath);
|
||||
if (pkgName === undefined) continue;
|
||||
const list = filesByPackage.get(pkgName) ?? [];
|
||||
list.push({ filePath: parsed.filePath, defs: [...parsed.localDefs] });
|
||||
filesByPackage.set(pkgName, list);
|
||||
}
|
||||
|
||||
// 2. Use bindingAugmentations channel per I8
|
||||
// 3. Use bindingAugmentations channel per I8. Same-package files see ALL
|
||||
// sibling names (exported and unexported). Cross-package visibility is
|
||||
// the import resolver's job.
|
||||
const augmentations = indexes.bindingAugmentations as Map<ScopeId, Map<string, BindingRef[]>>;
|
||||
|
||||
for (const [, siblings] of filesByPackage) {
|
||||
for (const target of siblings) {
|
||||
const targetModule = indexes.moduleScopes.byFilePath.get(target.filePath);
|
||||
if (targetModule === undefined) continue;
|
||||
|
||||
for (const receiver of siblings) {
|
||||
if (receiver.filePath === target.filePath) continue; // no self-reference
|
||||
const receiverModule = indexes.moduleScopes.byFilePath.get(receiver.filePath);
|
||||
if (receiverModule === undefined) continue;
|
||||
|
||||
for (const def of target.defs) {
|
||||
// Go: same-package sibling files can see ALL names (both
|
||||
// exported/uppercase and unexported/lowercase). Only cross-
|
||||
// package visibility requires uppercase first letter.
|
||||
const name = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
|
||||
if (name === '') continue;
|
||||
|
||||
const bucket = getAugmentationBucket(augmentations, receiverModule, name);
|
||||
if (bucket.some((b) => b.def.nodeId === def.nodeId)) continue;
|
||||
bucket.push({ def, origin: 'namespace' });
|
||||
}
|
||||
for (const siblings of filesByPackage.values()) {
|
||||
if (siblings.length < 2) continue;
|
||||
const production: NameIndex = new Map();
|
||||
const internalTest: NameIndex = new Map();
|
||||
const external: NameIndex = new Map();
|
||||
const receivers: { file: SiblingFile; module: ScopeId }[] = [];
|
||||
for (const file of siblings) {
|
||||
const module = indexes.moduleScopes.byFilePath.get(file.filePath);
|
||||
if (module === undefined) continue;
|
||||
receivers.push({ file, module });
|
||||
if (file.external) appendToIndex(external, file.filePath, file.defs);
|
||||
else if (file.isTest) appendToIndex(internalTest, file.filePath, file.defs);
|
||||
else appendToIndex(production, file.filePath, file.defs);
|
||||
}
|
||||
for (const { file, module } of receivers) {
|
||||
if (file.external) {
|
||||
publishIndex(augmentations, external, file.filePath, module);
|
||||
continue;
|
||||
}
|
||||
publishIndex(augmentations, production, file.filePath, module);
|
||||
if (file.isTest) publishIndex(augmentations, internalTest, file.filePath, module);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getAugmentationBucket(
|
||||
augmentations: Map<ScopeId, Map<string, BindingRef[]>>,
|
||||
scopeId: ScopeId,
|
||||
name: string,
|
||||
): BindingRef[] {
|
||||
let scopeBindings = augmentations.get(scopeId);
|
||||
if (scopeBindings === undefined) {
|
||||
scopeBindings = new Map<string, BindingRef[]>();
|
||||
augmentations.set(scopeId, scopeBindings);
|
||||
}
|
||||
let bucketArr = scopeBindings.get(name);
|
||||
if (bucketArr === undefined) {
|
||||
bucketArr = [];
|
||||
scopeBindings.set(name, bucketArr);
|
||||
}
|
||||
return bucketArr;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { detectGoInterfaceImplementations } from './interface-impls.js';
|
|||
import { populateGoRangeBindings } from './range-binding.js';
|
||||
import { expandGoWildcardNames } from './expand-wildcards.js';
|
||||
import { goMapValueType } from './interpret.js';
|
||||
import { goIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js';
|
||||
|
||||
/** Slice `[]T` and array `[N]T` / `[...]T` → the element spelling. Hoisted —
|
||||
* a literal inside the hook would mint a fresh RegExp per folded subscript. */
|
||||
|
|
@ -85,6 +86,7 @@ export const goScopeResolver: ScopeResolver = {
|
|||
hoistTypeBindingsToModule: true,
|
||||
propagatesReturnTypesAcrossImports: true,
|
||||
allowGlobalFreeCallFallback: true,
|
||||
isGlobalNameFallbackPlausible: goIsGlobalNameFallbackPlausible,
|
||||
|
||||
populateNamespaceSiblings: populateGoPackageSiblings,
|
||||
mirrorNamespaceTypeBindings: mirrorGoNamespaceTypeBindings,
|
||||
|
|
|
|||
|
|
@ -357,6 +357,26 @@ export function emitJavaScopeCaptures(
|
|||
}
|
||||
}
|
||||
|
||||
// Qualified constructor `new pkg.Foo()` already binds the simple tail as
|
||||
// `@reference.name` (F35). Copy the scoped spelling onto
|
||||
// `@reference.qualified-name` so constructor resolution can treat it as a
|
||||
// written qualifier instead of a bare unique-name guess (C# does the same).
|
||||
const ctorQualifiedNode = nodeMap['@reference.call.constructor.qualified'];
|
||||
const ctorQualifiedText = grouped['@reference.call.constructor.qualified']?.text;
|
||||
if (
|
||||
ctorQualifiedNode !== undefined &&
|
||||
ctorQualifiedText !== undefined &&
|
||||
ctorQualifiedText.length > 0 &&
|
||||
ctorQualifiedText !== grouped['@reference.name']?.text &&
|
||||
grouped['@reference.qualified-name'] === undefined
|
||||
) {
|
||||
grouped['@reference.qualified-name'] = syntheticCapture(
|
||||
'@reference.qualified-name',
|
||||
ctorQualifiedNode,
|
||||
ctorQualifiedText,
|
||||
);
|
||||
}
|
||||
|
||||
// Synthesize `@reference.arity` on every callsite.
|
||||
const callTag = (
|
||||
['@reference.call.free', '@reference.call.member', '@reference.call.constructor'] as const
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
syntheticCapture,
|
||||
type SyntaxNode,
|
||||
} from '../../utils/ast-helpers.js';
|
||||
import { collectEsmExportEvidence, esmExportVerdict } from '../../ts-js-export-marker.js';
|
||||
import { splitImportStatement } from '../typescript/import-decomposer.js';
|
||||
import { getJsParser, getJsScopeQuery, jsCachedTreeMatchesGrammar } from './query.js';
|
||||
import { computeTsArityMetadata } from '../typescript/arity-metadata.js';
|
||||
|
|
@ -983,6 +984,8 @@ export function emitJsScopeCaptures(
|
|||
}
|
||||
|
||||
const rawMatches = getJsScopeQuery(filePath).matches(tree.rootNode);
|
||||
// Export evidence, read once per file (see `ts-js-export-marker.ts`).
|
||||
const exportEvidence = collectEsmExportEvidence(tree.rootNode, filePath);
|
||||
const out: CaptureMatch[] = [];
|
||||
|
||||
for (const m of rawMatches) {
|
||||
|
|
@ -1124,6 +1127,13 @@ export function emitJsScopeCaptures(
|
|||
fnNode,
|
||||
deriveDefaultExportHocName(filePath),
|
||||
);
|
||||
// This declaration's name is synthetic, so the later query-name
|
||||
// marker cannot see it. The HOC predicate already proves the export.
|
||||
grouped['@declaration.is-exported'] = syntheticCapture(
|
||||
'@declaration.is-exported',
|
||||
fnNode,
|
||||
'true',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1207,6 +1217,20 @@ export function emitJsScopeCaptures(
|
|||
// non-call match, an absent receiver, or a chain with no nameable base
|
||||
// all leave `grouped` untouched.
|
||||
synthesizeReceiverChainCapture(grouped, groupedNodes['@reference.receiver']);
|
||||
// `@declaration.is-exported`: a verdict for every declaration the file's
|
||||
// export surface can decide (see `ts-js-export-marker.ts`); nothing where
|
||||
// it cannot, because absence is the honest answer there.
|
||||
const declNameNode = groupedNodes['@declaration.name'];
|
||||
if (exportEvidence !== undefined && declNameNode !== undefined) {
|
||||
const verdict = esmExportVerdict(declNameNode, exportEvidence);
|
||||
if (verdict !== undefined) {
|
||||
grouped['@declaration.is-exported'] = syntheticCapture(
|
||||
'@declaration.is-exported',
|
||||
declNameNode,
|
||||
verdict ? 'true' : 'false',
|
||||
);
|
||||
}
|
||||
}
|
||||
out.push(grouped);
|
||||
|
||||
// Synthesize `this` receiver type-bindings on class member functions.
|
||||
|
|
|
|||
|
|
@ -97,6 +97,12 @@ const javascriptScopeResolver: ScopeResolver = {
|
|||
// explicit imports at the call site. Workspace-wide unique-name fallback
|
||||
// recovers these edges.
|
||||
allowGlobalFreeCallFallback: true,
|
||||
|
||||
// Same ECMAScript `export *` exclusivity as TypeScript: a name declared by
|
||||
// two wildcard sources is refused, not guessed.
|
||||
exclusiveWildcardReexports: true,
|
||||
// Same ECMAScript rule as TypeScript: named imports bind module-level declarations only.
|
||||
namedImportsBindTopLevelOnly: true,
|
||||
};
|
||||
|
||||
export { javascriptScopeResolver };
|
||||
|
|
|
|||
|
|
@ -3,13 +3,19 @@
|
|||
*
|
||||
* Ruby import resolution rules:
|
||||
* - `require_relative './foo'` → resolve relative to the importing file's dir
|
||||
* - `require 'foo'` → suffix-match via the existing Ruby import resolver
|
||||
* - External gems → null (unresolvable within the repo)
|
||||
* - `require 'foo'` → use scoped gem metadata before legacy suffix matching
|
||||
* - Known local gems → resolve only within their declared load roots
|
||||
* - Known external gems → null, even if an unrelated repo file suffix matches
|
||||
*/
|
||||
|
||||
import { resolveRubyImportInternal } from '../../import-resolvers/ruby.js';
|
||||
import { getWorkspaceFileIndex } from '../../import-resolvers/workspace-file-index.js';
|
||||
import { isHeritageMarker } from '../../utils/heritage-marker.js';
|
||||
import {
|
||||
findRubyResolutionScope,
|
||||
type RubyResolutionConfig,
|
||||
type RubyResolutionScope,
|
||||
} from './resolution-config.js';
|
||||
|
||||
export interface RubyResolveContext {
|
||||
readonly fromFile: string;
|
||||
|
|
@ -26,16 +32,17 @@ export interface RubyResolveContext {
|
|||
* against the importing file's directory, trying `.rb` and `/index.rb`
|
||||
* suffixes.
|
||||
*
|
||||
* For bare requires (gem-style like `'json'`, `'serializable'`), delegates
|
||||
* to the existing `resolveRubyImportInternal` which uses suffix matching.
|
||||
*
|
||||
* Returns `null` for external gems that have no matching file in the repo.
|
||||
* For bare requires, scoped manifest metadata takes precedence: known local
|
||||
* gems resolve only within their declared load roots (a miss returns `null`),
|
||||
* and known external gem prefixes return `null` even if a repo suffix matches.
|
||||
* Only requires without matching gem evidence delegate to the existing
|
||||
* `resolveRubyImportInternal` suffix matcher.
|
||||
*/
|
||||
export function resolveRubyImportTarget(
|
||||
targetRaw: string,
|
||||
fromFile: string,
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
_resolutionConfig?: unknown,
|
||||
resolutionConfig?: unknown,
|
||||
): string | readonly string[] | null {
|
||||
if (!targetRaw) return null;
|
||||
if (isHeritageMarker(targetRaw)) return null;
|
||||
|
|
@ -51,7 +58,16 @@ export function resolveRubyImportTarget(
|
|||
return resolved;
|
||||
}
|
||||
|
||||
// ── require: bare/gem-style suffix matching ─────────────────────────
|
||||
// ── require: scoped gem evidence before repository-wide fallback ────
|
||||
const config = resolutionConfig as RubyResolutionConfig | null | undefined;
|
||||
const scope =
|
||||
config === null || config === undefined ? undefined : findRubyResolutionScope(config, fromFile);
|
||||
if (scope !== undefined) {
|
||||
const localGemTarget = resolveLocalGemTarget(targetRaw, scope, allFilePaths);
|
||||
if (localGemTarget !== undefined) return localGemTarget;
|
||||
if (matchesRequirePrefix(targetRaw, scope.externalRequirePrefixes)) return null;
|
||||
}
|
||||
|
||||
return resolveBare(targetRaw, allFilePaths);
|
||||
}
|
||||
|
||||
|
|
@ -95,6 +111,60 @@ function resolveRelative(
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Yield the require stem, then each slash-delimited ancestor. This makes both
|
||||
* local-root and external-gem lookup O(require path depth), not O(gem count).
|
||||
* A trailing `.rb` is stripped first so `require 'my_engine.rb'` matches the
|
||||
* configured prefix `my_engine`, matching Ruby's optional-suffix require.
|
||||
*/
|
||||
function requirePrefixCandidates(targetRaw: string): readonly string[] {
|
||||
const stem = targetRaw.endsWith('.rb') ? targetRaw.slice(0, -3) : targetRaw;
|
||||
if (stem.length === 0) return [];
|
||||
const candidates = [stem];
|
||||
let slash = stem.lastIndexOf('/');
|
||||
while (slash !== -1) {
|
||||
candidates.push(stem.slice(0, slash));
|
||||
slash = stem.lastIndexOf('/', slash - 1);
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function matchesRequirePrefix(targetRaw: string, prefixes: ReadonlySet<string>): boolean {
|
||||
return requirePrefixCandidates(targetRaw).some((candidate) => prefixes.has(candidate));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a path/gemspec-backed gem against its declared Ruby load roots.
|
||||
* `undefined` means no local-gem prefix matched; `null` means one matched but
|
||||
* none of its declared load roots contained the target.
|
||||
*/
|
||||
function resolveLocalGemTarget(
|
||||
targetRaw: string,
|
||||
scope: RubyResolutionScope,
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
): string | null | undefined {
|
||||
for (const prefix of requirePrefixCandidates(targetRaw)) {
|
||||
const loadRoots = scope.localLoadRootsByPrefix.get(prefix);
|
||||
if (loadRoots === undefined) continue;
|
||||
|
||||
for (const loadRoot of loadRoots) {
|
||||
const base = loadRoot ? `${loadRoot}/${targetRaw}` : targetRaw;
|
||||
if (base.endsWith('.rb')) {
|
||||
if (allFilePaths.has(base)) return base;
|
||||
continue;
|
||||
}
|
||||
const rbFile = `${base}.rb`;
|
||||
if (allFilePaths.has(rbFile)) return rbFile;
|
||||
}
|
||||
|
||||
// The prefix is owned by a known local gem. If its declared roots do not
|
||||
// contain the target, repository-wide suffix matching would fabricate an
|
||||
// edge to an unrelated file.
|
||||
return null;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a bare require path (`'serializable'`, `'json'`, `'net/http'`)
|
||||
* via suffix matching using the existing Ruby import resolver.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,196 @@
|
|||
/**
|
||||
* Ruby's veto on the global-name fallback — see
|
||||
* `ScopeResolver.isGlobalNameFallbackPlausible`.
|
||||
*
|
||||
* Ruby keeps the labeled fallback deliberately: with autoload (Rails' zeitwerk,
|
||||
* `ActiveSupport::Dependencies`) a file genuinely can call a method whose
|
||||
* defining file it never requires, so "no require" is NOT evidence of
|
||||
* impossibility the way it is in Go or Rust.
|
||||
*
|
||||
* What IS decidable is namespacing — but only for CLASS bodies. A method
|
||||
* defined inside a `class` is not callable as a bare `helper()` from another
|
||||
* file unless that file names the class somehow (a receiver `Ns.helper`, a
|
||||
* subclass declaration, an `include`, a `require`). A method defined inside a
|
||||
* `module` body is different in kind: modules exist to be mixed in, and Rails
|
||||
* mixes them in for you — every `*Helper` module is included into views and
|
||||
* controllers by the framework, and concerns arrive through `included do`
|
||||
* hooks — so a bare `format_money()` in a view legitimately reaches
|
||||
* `module ApplicationHelper` with no `include`, `require`, or constant
|
||||
* anywhere in the caller. Refusing that would delete real edges on exactly the
|
||||
* codebase shape Ruby's fallback exists to serve. So module-owned methods stay
|
||||
* a LABELED guess, and the refusal targets CLASS-owned methods whose class the
|
||||
* caller never names. A top-level method (`ownerId === undefined`) is left
|
||||
* alone, since that is the shape autoload actually delivers. When the owner
|
||||
* cannot be found or typed (no `parsedFileOf`, owner outside the file set),
|
||||
* the question is unanswered and the edge is allowed.
|
||||
*
|
||||
* "Never names" is read from the caller's own text-visible signals — its
|
||||
* `require`/`include`/`extend` targets, and any reference site spelling the
|
||||
* namespace's constant. If any of them mentions the namespace, the call is
|
||||
* plausible and the labeled edge stands. `require` paths are snake_case
|
||||
* (`billing/invoice_service`) while constants are CamelCase
|
||||
* (`Billing::InvoiceService`), so the comparison normalizes both sides —
|
||||
* without that the `require` branch never matched anything.
|
||||
*
|
||||
* One more thing a caller file can do without naming the class: INHERIT its
|
||||
* way to it. `class UsersController < AdminController` reaches every method
|
||||
* `ApplicationController` defines while naming only `AdminController`, and an
|
||||
* `include Concern` reaches whatever that concern includes in turn. Neither
|
||||
* chain is decidable from one file, so a caller with ANY inheritance or mixin
|
||||
* surface (an `inherits` site, an `include`/`extend`/`prepend` marker) is
|
||||
* treated as plausible. So is a caller file that DEFINES a module: a module's
|
||||
* methods run against whatever class includes the module, and call that class's
|
||||
* methods bare — `module PostGuardian; def can_see?; is_staff? ...` reaches
|
||||
* `class Guardian#is_staff?` because Guardian includes PostGuardian, a fact the
|
||||
* module file never states. What remains refused is the shape Ruby itself
|
||||
* rejects: a bare call to a class's method from a file that inherits nothing,
|
||||
* mixes in nothing, defines no module, and never spells the class. Measured on
|
||||
* discourse@3f71fa15c that is ~1000 refusals, and the sample is what the rule
|
||||
* promises: RSpec `before`/`after`/`subject` guessed to serializer methods of
|
||||
* the same name, `Gemfile`'s `gem` to `Plugin::Instance#gem`, `routes.rb`'s
|
||||
* `get` to `Draft#get` — fabricated callers, every one.
|
||||
*/
|
||||
|
||||
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
|
||||
import { moduleSegments } from '../../scope-resolution/utils/name-fallback-visibility.js';
|
||||
import { HERITAGE_MARKER_PREFIX } from '../../utils/heritage-marker.js';
|
||||
|
||||
/**
|
||||
* The namespace segments a candidate's qualified name declares, minus the
|
||||
* method itself. `Billing::Invoice#total` / `Billing.Invoice.total` → the
|
||||
* `Billing`, `Invoice` constants a caller would have to name.
|
||||
*/
|
||||
function namespaceConstantsOf(candidate: SymbolDefinition): readonly string[] {
|
||||
const qualified = candidate.qualifiedName;
|
||||
if (qualified === undefined || qualified === '') return [];
|
||||
const segments = qualified
|
||||
.split(/::|\.|#/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s !== '');
|
||||
// Drop the trailing method name; what remains is the namespace chain.
|
||||
const namespace = segments.slice(0, -1);
|
||||
// Only CONSTANTS name a Ruby namespace (upper-case initial). A lower-case
|
||||
// segment is a receiver expression, not a namespace a caller can mention.
|
||||
return namespace.filter((s) => /^[A-Z]/.test(s));
|
||||
}
|
||||
|
||||
/**
|
||||
* `billing/invoice_service` vs `InvoiceService`: a path segment names a
|
||||
* constant when, with underscores removed, the two are equal case-insensitively.
|
||||
* Zeitwerk's own inflection rule, minus acronym overrides — over-matching here
|
||||
* only loses a refusal (the edge stays, labeled), which is the safe direction.
|
||||
*/
|
||||
function pathSegmentNamesConstant(segment: string, constant: string): boolean {
|
||||
return segment.replace(/_/g, '').toLowerCase() === constant.toLowerCase();
|
||||
}
|
||||
|
||||
function requireReachesConstant(targetRaw: string, constant: string): boolean {
|
||||
for (const segment of moduleSegments(targetRaw)) {
|
||||
if (pathSegmentNamesConstant(segment, constant)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The declared kind of the candidate's owner, read from the candidate's own
|
||||
* file. Ruby labels `class` bodies `Class` and `module` bodies `Trait`
|
||||
* (`query.ts`: "module (labeled Trait for class-like registry lookup)").
|
||||
* `undefined` when the owner cannot be found — an unanswered question.
|
||||
*/
|
||||
function ownerLabelOf(
|
||||
candidate: SymbolDefinition,
|
||||
parsedFileOf: ((filePath: string) => ParsedFile | undefined) | undefined,
|
||||
): string | undefined {
|
||||
const ownerId = candidate.ownerId;
|
||||
if (ownerId === undefined || parsedFileOf === undefined) return undefined;
|
||||
const owner = parsedFileOf(candidate.filePath)?.localDefs.find((d) => d.nodeId === ownerId);
|
||||
return owner?.type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls that rebind `self` for the duration of a block: inside
|
||||
* `service.instance_eval do … end` a bare `helper()` is dispatched on
|
||||
* `service`, so it legitimately reaches a class-owned method the caller file
|
||||
* never names. Detected on the caller's SOURCE TEXT, not the call site — the
|
||||
* site does not know which block encloses it — so any file that uses one of
|
||||
* these forms keeps its class-owned guesses LABELED rather than refused. Coarse
|
||||
* in the safe direction: it loses refusals in that file, never an edge.
|
||||
*/
|
||||
const SELF_REBINDING_CALL =
|
||||
/\b(?:instance_eval|instance_exec|class_eval|class_exec|module_eval|module_exec)\b/;
|
||||
|
||||
export function rubyIsGlobalNameFallbackPlausible(ctx: {
|
||||
readonly callerParsed: ParsedFile;
|
||||
readonly candidate: SymbolDefinition;
|
||||
readonly parsedFileOf?: (filePath: string) => ParsedFile | undefined;
|
||||
readonly sourceTextOf?: (filePath: string) => string | undefined;
|
||||
}): boolean {
|
||||
if (ctx.candidate.filePath === ctx.callerParsed.filePath) return true;
|
||||
// Top-level method — the autoload shape the fallback exists for.
|
||||
if (ctx.candidate.ownerId === undefined) return true;
|
||||
// A self-rebinding block anywhere in the caller makes "the class is never
|
||||
// named here" no proof of impossibility (see `SELF_REBINDING_CALL`). The
|
||||
// pipeline always supplies the source; a missing text is an unanswered
|
||||
// question and keeps the labeled edge as well.
|
||||
const text = ctx.sourceTextOf?.(ctx.callerParsed.filePath);
|
||||
if (text === undefined || SELF_REBINDING_CALL.test(text)) return true;
|
||||
// Only a CLASS body makes a bare cross-file call impossible without naming
|
||||
// it (see the header). A module owner, or an owner we cannot type, is not a
|
||||
// refusal.
|
||||
if (ownerLabelOf(ctx.candidate, ctx.parsedFileOf) !== 'Class') return true;
|
||||
|
||||
const constants = namespaceConstantsOf(ctx.candidate);
|
||||
// Owned but with no nameable namespace (an anonymous or lower-cased owner):
|
||||
// nothing to check, so do not refuse on an unanswered question.
|
||||
if (constants.length === 0) return true;
|
||||
|
||||
// Any inheritance or mixin surface in the caller can reach the class
|
||||
// transitively (see the header) — not decidable here, so not refused.
|
||||
for (const site of ctx.callerParsed.referenceSites) {
|
||||
if (site.kind === 'inherits') return true;
|
||||
}
|
||||
for (const imp of ctx.callerParsed.parsedImports) {
|
||||
if (imp.targetRaw.startsWith(HERITAGE_MARKER_PREFIX)) return true;
|
||||
}
|
||||
// A file that defines a module is a mixin whose methods run inside some
|
||||
// including class (see the header). Ruby labels `module` bodies `Trait`.
|
||||
for (const def of ctx.callerParsed.localDefs) {
|
||||
if (def.type === 'Trait') return true;
|
||||
// A file that REOPENS `class Invoice` names the owner even without an
|
||||
// inherits site or a reference capture — the declaration itself is the
|
||||
// mention.
|
||||
if (def.type === 'Class') {
|
||||
const declared = (def.qualifiedName ?? '')
|
||||
.split(/::|\.|#/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s !== '')
|
||||
.at(-1);
|
||||
if (declared !== undefined && constants.includes(declared)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const imp of ctx.callerParsed.parsedImports) {
|
||||
for (const constant of constants) {
|
||||
if (requireReachesConstant(imp.targetRaw, constant)) return true;
|
||||
// `include Billing::Invoice` arrives as an import whose LOCAL name is the
|
||||
// constant rather than a path. Not every variant carries one.
|
||||
if ('localName' in imp && imp.localName === constant) return true;
|
||||
}
|
||||
}
|
||||
// A bare mention of the constant anywhere in the caller (`Billing::Invoice`,
|
||||
// `Invoice.new`) is enough to make the namespace present in this file. The
|
||||
// qualified spelling is matched SEGMENT-wise on `::` / `.`: `InvoiceService`
|
||||
// is not a mention of `Invoice`, and a substring test made it one.
|
||||
for (const site of ctx.callerParsed.referenceSites) {
|
||||
for (const constant of constants) {
|
||||
if (site.name === constant) return true;
|
||||
if (
|
||||
site.rawQualifiedName !== undefined &&
|
||||
site.rawQualifiedName.split(/::|\./).some((segment) => segment === constant)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
380
gitnexus/src/core/ingestion/languages/ruby/resolution-config.ts
Normal file
380
gitnexus/src/core/ingestion/languages/ruby/resolution-config.ts
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
import { readdirSync, readFileSync, type Dirent } from 'node:fs';
|
||||
import { isAbsolute, join, relative, resolve } from 'node:path';
|
||||
|
||||
export interface RubyResolutionScope {
|
||||
/** Require prefixes provided by gems whose source is outside this repository. */
|
||||
readonly externalRequirePrefixes: ReadonlySet<string>;
|
||||
/** Require prefix -> repository-relative Ruby load roots for local gems. */
|
||||
readonly localLoadRootsByPrefix: ReadonlyMap<string, readonly string[]>;
|
||||
}
|
||||
|
||||
export interface RubyResolutionConfig {
|
||||
/** Manifest directory (repository-relative POSIX path) -> dependency scope. */
|
||||
readonly scopesByDirectory: ReadonlyMap<string, RubyResolutionScope>;
|
||||
}
|
||||
|
||||
interface MutableRubyResolutionScope {
|
||||
readonly externalRequirePrefixes: Set<string>;
|
||||
readonly localLoadRootsByPrefix: Map<string, Set<string>>;
|
||||
}
|
||||
|
||||
interface GemspecMetadata {
|
||||
readonly name: string | null;
|
||||
readonly requirePaths: readonly string[];
|
||||
}
|
||||
|
||||
const MAX_VISITED_DIRECTORIES = 20_000;
|
||||
const SKIP_DIRECTORIES = new Set([
|
||||
'.git',
|
||||
'.gitnexus',
|
||||
'node_modules',
|
||||
'vendor',
|
||||
'dist',
|
||||
'build',
|
||||
'coverage',
|
||||
]);
|
||||
|
||||
const GEMFILE_DEPENDENCY = /^\s*gem\s*(?:\(\s*)?(['"])([A-Za-z0-9_.-]+)\1(?<options>.*)$/;
|
||||
const GEMSPEC_DEPENDENCY =
|
||||
/^\s*(?:[A-Za-z_]\w*\.)?(?:add_dependency|add_runtime_dependency|add_development_dependency)\s*(?:\(\s*)?(['"])([A-Za-z0-9_.-]+)\1/;
|
||||
const GEMSPEC_NAME = /^\s*(?:[A-Za-z_]\w*\.)?name\s*=\s*(['"])([A-Za-z0-9_.-]+)\1/;
|
||||
const GEMSPEC_REQUIRE_PATHS = /^\s*(?:[A-Za-z_]\w*\.)?require_paths\s*=\s*\[([^\]]*)\]/;
|
||||
const QUOTED_LITERAL = /(['"])([^'"]+)\1/g;
|
||||
const LOCKFILE_SECTION = /^([A-Z][A-Z ]*)\s*$/;
|
||||
const LOCKFILE_REMOTE = /^ {2}remote:\s*(.+?)\s*$/;
|
||||
const LOCKFILE_SPECS_HEADER = /^ {2}specs:\s*$/;
|
||||
const LOCKFILE_SPEC = /^ {4}([A-Za-z0-9_.-]+) \(/;
|
||||
|
||||
function createMutableScope(): MutableRubyResolutionScope {
|
||||
return {
|
||||
externalRequirePrefixes: new Set<string>(),
|
||||
localLoadRootsByPrefix: new Map<string, Set<string>>(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRepoPath(filePath: string): string {
|
||||
return filePath.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function repoRelativePath(repoPath: string, candidate: string): string | null {
|
||||
const rel = relative(resolve(repoPath), resolve(candidate));
|
||||
if (rel === '') return '';
|
||||
if (rel === '..' || rel.startsWith(`..\\`) || rel.startsWith('../') || isAbsolute(rel)) {
|
||||
return null;
|
||||
}
|
||||
return normalizeRepoPath(rel);
|
||||
}
|
||||
|
||||
function requirePrefixes(gemName: string, requireAs?: string): readonly string[] {
|
||||
const prefixes = new Set<string>([gemName]);
|
||||
if (gemName.includes('-')) prefixes.add(gemName.replaceAll('-', '/'));
|
||||
if (requireAs !== undefined && requireAs.length > 0) prefixes.add(requireAs);
|
||||
return [...prefixes];
|
||||
}
|
||||
|
||||
function literalOption(options: string, name: string): string | undefined {
|
||||
const modern = new RegExp(`(?:^|[,\\s])${name}\\s*:\\s*(['"])([^'"]+)\\1`).exec(options);
|
||||
if (modern !== null) return modern[2];
|
||||
const hashRocket = new RegExp(`(?:^|[,\\s]):${name}\\s*=>\\s*(['"])([^'"]+)\\1`).exec(options);
|
||||
return hashRocket?.[2];
|
||||
}
|
||||
|
||||
function hasOption(options: string, name: string): boolean {
|
||||
return (
|
||||
new RegExp(`(?:^|[,\\s])${name}\\s*:`).test(options) ||
|
||||
new RegExp(`(?:^|[,\\s]):${name}\\s*=>`).test(options)
|
||||
);
|
||||
}
|
||||
|
||||
function addExternalGem(
|
||||
scope: MutableRubyResolutionScope,
|
||||
gemName: string,
|
||||
requireAs?: string,
|
||||
): void {
|
||||
for (const prefix of requirePrefixes(gemName, requireAs)) {
|
||||
if (!scope.localLoadRootsByPrefix.has(prefix)) scope.externalRequirePrefixes.add(prefix);
|
||||
}
|
||||
}
|
||||
|
||||
function addLocalGem(
|
||||
scope: MutableRubyResolutionScope,
|
||||
gemName: string,
|
||||
loadRoots: readonly string[],
|
||||
requireAs?: string,
|
||||
): void {
|
||||
for (const prefix of requirePrefixes(gemName, requireAs)) {
|
||||
scope.externalRequirePrefixes.delete(prefix);
|
||||
let roots = scope.localLoadRootsByPrefix.get(prefix);
|
||||
if (roots === undefined) {
|
||||
roots = new Set<string>();
|
||||
scope.localLoadRootsByPrefix.set(prefix, roots);
|
||||
}
|
||||
for (const loadRoot of loadRoots) roots.add(loadRoot);
|
||||
}
|
||||
}
|
||||
|
||||
function parseGemspecMetadata(contents: string): GemspecMetadata {
|
||||
let name: string | null = null;
|
||||
let requirePaths: string[] | null = null;
|
||||
|
||||
for (const line of contents.split(/\r?\n/)) {
|
||||
const nameMatch = GEMSPEC_NAME.exec(line);
|
||||
if (nameMatch !== null) name = nameMatch[2];
|
||||
|
||||
const requirePathsMatch = GEMSPEC_REQUIRE_PATHS.exec(line);
|
||||
if (requirePathsMatch === null) continue;
|
||||
const paths: string[] = [];
|
||||
for (const match of requirePathsMatch[1].matchAll(QUOTED_LITERAL)) paths.push(match[2]);
|
||||
if (paths.length > 0) requirePaths = paths;
|
||||
}
|
||||
|
||||
return { name, requirePaths: requirePaths ?? ['lib'] };
|
||||
}
|
||||
|
||||
function loadLocalGemRoots(
|
||||
repoPath: string,
|
||||
gemRoot: string,
|
||||
expectedGemName: string,
|
||||
): readonly string[] | null {
|
||||
const gemRootRelative = repoRelativePath(repoPath, gemRoot);
|
||||
if (gemRootRelative === null) return null;
|
||||
|
||||
let requirePaths: readonly string[] = ['lib'];
|
||||
try {
|
||||
const gemspecs = readdirSync(gemRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.gemspec'))
|
||||
.map((entry) => join(gemRoot, entry.name))
|
||||
.sort();
|
||||
for (const gemspec of gemspecs) {
|
||||
const metadata = parseGemspecMetadata(readFileSync(gemspec, 'utf8'));
|
||||
if (metadata.name === expectedGemName) {
|
||||
requirePaths = metadata.requirePaths;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// An unreadable local gem stays local; the conventional lib root is a
|
||||
// bounded best effort, and resolution falls through if no file matches.
|
||||
}
|
||||
|
||||
return requirePaths
|
||||
.map((requirePath) => repoRelativePath(repoPath, resolve(gemRoot, requirePath)))
|
||||
.filter((loadRoot): loadRoot is string => loadRoot !== null);
|
||||
}
|
||||
|
||||
function addGemfileDependencies(
|
||||
contents: string,
|
||||
manifestDirectory: string,
|
||||
repoPath: string,
|
||||
scope: MutableRubyResolutionScope,
|
||||
): void {
|
||||
for (const line of contents.split(/\r?\n/)) {
|
||||
const match = GEMFILE_DEPENDENCY.exec(line);
|
||||
if (match === null) continue;
|
||||
|
||||
const gemName = match[2];
|
||||
const options = match.groups?.options ?? '';
|
||||
const requireAs = literalOption(options, 'require');
|
||||
const localPath = literalOption(options, 'path');
|
||||
|
||||
if (localPath !== undefined) {
|
||||
const loadRoots = loadLocalGemRoots(repoPath, resolve(manifestDirectory, localPath), gemName);
|
||||
if (loadRoots === null) addExternalGem(scope, gemName, requireAs);
|
||||
else addLocalGem(scope, gemName, loadRoots, requireAs);
|
||||
continue;
|
||||
}
|
||||
|
||||
// A dynamic path expression cannot be classified without evaluating Ruby.
|
||||
// Fail open instead of deleting a potentially real in-repo import edge.
|
||||
if (hasOption(options, 'path')) continue;
|
||||
|
||||
addExternalGem(scope, gemName, requireAs);
|
||||
}
|
||||
}
|
||||
|
||||
function addGemspecDependencies(
|
||||
contents: string,
|
||||
gemspecDirectory: string,
|
||||
repoPath: string,
|
||||
scope: MutableRubyResolutionScope,
|
||||
): void {
|
||||
const metadata = parseGemspecMetadata(contents);
|
||||
if (metadata.name !== null) {
|
||||
if (repoRelativePath(repoPath, gemspecDirectory) !== null) {
|
||||
const loadRoots = metadata.requirePaths
|
||||
.map((requirePath) => repoRelativePath(repoPath, resolve(gemspecDirectory, requirePath)))
|
||||
.filter((loadRoot): loadRoot is string => loadRoot !== null);
|
||||
addLocalGem(scope, metadata.name, loadRoots);
|
||||
}
|
||||
}
|
||||
|
||||
for (const line of contents.split(/\r?\n/)) {
|
||||
const match = GEMSPEC_DEPENDENCY.exec(line);
|
||||
if (match !== null) addExternalGem(scope, match[2]);
|
||||
}
|
||||
}
|
||||
|
||||
function addLockedDependencies(
|
||||
contents: string,
|
||||
lockfileDirectory: string,
|
||||
repoPath: string,
|
||||
scope: MutableRubyResolutionScope,
|
||||
): void {
|
||||
let section = '';
|
||||
let remote: string | null = null;
|
||||
let inSpecs = false;
|
||||
|
||||
for (const line of contents.split(/\r?\n/)) {
|
||||
const sectionMatch = LOCKFILE_SECTION.exec(line);
|
||||
if (sectionMatch !== null) {
|
||||
section = sectionMatch[1];
|
||||
remote = null;
|
||||
inSpecs = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const remoteMatch = LOCKFILE_REMOTE.exec(line);
|
||||
if (remoteMatch !== null) {
|
||||
remote = remoteMatch[1];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (LOCKFILE_SPECS_HEADER.test(line)) {
|
||||
inSpecs = true;
|
||||
continue;
|
||||
}
|
||||
if (!inSpecs) continue;
|
||||
|
||||
const specMatch = LOCKFILE_SPEC.exec(line);
|
||||
if (specMatch === null) continue;
|
||||
const gemName = specMatch[1];
|
||||
|
||||
if (section === 'GEM' || section === 'GIT') {
|
||||
addExternalGem(scope, gemName);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (section !== 'PATH' && section !== 'GEMSPEC') continue;
|
||||
if (remote === null) continue;
|
||||
|
||||
const loadRoots = loadLocalGemRoots(repoPath, resolve(lockfileDirectory, remote), gemName);
|
||||
if (loadRoots === null) addExternalGem(scope, gemName);
|
||||
else addLocalGem(scope, gemName, loadRoots);
|
||||
}
|
||||
}
|
||||
|
||||
function freezeScope(scope: MutableRubyResolutionScope): RubyResolutionScope {
|
||||
const localLoadRootsByPrefix = new Map<string, readonly string[]>();
|
||||
for (const [prefix, roots] of scope.localLoadRootsByPrefix) {
|
||||
localLoadRootsByPrefix.set(prefix, [...roots].sort());
|
||||
}
|
||||
return {
|
||||
externalRequirePrefixes: new Set(scope.externalRequirePrefixes),
|
||||
localLoadRootsByPrefix,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the nearest manifest directory that owns `fromFile`.
|
||||
*
|
||||
* Walking ancestors makes lookup O(path depth), independent of how many
|
||||
* sibling Gemfiles a monorepo contains.
|
||||
*/
|
||||
export function findRubyResolutionScope(
|
||||
config: RubyResolutionConfig,
|
||||
fromFile: string,
|
||||
): RubyResolutionScope | undefined {
|
||||
const normalized = normalizeRepoPath(fromFile);
|
||||
let directory = normalized.includes('/') ? normalized.slice(0, normalized.lastIndexOf('/')) : '';
|
||||
|
||||
for (;;) {
|
||||
const scope = config.scopesByDirectory.get(directory);
|
||||
if (scope !== undefined) return scope;
|
||||
if (directory === '') return undefined;
|
||||
const slash = directory.lastIndexOf('/');
|
||||
directory = slash === -1 ? '' : directory.slice(0, slash);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Statically collect Ruby dependency sources without evaluating Gemfile or
|
||||
* gemspec code. Scopes stay separate by manifest directory so sibling projects
|
||||
* in a monorepo cannot suppress one another's local imports. Lockfiles
|
||||
* contribute remote GEM/GIT specs and local PATH/GEMSPEC load roots only when
|
||||
* an adjacent Gemfile or gemspec establishes a Bundler/RubyGems project.
|
||||
*
|
||||
* No declarative manifest means no safe gate, so return null and preserve the
|
||||
* resolver's existing fail-open behavior for loose script directories.
|
||||
*/
|
||||
export function loadRubyResolutionConfig(repoPath: string): RubyResolutionConfig | null {
|
||||
const pending = [repoPath];
|
||||
const manifestsByDirectory = new Map<string, string[]>();
|
||||
const lockfilesByDirectory = new Map<string, string>();
|
||||
let visitedDirectories = 0;
|
||||
|
||||
while (pending.length > 0 && visitedDirectories < MAX_VISITED_DIRECTORIES) {
|
||||
const directory = pending.pop();
|
||||
if (directory === undefined) break;
|
||||
visitedDirectories++;
|
||||
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = readdirSync(directory, { withFileTypes: true }).sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && !SKIP_DIRECTORIES.has(entry.name)) {
|
||||
pending.push(join(directory, entry.name));
|
||||
} else if (entry.isFile() && (entry.name === 'Gemfile' || entry.name.endsWith('.gemspec'))) {
|
||||
const manifests = manifestsByDirectory.get(directory) ?? [];
|
||||
manifests.push(join(directory, entry.name));
|
||||
manifestsByDirectory.set(directory, manifests);
|
||||
} else if (entry.isFile() && entry.name === 'Gemfile.lock') {
|
||||
lockfilesByDirectory.set(directory, join(directory, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (manifestsByDirectory.size === 0) return null;
|
||||
|
||||
const mutableScopes = new Map<string, MutableRubyResolutionScope>();
|
||||
for (const [directory, manifests] of [...manifestsByDirectory].sort(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
)) {
|
||||
const directoryRelative = repoRelativePath(repoPath, directory);
|
||||
if (directoryRelative === null) continue;
|
||||
const scope = createMutableScope();
|
||||
mutableScopes.set(directoryRelative, scope);
|
||||
|
||||
for (const manifest of manifests.sort()) {
|
||||
try {
|
||||
const contents = readFileSync(manifest, 'utf8');
|
||||
if (manifest.endsWith('.gemspec')) {
|
||||
addGemspecDependencies(contents, directory, repoPath, scope);
|
||||
} else {
|
||||
addGemfileDependencies(contents, directory, repoPath, scope);
|
||||
}
|
||||
} catch {
|
||||
// A partially readable scope remains fail-open for unknown gems.
|
||||
}
|
||||
}
|
||||
|
||||
const lockfile = lockfilesByDirectory.get(directory);
|
||||
if (lockfile === undefined) continue;
|
||||
try {
|
||||
addLockedDependencies(readFileSync(lockfile, 'utf8'), directory, repoPath, scope);
|
||||
} catch {
|
||||
// Direct declarations still provide bounded evidence when the lock is unreadable.
|
||||
}
|
||||
}
|
||||
|
||||
const scopesByDirectory = new Map<string, RubyResolutionScope>();
|
||||
for (const [directory, scope] of mutableScopes) {
|
||||
scopesByDirectory.set(directory, freezeScope(scope));
|
||||
}
|
||||
return { scopesByDirectory };
|
||||
}
|
||||
|
|
@ -10,6 +10,8 @@ import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-l
|
|||
import type { KnowledgeGraph } from '../../../graph/types.js';
|
||||
import { generateId } from '../../../../lib/utils.js';
|
||||
import { decodeMarker } from '../../utils/heritage-marker.js';
|
||||
import { rubyIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js';
|
||||
import { loadRubyResolutionConfig } from './resolution-config.js';
|
||||
|
||||
/**
|
||||
* #1991: resolve a BARE mixin reference (`include Loggable`) to a nested module by
|
||||
|
|
@ -268,6 +270,8 @@ export const rubyScopeResolver: ScopeResolver = {
|
|||
resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) =>
|
||||
resolveRubyImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig),
|
||||
|
||||
loadResolutionConfig: (repoPath) => loadRubyResolutionConfig(repoPath),
|
||||
|
||||
expandsWildcardTo: (targetModuleScope, parsedFiles) =>
|
||||
expandRubyWildcardNames(targetModuleScope, parsedFiles),
|
||||
|
||||
|
|
@ -287,4 +291,5 @@ export const rubyScopeResolver: ScopeResolver = {
|
|||
fieldFallbackOnMethodLookup: true,
|
||||
propagatesReturnTypesAcrossImports: true,
|
||||
allowGlobalFreeCallFallback: true,
|
||||
isGlobalNameFallbackPlausible: rubyIsGlobalNameFallbackPlausible,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,183 @@
|
|||
/**
|
||||
* Rust's veto on the global-name fallback — see
|
||||
* `ScopeResolver.isGlobalNameFallbackPlausible`.
|
||||
*
|
||||
* Rust has NO ambient namespace. A bare `helper()` resolves only against names
|
||||
* in scope, and for an item declared in another module the only way in is a
|
||||
* `use` path (or a fully-qualified `crate::a::b::helper()` call, which is not a
|
||||
* bare free call and never reaches this tier — it carries a qualified name and
|
||||
* is resolved earlier by `resolveQualifiedFreeCall`).
|
||||
*
|
||||
* This hook checks import evidence, not Rust item visibility. A child module
|
||||
* can use private ancestor items, and visibility restrictions such as
|
||||
* `pub(crate)` require more context than `SymbolDefinition` carries. A matching
|
||||
* import therefore keeps a labeled guess rather than proving accessibility.
|
||||
*
|
||||
* Module paths are matched against the candidate's FILE path (extension
|
||||
* stripped, and `mod`/`lib`/`main` stem dropped, since `a/b/mod.rs` IS module
|
||||
* `a::b`). `crate::` names the root; `self::` and `super::` resolve relative
|
||||
* to the caller's module before comparison.
|
||||
*/
|
||||
|
||||
import type { ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
import {
|
||||
modulePathReaches,
|
||||
stripExtension,
|
||||
} from '../../scope-resolution/utils/name-fallback-visibility.js';
|
||||
|
||||
/** File-stems that name their PARENT directory as the module, not themselves. */
|
||||
const RUST_DIRECTORY_MODULE_STEMS: ReadonlySet<string> = new Set(['mod', 'lib', 'main']);
|
||||
|
||||
/** Directories that hold a crate's root and contribute no module segment, so
|
||||
* `src/net/http.rs` is module `net::http` and not `src::net::http`. */
|
||||
const RUST_CRATE_ROOT_DIRS: ReadonlySet<string> = new Set(['src', 'tests', 'benches', 'examples']);
|
||||
|
||||
/** Path prefixes of a `use` that name a root rather than a module segment. */
|
||||
const RUST_USE_ROOT_PREFIXES: ReadonlySet<string> = new Set(['crate', '$crate']);
|
||||
|
||||
// One scope lookup per immutable parsed-file snapshot, not per fallback site.
|
||||
// Weak keys release both the snapshot and its index at the end of ingestion.
|
||||
const scopeLookupByFile = new WeakMap<ParsedFile, ReadonlyMap<ScopeId, Scope>>();
|
||||
|
||||
/**
|
||||
* The module path a Rust file provides, as a `/`-joined path.
|
||||
*
|
||||
* Two normalizations, both needed for a file path and a `use` path to line up
|
||||
* on their trailing segments: the `mod`/`lib`/`main` stem names its parent
|
||||
* directory, and a leading crate-root directory (`src/`) is not a module.
|
||||
*/
|
||||
function rustModulePathOf(filePath: string): string {
|
||||
const withoutExtension = stripExtension(filePath);
|
||||
const segments = withoutExtension.split('/').filter((s) => s !== '');
|
||||
const stem = segments[segments.length - 1];
|
||||
if (stem !== undefined && RUST_DIRECTORY_MODULE_STEMS.has(stem)) segments.pop();
|
||||
// Only the crate-root folder is non-semantic. A nested `src/` is module
|
||||
// `src` (`src/src/helper.rs` → `crate::src::helper`), not another root.
|
||||
if (segments.length > 0 && RUST_CRATE_ROOT_DIRS.has(segments[0]!)) segments.shift();
|
||||
return segments.join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Directory that owns the crate-root folder (`src`/`tests`/…). Empty when the
|
||||
* file sits at the analyzed root (`src/lib.rs`) so a single-crate tree is not
|
||||
* refused on a missing workspace prefix.
|
||||
*/
|
||||
function rustCrateRootOf(filePath: string): string {
|
||||
const segments = stripExtension(filePath)
|
||||
.split('/')
|
||||
.filter((s) => s !== '');
|
||||
const stem = segments[segments.length - 1];
|
||||
if (stem !== undefined && RUST_DIRECTORY_MODULE_STEMS.has(stem)) segments.pop();
|
||||
const rootIdx = segments.findIndex((s) => RUST_CRATE_ROOT_DIRS.has(s));
|
||||
if (rootIdx <= 0) return '';
|
||||
return segments.slice(0, rootIdx).join('/');
|
||||
}
|
||||
|
||||
/** Resolve explicit relative prefixes against the caller's module path. */
|
||||
function rustUsePathOf(targetRaw: string, callerFilePath: string): string {
|
||||
const segments = targetRaw.split('::').filter((s) => s !== '');
|
||||
if (segments[0] === 'self' || segments[0] === 'super') {
|
||||
const base = rustModulePathOf(callerFilePath).split('/').filter(Boolean);
|
||||
if (segments[0] === 'self') segments.shift();
|
||||
while (segments[0] === 'super') {
|
||||
base.pop();
|
||||
segments.shift();
|
||||
}
|
||||
return [...base, ...segments].join('::');
|
||||
}
|
||||
while (segments.length > 0 && RUST_USE_ROOT_PREFIXES.has(segments[0]!)) segments.shift();
|
||||
return segments.join('::');
|
||||
}
|
||||
|
||||
export function rustIsGlobalNameFallbackPlausible(ctx: {
|
||||
readonly callerParsed: ParsedFile;
|
||||
readonly candidate: SymbolDefinition;
|
||||
readonly site: {
|
||||
readonly name: string;
|
||||
readonly rawQualifiedName?: string;
|
||||
readonly inScope?: ScopeId;
|
||||
};
|
||||
}): boolean {
|
||||
if (ctx.candidate.filePath === ctx.callerParsed.filePath) return true;
|
||||
// A PATH-QUALIFIED call (`User::new(...)`, `crate::a::helper()`) reaches this
|
||||
// tier when the qualifier could not be followed, carrying only its tail name.
|
||||
// It is not a bare-name guess: the source named the path, so the module rule
|
||||
// below would refuse an edge the code spells out.
|
||||
if (ctx.site.rawQualifiedName !== undefined) return true;
|
||||
|
||||
const candidateModule = rustModulePathOf(ctx.candidate.filePath);
|
||||
// A candidate whose file maps to no module path (a crate root reduced to '')
|
||||
// is not something this rule can speak about; allow the labeled edge rather
|
||||
// than refuse on an unanswered question.
|
||||
if (candidateModule === '') return true;
|
||||
|
||||
const candidateName = rustSimpleNameOf(ctx.candidate);
|
||||
// Imports are lexical evidence, not a file-wide allowlist. Legacy/synthetic
|
||||
// imports without a scope receipt retain the previous conservative behavior.
|
||||
let visibleScopes: Set<ScopeId> | undefined;
|
||||
if (ctx.site.inScope !== undefined) {
|
||||
let scopes = scopeLookupByFile.get(ctx.callerParsed);
|
||||
if (scopes === undefined) {
|
||||
scopes = new Map(ctx.callerParsed.scopes.map((scope) => [scope.id, scope]));
|
||||
scopeLookupByFile.set(ctx.callerParsed, scopes);
|
||||
}
|
||||
visibleScopes = new Set();
|
||||
let current: ScopeId | null = ctx.site.inScope;
|
||||
while (current !== null && !visibleScopes.has(current)) {
|
||||
visibleScopes.add(current);
|
||||
const scope = scopes.get(current);
|
||||
if (scope === undefined || scope.kind === 'Namespace') break;
|
||||
current = scope.parent;
|
||||
}
|
||||
}
|
||||
for (const imp of ctx.callerParsed.parsedImports) {
|
||||
if (
|
||||
imp.declaredAtScope !== undefined &&
|
||||
visibleScopes !== undefined &&
|
||||
!visibleScopes.has(imp.declaredAtScope)
|
||||
)
|
||||
continue;
|
||||
// `crate::` is the caller's crate. A same trailing module in another
|
||||
// workspace crate is a different item and cannot authorize the guess.
|
||||
if (imp.targetRaw === 'crate' || imp.targetRaw.startsWith('crate::')) {
|
||||
const callerRoot = rustCrateRootOf(ctx.callerParsed.filePath);
|
||||
const candidateRoot = rustCrateRootOf(ctx.candidate.filePath);
|
||||
if (callerRoot !== '' && candidateRoot !== '' && callerRoot !== candidateRoot) continue;
|
||||
}
|
||||
const usePath = rustUsePathOf(imp.targetRaw, ctx.callerParsed.filePath);
|
||||
// Only a glob introduces every bare item of a module. A named import must
|
||||
// match both the candidate's original name and the call's local spelling.
|
||||
if (imp.kind === 'wildcard') {
|
||||
if (modulePathReaches(usePath, candidateModule)) return true;
|
||||
continue;
|
||||
}
|
||||
if (!('localName' in imp) || imp.localName !== ctx.site.name) continue;
|
||||
// Otherwise the path names ONE item inside a module (`use crate::a::other`).
|
||||
// Its PARENT is the candidate's module only if that item IS the candidate:
|
||||
// importing `other` says nothing about a `helper` in `a`, and the bare
|
||||
// parent-path match used to accept every item of `a` on its strength.
|
||||
// An alias authorizes only the local spelling checked above.
|
||||
if (importedNameOf(imp) !== candidateName) continue;
|
||||
if (modulePathReaches(usePath, candidateModule)) return true;
|
||||
const parent = usePath.slice(0, Math.max(0, usePath.lastIndexOf('::')));
|
||||
if (parent !== '' && modulePathReaches(parent, candidateModule)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The identifier a `use` binds, as written at its source (`importedName`). */
|
||||
function importedNameOf(imp: ParsedFile['parsedImports'][number]): string | undefined {
|
||||
return 'importedName' in imp ? imp.importedName : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The identifier a Rust declaration contributes to its module: the FIRST
|
||||
* segment of `qualifiedName` after any module prefix — `User` for `User.new`
|
||||
* (an associated function is reached through its type, so it is the type the
|
||||
* `use` must name), the bare name for a free function.
|
||||
*/
|
||||
function rustSimpleNameOf(candidate: SymbolDefinition): string {
|
||||
const qualified = candidate.qualifiedName ?? '';
|
||||
const segments = qualified.split(/::|\./).filter((s) => s !== '');
|
||||
return segments[0] ?? '';
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-l
|
|||
import type { HeritageTypeArgumentSink } from '../../scope-resolution/utils/generic-instantiation.js';
|
||||
import type { KnowledgeGraph } from '../../../graph/types.js';
|
||||
import { generateId } from '../../../../lib/utils.js';
|
||||
import { rustIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js';
|
||||
|
||||
/**
|
||||
* Emit Rust `S IMPLEMENTS T` edges from `impl T for S` trait implementations.
|
||||
|
|
@ -192,4 +193,6 @@ export const rustScopeResolver: ScopeResolver = {
|
|||
hoistTypeBindingsToModule: true,
|
||||
propagatesReturnTypesAcrossImports: true,
|
||||
allowGlobalFreeCallFallback: true,
|
||||
importsBindAtLexicalScope: true,
|
||||
isGlobalNameFallbackPlausible: rustIsGlobalNameFallbackPlausible,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* Swift's veto on the global-name fallback — see
|
||||
* `ScopeResolver.isGlobalNameFallbackPlausible`.
|
||||
*
|
||||
* Swift's default access level is `internal`: visible throughout the MODULE and
|
||||
* nowhere else. Swift needs no per-file import inside a module, which is why
|
||||
* the global fallback is enabled for it at all — but that whole-module
|
||||
* visibility stops hard at the module boundary. A candidate in a DIFFERENT
|
||||
* module is reachable only if the caller wrote `import <ThatModule>`, and even
|
||||
* then only if the declaration is `public`.
|
||||
*
|
||||
* A module is approximated by its source directory, the layout every Swift
|
||||
* package configuration discovers: `Sources/<Target>/…` (also under `Package/`).
|
||||
* `src/<Target>/…` is also recognized by the package configuration loader.
|
||||
* Files outside these layouts have unknown module identity.
|
||||
*
|
||||
* The `private` / `fileprivate` half of the rule is NOT implemented, because
|
||||
* neither marker is recoverable from the parse model this hook sees —
|
||||
* `SymbolDefinition` carries no access level and `ParsedFile` no modifiers.
|
||||
* Those candidates keep the labeled low-confidence edge, which is the
|
||||
* "cannot decide, so do not refuse" direction the hook contract asks for.
|
||||
*/
|
||||
|
||||
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
|
||||
import { modulePathReaches } from '../../scope-resolution/utils/name-fallback-visibility.js';
|
||||
|
||||
/** Keep aligned with loadSwiftPackageConfig: other layouts are unconfigured. */
|
||||
const SWIFT_TARGET_ROOTS: ReadonlySet<string> = new Set(['Sources', 'src']);
|
||||
|
||||
/**
|
||||
* The module (target) a Swift file belongs to.
|
||||
*
|
||||
* `Sources/Core/User.swift` → `Core`. Arbitrary Xcode folders are not module
|
||||
* boundaries; without a recognized target layout leave the answer unknown.
|
||||
*/
|
||||
function swiftModuleOf(filePath: string): string {
|
||||
const segments = filePath.split('/').filter((s) => s !== '');
|
||||
for (let i = 0; i < segments.length - 2; i++) {
|
||||
if (SWIFT_TARGET_ROOTS.has(segments[i]!)) {
|
||||
// Keep the path through the target so `Sources/Core` and
|
||||
// `Package/Sources/Core` stay distinct modules. `import Core` still
|
||||
// reaches either via trailing-segment containment.
|
||||
return segments.slice(0, i + 2).join('/');
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function swiftIsGlobalNameFallbackPlausible(ctx: {
|
||||
readonly callerParsed: ParsedFile;
|
||||
readonly candidate: SymbolDefinition;
|
||||
}): boolean {
|
||||
if (ctx.candidate.filePath === ctx.callerParsed.filePath) return true;
|
||||
|
||||
const callerModule = swiftModuleOf(ctx.callerParsed.filePath);
|
||||
const candidateModule = swiftModuleOf(ctx.candidate.filePath);
|
||||
// Same module: whole-module `internal` visibility, no import needed.
|
||||
if (callerModule === candidateModule) return true;
|
||||
// A file the layout heuristic cannot place is not something this rule can
|
||||
// speak about — allow rather than refuse on an unanswered question.
|
||||
if (callerModule === '' || candidateModule === '') return true;
|
||||
|
||||
for (const imp of ctx.callerParsed.parsedImports) {
|
||||
if (modulePathReaches(imp.targetRaw, candidateModule)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
@ -66,6 +66,7 @@ import {
|
|||
mirrorSwiftSiblingTypeBindings,
|
||||
type SwiftResolveContext,
|
||||
} from './index.js';
|
||||
import { swiftIsGlobalNameFallbackPlausible } from './name-fallback-visibility.js';
|
||||
|
||||
const ZERO_RANGE = { startLine: 0, startCol: 0, endLine: 0, endCol: 0 } as const;
|
||||
|
||||
|
|
@ -136,6 +137,7 @@ const swiftScopeResolver: ScopeResolver = {
|
|||
// global free-call fallback (as Python/Go/Ruby/COBOL do for the same
|
||||
// no-`new` constructor + cross-file free-call shape).
|
||||
allowGlobalFreeCallFallback: true,
|
||||
isGlobalNameFallbackPlausible: swiftIsGlobalNameFallbackPlausible,
|
||||
|
||||
// Swift's call graph models `Type(...)` as a reference to the type
|
||||
// itself, not its `init` — both the legacy DAG and this test suite link
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import {
|
|||
syntheticCapture,
|
||||
type SyntaxNode,
|
||||
} from '../../utils/ast-helpers.js';
|
||||
import { collectEsmExportEvidence, esmExportVerdict } from '../../ts-js-export-marker.js';
|
||||
import { splitImportStatement } from './import-decomposer.js';
|
||||
import { getTsParser, getTsScopeQuery, tsCachedTreeMatchesGrammar } from './query.js';
|
||||
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
|
||||
|
|
@ -388,6 +389,8 @@ export function emitTsScopeCaptures(
|
|||
}
|
||||
|
||||
const rawMatches = getTsScopeQuery(filePath).matches(tree.rootNode);
|
||||
// Export evidence, read once per file (see `ts-js-export-marker.ts`).
|
||||
const exportEvidence = collectEsmExportEvidence(tree.rootNode, filePath);
|
||||
const out: CaptureMatch[] = [];
|
||||
|
||||
for (const m of rawMatches) {
|
||||
|
|
@ -561,6 +564,13 @@ export function emitTsScopeCaptures(
|
|||
fnNode,
|
||||
deriveDefaultExportHocName(filePath),
|
||||
);
|
||||
// This declaration's name is synthetic, so the later query-name
|
||||
// marker cannot see it. The HOC predicate already proves the export.
|
||||
grouped['@declaration.is-exported'] = syntheticCapture(
|
||||
'@declaration.is-exported',
|
||||
fnNode,
|
||||
'true',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -660,6 +670,20 @@ export function emitTsScopeCaptures(
|
|||
// instead of re-parsing the receiver's source text. Self-gating: a
|
||||
// non-call match, an absent receiver, or a chain with no nameable base
|
||||
// all leave `grouped` untouched.
|
||||
// `@declaration.is-exported`: a verdict for every declaration the file's
|
||||
// export surface can decide (see `ts-js-export-marker.ts`); nothing where
|
||||
// it cannot, because absence is the honest answer there.
|
||||
const declNameNode = groupedNodes['@declaration.name'];
|
||||
if (exportEvidence !== undefined && declNameNode !== undefined) {
|
||||
const verdict = esmExportVerdict(declNameNode, exportEvidence);
|
||||
if (verdict !== undefined) {
|
||||
grouped['@declaration.is-exported'] = syntheticCapture(
|
||||
'@declaration.is-exported',
|
||||
declNameNode,
|
||||
verdict ? 'true' : 'false',
|
||||
);
|
||||
}
|
||||
}
|
||||
synthesizeReceiverChainCapture(grouped, groupedNodes['@reference.receiver']);
|
||||
out.push(grouped);
|
||||
|
||||
|
|
|
|||
|
|
@ -1346,7 +1346,13 @@ export const TYPESCRIPT_SCOPE_QUERY = `
|
|||
* (\`<Foo>...</Foo>\`) emit; the closing tag is intentionally NOT captured —
|
||||
* each JSX element should emit exactly one CALLS edge per use site.
|
||||
*/
|
||||
const TSX_JSX_QUERY_SUFFIX = `
|
||||
/**
|
||||
* Exported alongside `TYPESCRIPT_SCOPE_QUERY` so
|
||||
* `value-ref-dispatchability.test.ts` checks the whole query a `.tsx` file is
|
||||
* analyzed with. Checking the base alone would miss a `value-ref` rule added
|
||||
* here. Not part of the provider surface — use `getTsScopeQuery`.
|
||||
*/
|
||||
export const TSX_JSX_QUERY_SUFFIX = `
|
||||
;; <Foo />
|
||||
((jsx_self_closing_element
|
||||
name: (identifier) @reference.name) @reference.call.free
|
||||
|
|
|
|||
|
|
@ -132,6 +132,15 @@ const typescriptScopeResolver: ScopeResolver = {
|
|||
fieldFallbackOnMethodLookup: false,
|
||||
propagatesReturnTypesAcrossImports: true,
|
||||
|
||||
// ECMAScript: `export * from './a'; export * from './b'` with a name declared
|
||||
// in both exports NEITHER — the finalize pass refuses the binding instead of
|
||||
// taking the first-listed source (see `exclusiveWildcardReexports`).
|
||||
exclusiveWildcardReexports: true,
|
||||
// `import { x }` never reaches a class member: named imports and named
|
||||
// re-exports bind to module-level declarations only (a class method sharing
|
||||
// a name with a top-level value must not win the callable preference).
|
||||
namedImportsBindTopLevelOnly: true,
|
||||
|
||||
// TypeScript uses `.values()` / `.keys()` method-call syntax for collection
|
||||
// views -- no property-style accessors like C#'s `Dictionary<K,V>.Values` --
|
||||
// so `elementTypeOf` answers only the `index` route and lets the regular
|
||||
|
|
|
|||
|
|
@ -278,10 +278,11 @@ async function firstReadableConfig(base: string): Promise<string | null> {
|
|||
async function findTsconfigFiles(repoRoot: string): Promise<string[]> {
|
||||
const found: string[] = [];
|
||||
const queue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }];
|
||||
let queueHead = 0;
|
||||
let dirsScanned = 0;
|
||||
|
||||
while (queue.length > 0 && dirsScanned < SCAN_MAX_DIRS) {
|
||||
const { dir, depth } = queue.shift()!;
|
||||
while (queueHead < queue.length && dirsScanned < SCAN_MAX_DIRS) {
|
||||
const { dir, depth } = queue[queueHead++]!;
|
||||
dirsScanned++;
|
||||
let entries: import('fs').Dirent[];
|
||||
try {
|
||||
|
|
@ -317,8 +318,8 @@ function parseJsonc(raw: string): Record<string, unknown> {
|
|||
return JSON.parse(withoutComments) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function repoRelative(repoRoot: string, absDir: string): string {
|
||||
const rel = path.relative(repoRoot, absDir).split(path.sep).join('/');
|
||||
function repoRelative(repoRoot: string, absDir: string, pathApi: typeof path = path): string {
|
||||
const rel = pathApi.relative(repoRoot, absDir).split(pathApi.sep).join('/');
|
||||
return rel === '.' || rel === '' ? '' : rel;
|
||||
}
|
||||
|
||||
|
|
@ -328,11 +329,34 @@ function repoRelative(repoRoot: string, absDir: string): string {
|
|||
* `path.resolve` swallows the wildcard into a path segment, so it is stripped
|
||||
* before resolving and re-appended after — the `*` is a substitution marker,
|
||||
* not a directory named `*`.
|
||||
*
|
||||
* `pathApi` is injectable so the win32 separator branch is unit-testable from a
|
||||
* POSIX runner; production callers always use the platform-bound `path`.
|
||||
*/
|
||||
function rebaseTarget(repoRoot: string, absTarget: string): string {
|
||||
function rebaseTarget(repoRoot: string, absTarget: string, pathApi: typeof path = path): string {
|
||||
// `/repo/src/*` must come back as `src/*`, not `src*`: stripping only the
|
||||
// star leaves a trailing slash that `path.relative` then eats.
|
||||
const suffix = absTarget.endsWith('/*') ? '/*' : absTarget.endsWith('*') ? '*' : '';
|
||||
const base = suffix === '' ? absTarget : absTarget.slice(0, -suffix.length);
|
||||
return `${repoRelative(repoRoot, base)}${suffix}`;
|
||||
//
|
||||
// The target arrives from `path.resolve`, so on Windows it is `C:\repo\src\*`
|
||||
// and an `endsWith('/*')` check never matches. It fell through to the bare
|
||||
// `*` branch, `path.relative` ate the trailing backslash, and every alias
|
||||
// target came back as `src*` — which `substituteStar` turns into `srclib/x`,
|
||||
// so nothing an alias reached was ever resolved on Windows. Normalise the
|
||||
// separator before looking at the suffix.
|
||||
const normalized = absTarget.split(pathApi.sep).join('/');
|
||||
const suffix = normalized.endsWith('/*') ? '/*' : normalized.endsWith('*') ? '*' : '';
|
||||
const base = suffix === '' ? normalized : normalized.slice(0, -suffix.length);
|
||||
const prefix = repoRelative(repoRoot, base, pathApi);
|
||||
// A target naming the repo ROOT (`"*": ["./*"]` under `baseUrl: "."`) leaves
|
||||
// an empty prefix, and `${''}${'/*'}` is `/*`. `substituteStar` turns that
|
||||
// into `/lib/date`, but `resolveFile` matches repo-relative keys and never
|
||||
// strips a leading slash, so `lib/date.ts` misses and the alias goes
|
||||
// external. The bare `*` is the encoding that substitutes correctly — and it
|
||||
// is what the pre-#3203 Windows path emitted by accident, so this keeps the
|
||||
// separator fix from narrowing what already resolved there.
|
||||
if (prefix === '' && suffix === '/*') return '*';
|
||||
return `${prefix}${suffix}`;
|
||||
}
|
||||
|
||||
/** Test seam for {@link rebaseTarget} (see `test/unit/tsconfig-rebase-target.test.ts`). */
|
||||
export const _rebaseTargetForTests = rebaseTarget;
|
||||
|
|
|
|||
|
|
@ -117,6 +117,9 @@ const vueScopeResolver: ScopeResolver = {
|
|||
// Vue uses explicit imports for all external symbols; no global free-
|
||||
// call fallback needed (would produce spurious edges for built-ins).
|
||||
allowGlobalFreeCallFallback: false,
|
||||
// Vue SFC scripts are TypeScript/JavaScript: a named import binds a module-level
|
||||
// declaration, never a class member (see the TS resolver).
|
||||
namedImportsBindTopLevelOnly: true,
|
||||
|
||||
/**
|
||||
* Expand the scope-resolution file universe for Vue by performing a
|
||||
|
|
|
|||
|
|
@ -20,7 +20,13 @@ import { requireVendoredGrammar } from '../../../tree-sitter/vendored-grammars.j
|
|||
* container and import bindings — `emitZigScopeCaptures` filters those
|
||||
* groups out so a name binds exactly once.
|
||||
*/
|
||||
const ZIG_SCOPE_QUERY = `
|
||||
/**
|
||||
* Exported for `value-ref-dispatchability.test.ts`, which reads every language's
|
||||
* scope query to enforce the keyed/unkeyed partition that
|
||||
* `callableValueReferenceBoundaries`' dispatch exclusion depends on. Not part of
|
||||
* the provider surface — nothing else should import it.
|
||||
*/
|
||||
export const ZIG_SCOPE_QUERY = `
|
||||
;; Scopes
|
||||
(source_file) @scope.module
|
||||
(struct_declaration) @scope.class
|
||||
|
|
@ -359,6 +365,60 @@ const ZIG_SCOPE_QUERY = `
|
|||
"const" . (identifier) @type-binding.name
|
||||
(call_expression) @type-binding.type .) @type-binding.alias
|
||||
|
||||
;; References — VALUE positions (#3399): a callable named where a value is
|
||||
;; expected rather than where a callee is. Zig's JS bridge is built entirely
|
||||
;; out of this shape —
|
||||
;;
|
||||
;; pub const namespaceURI = bridge.accessor(Element.getNamespaceUri, null, .{});
|
||||
;;
|
||||
;; — 2,047 such declarations across 257 files in lightpanda-io/browser, the
|
||||
;; project's whole JS↔Zig surface, and NONE of them reached the graph: Zig
|
||||
;; emitted no \`value-ref\` capture at all, so a public DOM accessor's only
|
||||
;; recorded callers were the two internal ones and \`impact\` called that \`exact\`.
|
||||
;;
|
||||
;; These become reference-class USES edges through the existing
|
||||
;; \`mapReferenceKindToEdgeType\` mapping — a registration is not an invocation
|
||||
;; (Kythe \`ref\` vs \`ref/call\`; Joern \`METHOD_REF\`) — resolved by the
|
||||
;; property-dispatch pass, which keeps ONLY callable targets. That callable gate
|
||||
;; is what makes these deliberately broad rules safe: \`js.Bridge(Element)\` and
|
||||
;; \`register(count)\` match too, and emit nothing, exactly as TypeScript's
|
||||
;; \`{ port: DEFAULT_PORT }\` does.
|
||||
;;
|
||||
;; No \`@reference.property-key\` is attached: Zig has no object-literal key to
|
||||
;; dispatch through, so these register a reference and never synthesize CALLS.
|
||||
;; The terminal invoke — \`Accessor.init\` → a struct field → \`Factory.zig\`'s
|
||||
;; \`inline for\`/\`@typeInfo\` → \`@call(.auto, func, args)\` — needs comptime
|
||||
;; evaluation and is deliberately NOT modelled; \`impact\` reports the shortfall
|
||||
;; as \`epistemic: "lower-bound"\` instead of pretending to certainty.
|
||||
|
||||
;; Call ARGUMENTS. In tree-sitter-zig arguments are direct children of
|
||||
;; \`call_expression\`, NOT wrapped in an \`arguments\` node (only builtins have
|
||||
;; one), so the callee has to be consumed explicitly by \`function:\` — without
|
||||
;; that binding the same rule also matches the callee of \`foo(bar)\` and mints a
|
||||
;; USES edge duplicating the call.
|
||||
(call_expression
|
||||
function: (_)
|
||||
(identifier) @reference.name @reference.value-ref)
|
||||
|
||||
;; Qualified argument — \`bridge.accessor(Element.getNamespaceUri, …)\`. The
|
||||
;; RECEIVER is captured alongside the member so the site carries the owner it
|
||||
;; was written with; \`@reference.name\` stays the member, which is the name the
|
||||
;; scope walk resolves.
|
||||
(call_expression
|
||||
function: (_)
|
||||
(field_expression
|
||||
object: (_) @reference.receiver
|
||||
member: (identifier) @reference.name) @reference.value-ref)
|
||||
|
||||
;; Const binding initialiser — \`pub const defaultHandler = onReset;\`. Both
|
||||
;; anchors are load-bearing: the leading \`.\` pins the bound name to the first
|
||||
;; named child (see the declaration rules above), and the trailing \`.\` keeps the
|
||||
;; initializer as the LAST child, so a \`const x: T = y\` annotation shape cannot
|
||||
;; put the TYPE in value position.
|
||||
(variable_declaration
|
||||
"const" . (identifier)
|
||||
(identifier) @reference.name @reference.value-ref .)
|
||||
|
||||
;; References — free calls: foo(...)
|
||||
(call_expression
|
||||
function: (identifier) @reference.name) @reference.call.free
|
||||
|
|
|
|||
|
|
@ -17,6 +17,14 @@
|
|||
*
|
||||
* Runs after `propagateImportedReturnTypes`, so return bindings hoisted from
|
||||
* other files are visible when a subject is a call.
|
||||
*
|
||||
* This hook also carries `bindZigThisAliases` (`this-alias-bindings.ts`), which
|
||||
* is a different job — binding `const Self = @This();` to its container — but
|
||||
* needs the same thing this pass is already paying for: the file's parsed tree,
|
||||
* post-finalize. Giving it a pass of its own would re-parse every Zig file in
|
||||
* the repo whenever the tree cache is cold. That is the whole reason it is
|
||||
* here; the two do not otherwise interact, for the reason recorded at the call
|
||||
* site.
|
||||
*/
|
||||
|
||||
import type { ParsedFile, Scope, ScopeId, TypeRef } from 'gitnexus-shared';
|
||||
|
|
@ -32,6 +40,7 @@ import {
|
|||
isClassLike,
|
||||
} from '../../scope-resolution/scope/walkers.js';
|
||||
import { isZigKeywordDeclaration, zigUnwrapValue } from './captures.js';
|
||||
import { bindZigThisAliases } from './this-alias-bindings.js';
|
||||
import { normalizeZigTypeName } from './interpret.js';
|
||||
|
||||
type ZigTree = ReturnType<ReturnType<typeof getZigParser>['parse']>;
|
||||
|
|
@ -89,6 +98,23 @@ export function populateZigRangeBindings(
|
|||
}
|
||||
const scopes = parsed.scopes;
|
||||
if (scopes.length === 0) continue;
|
||||
|
||||
// `const Self = @This();` — bind the alias to its container. It sits in this
|
||||
// loop for ONE reason: the tree. A pass of its own would re-parse every Zig
|
||||
// file in the repo whenever the tree cache is cold.
|
||||
//
|
||||
// Its position relative to the payload walk below is NOT load-bearing, and
|
||||
// saying otherwise would be wrong in a checkable way: the payload walk types
|
||||
// a subject through `findReceiverTypeBinding`, which reads `typeBindings`
|
||||
// and the namespace/workspace type channels — never `bindingAugmentations`,
|
||||
// where this writes. Measured on `for (Self.items) |it|`: `it` is bound
|
||||
// neither before nor after. Nor is that a gap this should close by also
|
||||
// writing a typeBinding — NO container name has one, the file stem included,
|
||||
// so a payload subject written `Type.member` resolves for no spelling at
|
||||
// all, and giving the alias an entry would make it behave unlike the very
|
||||
// container it names.
|
||||
bindZigThisAliases(parsed, tree.rootNode, indexes);
|
||||
|
||||
const resolver = new ZigSubjectTypeResolver(scopes, indexes, classScopeByDefId);
|
||||
|
||||
// Pre-order: an outer payload is bound before an inner construct reads it
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@
|
|||
* 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`.
|
||||
* same `resolveZigImportInternal` the legacy import-resolver config wraps, with
|
||||
* the repo's Zig build packages threaded through `loadResolutionConfig` and the
|
||||
* one governing each file selected per-file by `zigPackageFor`.
|
||||
*/
|
||||
|
||||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
|
|
@ -14,7 +15,11 @@ 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 {
|
||||
loadZigWorkspaceIndex,
|
||||
zigPackageFor,
|
||||
type ZigWorkspaceIndex,
|
||||
} from '../../language-config.js';
|
||||
import { resolveZigImportInternal } from '../../import-resolvers/zig.js';
|
||||
import { zigProvider } from '../zig.js';
|
||||
import { expandZigWildcardNames, zigArityCompatibility, zigMergeBindings } from './index.js';
|
||||
|
|
@ -46,14 +51,23 @@ export const zigScopeResolver: ScopeResolver = {
|
|||
// import; a one-hop split at the last dot resolved none of them.
|
||||
resolveNamespaceChains: true,
|
||||
|
||||
loadResolutionConfig: (repoPath: string) => loadZigBuildConfig(repoPath),
|
||||
// The whole workspace, not just the root package. A Zig module's import table
|
||||
// is declared by the `build.zig` of the package the file belongs to, so a repo
|
||||
// laying its packages out as `packages/<name>/build.zig` has as many import
|
||||
// tables as packages — and reading only the root one leaves every bare
|
||||
// `@import("<module>")` in such a repo unresolved. Same shape, same reason, as
|
||||
// `loadTsconfigIndex` for a TypeScript monorepo.
|
||||
loadResolutionConfig: (repoPath: string) => loadZigWorkspaceIndex(repoPath),
|
||||
|
||||
resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) =>
|
||||
resolveZigImportInternal(
|
||||
fromFile,
|
||||
targetRaw,
|
||||
allFilePaths,
|
||||
(resolutionConfig as ZigBuildZonConfig | null | undefined) ?? null,
|
||||
// The package governing THIS file — `zigPackageFor` is the `tsconfigFor`
|
||||
// analogue. `resolveZigImportInternal` is handed one package's config and
|
||||
// is unchanged by this: which config it receives is the only difference.
|
||||
zigPackageFor(resolutionConfig as ZigWorkspaceIndex | null | undefined, fromFile),
|
||||
),
|
||||
|
||||
// `pub usingnamespace @import("x.zig");` — target decls become local decls.
|
||||
|
|
|
|||
191
gitnexus/src/core/ingestion/languages/zig/this-alias-bindings.ts
Normal file
191
gitnexus/src/core/ingestion/languages/zig/this-alias-bindings.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
/**
|
||||
* Bind a Zig `const X = @This();` alias to the container it names (#3219
|
||||
* review round 8).
|
||||
*
|
||||
* `@This()` IS the enclosing container, and Zig code says so constantly: the
|
||||
* single most common spelling is `const Self = @This();`, followed by
|
||||
* `Alias.member` wherever the explicit form is wanted (`Self.width(self)`, a
|
||||
* registration `bridge.accessor(Self.width, …)`, a nested type `Self.Node`).
|
||||
* A compiler resolves `Self` and the container's own name to the same type.
|
||||
* This index did not, for one specific reason:
|
||||
*
|
||||
* - A container-level alias (`const Self = @This();` inside `struct {…}`)
|
||||
* mints a `Variable` beside the `Struct`, so `Self` binds to a VALUE. Every
|
||||
* class-like lookup filters on `isClassLike` and walks straight past it.
|
||||
* - A FILE-level alias in a file-as-struct mints nothing at all —
|
||||
* `isZigFileThisAlias` suppresses the Const deliberately, so it cannot
|
||||
* shadow the type for `x: *Page`. The name is then bound to nothing, while
|
||||
* the container itself is bound under the FILE STEM.
|
||||
*
|
||||
* So `Self.width` resolved only when the alias happened to be spelled like its
|
||||
* container. Measured on the three Zig corpora on hand: 73 of ghostty's 185
|
||||
* `@This()` files, 93 of tigerbeetle's 94 and 8 of mach's 42 spell it
|
||||
* differently, carrying 302 `Alias.member` references between them, 96 of those
|
||||
* calls. Those were not wrong edges — they were no edges, and a caller list
|
||||
* missing them is exactly the false confidence #3399 is about.
|
||||
*
|
||||
* WHAT THIS ADDS, precisely: one binding of the alias NAME to the container's
|
||||
* own definition, appended to `indexes.bindingAugmentations` (the sanctioned
|
||||
* post-finalize channel, invariant I8 — `indexes.bindings` is frozen). The
|
||||
* augmentation channel is consulted by `lookupBindingsAt` only AFTER a scope's
|
||||
* own `Scope.bindings`, so this can never outrank a real local declaration: the
|
||||
* `Variable` the container-level alias already mints still answers first for
|
||||
* anything that wants a value, and the container answers for anything that
|
||||
* wants a type. Nothing is replaced and nothing is removed.
|
||||
*
|
||||
* WHAT IT DELIBERATELY DOES NOT DO. Only aliases declared DIRECTLY in a
|
||||
* container body or at file level are bound, which is the same set
|
||||
* `collectZigThisAliases` recognizes for the type-rewrite path. A function-local
|
||||
* `const Self = @This();` also names the enclosing container, but it belongs in
|
||||
* that function's scope, not the container's, and binding it here would make it
|
||||
* visible to sibling functions that never declared it.
|
||||
*/
|
||||
|
||||
import type { ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type { BindingRef } from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { isClassLike } from '../../scope-resolution/scope/walkers.js';
|
||||
import { isZigKeywordDeclaration, ZIG_CONTAINER_TYPES } from './captures.js';
|
||||
|
||||
/** `const X = @This();` — the declaration shape, wherever it sits. */
|
||||
function thisAliasName(node: SyntaxNode): string | undefined {
|
||||
if (node.type !== 'variable_declaration' || !isZigKeywordDeclaration(node)) return undefined;
|
||||
const named = node.namedChildren.filter((c): c is SyntaxNode => c !== null);
|
||||
if (named.length !== 2 || named[0]!.type !== 'identifier') return undefined;
|
||||
const value = named[1]!;
|
||||
if (value.type !== 'builtin_function' || value.namedChild(0)?.text !== '@This') return undefined;
|
||||
return named[0]!.text;
|
||||
}
|
||||
|
||||
/** The class-like definition a Class scope is the body of. */
|
||||
function containerDefOf(scope: Scope): SymbolDefinition | undefined {
|
||||
return scope.ownedDefs.find((d) => isClassLike(d.type));
|
||||
}
|
||||
|
||||
/**
|
||||
* The Class scope of the FILE-AS-STRUCT, if this file is one.
|
||||
*
|
||||
* Identified by range rather than by name: it is the only Class scope that both
|
||||
* hangs directly off the module scope AND spans the same lines as it. A
|
||||
* namespace-only file with a top-level `pub const Counter = struct {…}` also
|
||||
* produces a Class scope under the module scope, and it also carries the file
|
||||
* stem when the file is `Counter.zig` — matching on the name would bind that
|
||||
* file's `@This()` alias to `Counter`, which is not what `@This()` means there.
|
||||
*/
|
||||
function fileStructScope(scopes: readonly Scope[], moduleScope: Scope): Scope | undefined {
|
||||
return scopes.find(
|
||||
(s) =>
|
||||
s.kind === 'Class' &&
|
||||
s.parent === moduleScope.id &&
|
||||
s.range.startLine === moduleScope.range.startLine &&
|
||||
s.range.endLine === moduleScope.range.endLine,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Innermost Class scope containing `node`.
|
||||
*
|
||||
* Compared on (line, column) rather than line alone, because a Zig container
|
||||
* can nest inside another on ONE line — `const A = struct { const S = @This();
|
||||
* const B = struct { const T = @This(); }; };` gives both scopes the same start
|
||||
* line, and picking between them by line would be a coin toss that binds an
|
||||
* alias to the wrong container. A wrong edge is the one outcome this whole
|
||||
* change set treats as worse than no edge.
|
||||
*/
|
||||
function enclosingClassScope(scopes: readonly Scope[], node: SyntaxNode): Scope | undefined {
|
||||
const line = node.startPosition.row + 1;
|
||||
const column = node.startPosition.column;
|
||||
const startsAtOrBefore = (l: number, c: number): boolean =>
|
||||
l < line || (l === line && c <= column);
|
||||
const endsAtOrAfter = (l: number, c: number): boolean => l > line || (l === line && c >= column);
|
||||
let best: Scope | undefined;
|
||||
for (const s of scopes) {
|
||||
if (s.kind !== 'Class') continue;
|
||||
if (!startsAtOrBefore(s.range.startLine, s.range.startCol)) continue;
|
||||
if (!endsAtOrAfter(s.range.endLine, s.range.endCol)) continue;
|
||||
if (
|
||||
best === undefined ||
|
||||
s.range.startLine > best.range.startLine ||
|
||||
(s.range.startLine === best.range.startLine && s.range.startCol > best.range.startCol)
|
||||
) {
|
||||
best = s;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append `alias -> def` at `scopeId`, skipping the append when THIS def is
|
||||
* already bound there. Idempotence, not precedence: a duplicate would be
|
||||
* harmless for lookup (`lookupBindingsAt` dedupes by `def.nodeId`) but would
|
||||
* make the channel's contents depend on how many times the hook ran. Other
|
||||
* bindings under the same name are left alone — the channel is append-only per
|
||||
* I8, and the whole point of the augmentation tier is that a scope's own
|
||||
* `Scope.bindings` are consulted first and still win.
|
||||
*/
|
||||
function appendBinding(
|
||||
indexes: ScopeResolutionIndexes,
|
||||
scopeId: ScopeId,
|
||||
alias: string,
|
||||
def: SymbolDefinition,
|
||||
): void {
|
||||
const channel = indexes.bindingAugmentations as Map<ScopeId, Map<string, BindingRef[]>>;
|
||||
let byName = channel.get(scopeId);
|
||||
if (byName === undefined) {
|
||||
byName = new Map<string, BindingRef[]>();
|
||||
channel.set(scopeId, byName);
|
||||
}
|
||||
const bucket = byName.get(alias);
|
||||
if (bucket === undefined) {
|
||||
byName.set(alias, [{ def, origin: 'local' }]);
|
||||
return;
|
||||
}
|
||||
if (bucket.some((ref) => ref.def.nodeId === def.nodeId)) return;
|
||||
bucket.push({ def, origin: 'local' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind every `@This()` alias declared in `parsed` at container or file level.
|
||||
*
|
||||
* Called per file from `populateZigRangeBindings`, which already holds the
|
||||
* parsed tree — a second pass over `parsedFiles` would re-parse every file when
|
||||
* the tree cache is cold. That is the only thing the two share. Ordering
|
||||
* against the payload bindings that follow does not matter, twice over: the
|
||||
* payload walk types its subjects through `findReceiverTypeBinding`, which
|
||||
* never reads the channel written here, and a `@This()` alias is a
|
||||
* container-private name no other file can import, so nothing outside this file
|
||||
* reads it either.
|
||||
*/
|
||||
export function bindZigThisAliases(
|
||||
parsed: ParsedFile,
|
||||
root: SyntaxNode,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
): void {
|
||||
const moduleScope = parsed.scopes.find((s) => s.kind === 'Module');
|
||||
if (moduleScope === undefined) return;
|
||||
const fileStruct = fileStructScope(parsed.scopes, moduleScope);
|
||||
|
||||
const visit = (node: SyntaxNode): void => {
|
||||
const alias = thisAliasName(node);
|
||||
if (alias !== undefined) {
|
||||
const parent = node.parent;
|
||||
if (parent?.type === 'source_file') {
|
||||
// The file-as-struct's own name. Bound at the MODULE scope, which is
|
||||
// where the container is already bound under the file stem, so the
|
||||
// alias and the stem are visible to exactly the same sites.
|
||||
const def = fileStruct === undefined ? undefined : containerDefOf(fileStruct);
|
||||
if (def !== undefined) appendBinding(indexes, moduleScope.id, alias, def);
|
||||
} else if (parent !== null && ZIG_CONTAINER_TYPES.has(parent.type)) {
|
||||
const scope = enclosingClassScope(parsed.scopes, node);
|
||||
const def = scope === undefined ? undefined : containerDefOf(scope);
|
||||
if (scope !== undefined && def !== undefined) appendBinding(indexes, scope.id, alias, def);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child !== null) visit(child);
|
||||
}
|
||||
};
|
||||
visit(root);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import type { ParsedFile, ReferenceSite } from 'gitnexus-shared';
|
||||
import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
import type { ZigBuildZonConfig } from '../../language-config.js';
|
||||
import { zigPackageFor, type ZigWorkspaceIndex } from '../../language-config.js';
|
||||
import { resolveZigImportInternal } from '../../import-resolvers/zig.js';
|
||||
import {
|
||||
buildZigBoolConstMap,
|
||||
|
|
@ -51,7 +51,11 @@ export function populateZigWorkspaceStaticGating(
|
|||
tree,
|
||||
parsed.filePath,
|
||||
knownPaths,
|
||||
ctx.resolutionConfig as ZigBuildZonConfig | null | undefined,
|
||||
// `resolutionConfig` is the whole Zig workspace; the package governing
|
||||
// THIS file is what `resolveZigImportInternal` takes. Selected here rather
|
||||
// than hoisted out of the loop because the answer is per-file: two files
|
||||
// of this pass can belong to different packages.
|
||||
zigPackageFor(ctx.resolutionConfig as ZigWorkspaceIndex | null | undefined, parsed.filePath),
|
||||
);
|
||||
if (aliases.size === 0) continue;
|
||||
const ranges = collectZigStaticGatedRanges(
|
||||
|
|
@ -76,7 +80,7 @@ function collectImportAliases(
|
|||
tree: ZigTree,
|
||||
fromFile: string,
|
||||
knownPaths: ReadonlySet<string>,
|
||||
resolutionConfig?: ZigBuildZonConfig | null,
|
||||
packageConfig: ReturnType<typeof zigPackageFor>,
|
||||
): ZigImportAliasMap {
|
||||
const candidates = new Map<string, string>();
|
||||
const declarationCounts = new Map<string, number>();
|
||||
|
|
@ -91,7 +95,7 @@ function collectImportAliases(
|
|||
const raw = builtin?.descendantsOfType('string').at(0)?.text;
|
||||
if (raw === undefined) continue;
|
||||
const specifier = raw.replace(/^['"]|['"]$/g, '');
|
||||
const target = resolveZigImportInternal(fromFile, specifier, knownPaths, resolutionConfig);
|
||||
const target = resolveZigImportInternal(fromFile, specifier, knownPaths, packageConfig);
|
||||
if (target !== null) candidates.set(binding, target);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
computeChunkHash,
|
||||
loadParseCacheChunk,
|
||||
persistParseCacheChunk,
|
||||
markParseCacheChunkStale,
|
||||
PARSE_CACHE_VERSION,
|
||||
packParseCacheChunks,
|
||||
} from '../../../storage/parse-cache.js';
|
||||
|
|
@ -37,6 +38,7 @@ import {
|
|||
loadDurableParsedFileIndex,
|
||||
prepareDurableParsedFileChunk,
|
||||
durableChunkHasShards,
|
||||
durableChunkHasStaleShards,
|
||||
} from '../../../storage/parsedfile-store.js';
|
||||
import type { ParseWorkerResult } from '../workers/parse-worker.js';
|
||||
import { DEFAULT_PDG_MAX_FUNCTION_LINES } from '../cfg/collect.js';
|
||||
|
|
@ -932,8 +934,12 @@ export async function runChunkedParseAndResolve(
|
|||
* Chunk hashes whose durable ParsedFile directory could not be reset. The
|
||||
* old generation's shards are still on disk, so a warm hit would union
|
||||
* stale shards with the new ones. Treated exactly like a quarantined chunk:
|
||||
* skip the parse-cache write so the next run re-dispatches into a clean
|
||||
* directory rather than trusting a generation we could not clear.
|
||||
* skip the parse-cache write AND, when shards from that generation are
|
||||
* still on disk, retire the hash (#3204): the old `.v8` is not carried
|
||||
* forward and the directory is dropped from the durable index, so the next
|
||||
* run re-dispatches. Kept as its own set rather than
|
||||
* read back off `staleKeys`, which is a superset — it is what selects the
|
||||
* warn below over the quarantine branch's dev-only log.
|
||||
*/
|
||||
const durablePrepareFailures = new Set<string>();
|
||||
|
||||
|
|
@ -1073,7 +1079,11 @@ export async function runChunkedParseAndResolve(
|
|||
// Persist raw results for this chunk hash (skipping when any chunk file
|
||||
// was worker-quarantined, so the narrower rawResults isn't cached under
|
||||
// the full-chunk key — see the original inline note / U20.U2).
|
||||
if (parseCache && p.chunkHash && rawResults.length > 0) {
|
||||
// `rawResults.length > 0` guards the WRITE only. A quarantined chunk
|
||||
// often returns nothing at all (the worker died on it), and that chunk
|
||||
// still has to be retired — otherwise its pre-existing `.v8` is copied
|
||||
// forward at save time (#3204).
|
||||
if (parseCache && p.chunkHash) {
|
||||
const quarantineSet = new Set(workerPool?.getQuarantinedPaths?.() ?? []);
|
||||
const chunkHadQuarantine = p.chunkFiles.some((f) => quarantineSet.has(f.path));
|
||||
const durableGenerationStale = durablePrepareFailures.has(p.chunkHash);
|
||||
|
|
@ -1084,6 +1094,10 @@ export async function runChunkedParseAndResolve(
|
|||
'so its shards may be stale; next run will re-dispatch it',
|
||||
);
|
||||
} else if (chunkHadQuarantine) {
|
||||
// This chunk's durable directory now holds only this run's NARROWER
|
||||
// shards, so a warm hit would replay the full-coverage `.v8` over
|
||||
// partial ParsedFiles (#3204).
|
||||
markParseCacheChunkStale(parseCache, p.chunkHash);
|
||||
if (isDev) {
|
||||
const quarantinedInChunk = p.chunkFiles.filter((f) => quarantineSet.has(f.path)).length;
|
||||
logger.info(
|
||||
|
|
@ -1092,7 +1106,7 @@ export async function runChunkedParseAndResolve(
|
|||
`next run will rediscover (${p.chunkHash.slice(0, 8)})`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
} else if (rawResults.length > 0) {
|
||||
await persistParseCacheChunk(parseCache, p.chunkHash, rawResults);
|
||||
if (isDev) {
|
||||
logger.info(
|
||||
|
|
@ -1142,6 +1156,22 @@ export async function runChunkedParseAndResolve(
|
|||
// directory on write, so at worst the old generation lingers.
|
||||
// Caught per chunk so one failure cannot abort the others.
|
||||
durablePrepareFailures.add(miss.chunkHash);
|
||||
// Retire ONLY when a generation nobody cleared is still on disk.
|
||||
// `prepareDurableParsedFileChunk` is rm-then-mkdir: an rm failure
|
||||
// leaves the old shards to be unioned into a warm hit, but an rm
|
||||
// that succeeded before a failing mkdir leaves nothing — the
|
||||
// workers recreate the directory and write a clean generation, so
|
||||
// retiring there would discard a good `.v8` for no safety gain
|
||||
// (and a correlated burst would discard the whole shared cache).
|
||||
// Retire here rather than at finalize: that branch sits behind
|
||||
// `rawResults.length > 0`, so a chunk whose worker round returns
|
||||
// nothing would keep its stale entry.
|
||||
if (
|
||||
parseCache &&
|
||||
(await durableChunkHasStaleShards(durableParsedFileDir, miss.chunkHash))
|
||||
) {
|
||||
markParseCacheChunkStale(parseCache, miss.chunkHash);
|
||||
}
|
||||
logger.warn(
|
||||
{ err, chunkHash: miss.chunkHash.slice(0, 8) },
|
||||
'parsedfile-cache: could not reset durable chunk generation; ' +
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@
|
|||
*/
|
||||
|
||||
import { createKnowledgeGraph } from '../graph/graph.js';
|
||||
import type { KnowledgeGraph } from '../graph/types.js';
|
||||
import { GLOBAL_NAME_FALLBACK_REASON } from '../graph/edge-reasons.js';
|
||||
import { GraphEmitSink, type GraphEmitManifest } from '../lbug/graph-emit-sink.js';
|
||||
import { type PipelineProgress } from 'gitnexus-shared';
|
||||
import { PipelineResult } from '../../types/pipeline.js';
|
||||
|
|
@ -410,6 +412,11 @@ export const runPipelineFromRepo = async (
|
|||
graphEmitSink?.close();
|
||||
}
|
||||
|
||||
// Resolved-call index for the name-fallback census: read through the SINK,
|
||||
// whose field-wise scan includes every streamed edge, not through `graph`,
|
||||
// which under streaming holds none of them.
|
||||
const resolvedCalleeNamesByCaller = collectResolvedCalleeNames(graphEmitSink ?? graph, graph);
|
||||
|
||||
// Extract final results for the PipelineResult contract
|
||||
const {
|
||||
totalFiles,
|
||||
|
|
@ -473,6 +480,7 @@ export const runPipelineFromRepo = async (
|
|||
communityResult,
|
||||
processResult,
|
||||
resolutionOutcomes,
|
||||
resolvedCalleeNamesByCaller,
|
||||
undecidedSatisfaction,
|
||||
usedWorkerPool,
|
||||
reparsedFileCount,
|
||||
|
|
@ -518,3 +526,29 @@ export const runPipelineFromRepo = async (
|
|||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Caller node id → the simple names of every callee it has a CALLS edge to.
|
||||
*
|
||||
* `edges` may be the streaming sink or the raw graph; `nodes` is always the raw
|
||||
* graph, which holds every node in both modes (only relationships stream). One
|
||||
* O(E) field-wise pass, allocation-free per edge except for the per-caller set.
|
||||
*/
|
||||
export function collectResolvedCalleeNames(
|
||||
edges: Pick<KnowledgeGraph, 'forEachRelationshipFields'>,
|
||||
nodes: Pick<KnowledgeGraph, 'getNode'>,
|
||||
): ReadonlyMap<string, ReadonlySet<string>> {
|
||||
const out = new Map<string, Set<string>>();
|
||||
edges.forEachRelationshipFields((sourceId, targetId, type, _confidence, reason) => {
|
||||
if (type !== 'CALLS' || reason === GLOBAL_NAME_FALLBACK_REASON) return;
|
||||
const name = nodes.getNode(targetId)?.properties.name;
|
||||
if (typeof name !== 'string' || name === '') return;
|
||||
let names = out.get(sourceId);
|
||||
if (names === undefined) {
|
||||
names = new Set<string>();
|
||||
out.set(sourceId, names);
|
||||
}
|
||||
names.add(name);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,9 @@
|
|||
* Processes help agents understand how features work through the codebase.
|
||||
*/
|
||||
|
||||
import type { GraphNode, NodeLabel } from 'gitnexus-shared';
|
||||
import type { GraphNode, NodeLabel, RelationshipType } from 'gitnexus-shared';
|
||||
import { KnowledgeGraph } from '../graph/types.js';
|
||||
import { isHeuristicEdgeReason } from '../graph/edge-reasons.js';
|
||||
import { CommunityMembership } from './community-processor.js';
|
||||
import { calculateEntryPointScore, isTestFile } from './entry-point-scoring.js';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
|
|
@ -436,12 +437,28 @@ type AdjacencyList = Map<string, string[]>;
|
|||
*/
|
||||
const MIN_TRACE_CONFIDENCE = 0.5;
|
||||
|
||||
/**
|
||||
* True when an edge may seed or extend a traced flow.
|
||||
*
|
||||
* The confidence floor alone is not sufficient: the global-name fallback emits
|
||||
* at exactly `MIN_TRACE_CONFIDENCE`, so a `<` comparison admits every one of
|
||||
* its guesses. A flow assembled from name guesses reads as a real execution
|
||||
* path through code that may never call each other, so the reason is checked
|
||||
* too — see `graph/edge-reasons.ts`.
|
||||
*/
|
||||
const isTraceableCallsEdge = (
|
||||
type: RelationshipType,
|
||||
confidence: number,
|
||||
reason: string,
|
||||
): boolean =>
|
||||
type === 'CALLS' && confidence >= MIN_TRACE_CONFIDENCE && !isHeuristicEdgeReason(reason);
|
||||
|
||||
const buildCallsGraph = (graph: KnowledgeGraph): AdjacencyList => {
|
||||
const adj = new Map<string, string[]>();
|
||||
|
||||
// Field-wise scan (#2680) — whole-graph walk, four fields, no object needed.
|
||||
graph.forEachRelationshipFields((sourceId, targetId, type, confidence) => {
|
||||
if (type !== 'CALLS' || confidence < MIN_TRACE_CONFIDENCE) return;
|
||||
// Field-wise scan (#2680) — whole-graph walk, five fields, no object needed.
|
||||
graph.forEachRelationshipFields((sourceId, targetId, type, confidence, reason) => {
|
||||
if (!isTraceableCallsEdge(type, confidence, reason)) return;
|
||||
const existing = adj.get(sourceId);
|
||||
if (existing === undefined) adj.set(sourceId, [targetId]);
|
||||
else existing.push(targetId);
|
||||
|
|
@ -453,8 +470,8 @@ const buildCallsGraph = (graph: KnowledgeGraph): AdjacencyList => {
|
|||
const buildReverseCallsGraph = (graph: KnowledgeGraph): AdjacencyList => {
|
||||
const adj = new Map<string, string[]>();
|
||||
|
||||
graph.forEachRelationshipFields((sourceId, targetId, type, confidence) => {
|
||||
if (type !== 'CALLS' || confidence < MIN_TRACE_CONFIDENCE) return;
|
||||
graph.forEachRelationshipFields((sourceId, targetId, type, confidence, reason) => {
|
||||
if (!isTraceableCallsEdge(type, confidence, reason)) return;
|
||||
const existing = adj.get(targetId);
|
||||
if (existing === undefined) adj.set(targetId, [sourceId]);
|
||||
else existing.push(sourceId);
|
||||
|
|
|
|||
|
|
@ -707,6 +707,10 @@ function buildDefFromDeclarationMatch(
|
|||
const isExplicit = parseBooleanCapture(match['@declaration.is-explicit']);
|
||||
const isDeleted = parseBooleanCapture(match['@declaration.is-deleted']);
|
||||
const isSynthetic = parseBooleanCapture(match['@declaration.is-synthetic']);
|
||||
// Tri-state on purpose: only a producer that saw the file's export surface
|
||||
// emits the marker, and both `true` and `false` are verdicts (see
|
||||
// `SymbolDefinition.isExported`). Absent stays absent.
|
||||
const isExported = parseBooleanCapture(match['@declaration.is-exported']);
|
||||
|
||||
return {
|
||||
nodeId: makeDefId(filePath, anchor.range, type, nameCap.text),
|
||||
|
|
@ -725,6 +729,7 @@ function buildDefFromDeclarationMatch(
|
|||
...(isExplicit === true ? { isExplicit: true } : {}),
|
||||
...(isDeleted === true ? { isDeleted: true } : {}),
|
||||
...(isSynthetic === true ? { isSynthetic: true } : {}),
|
||||
...(isExported !== undefined ? { isExported } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1037,8 +1042,7 @@ function pass3CollectImports(
|
|||
if (provider.interpretImport === undefined) return;
|
||||
// Hoisted: the capability is a property of the language, identical for every
|
||||
// match in the file. A provider that declares its imports do not execute
|
||||
// where they are written (C/C++ `#include`, Rust `use`, COBOL `COPY`) skips
|
||||
// the position walk entirely — position cannot defer something that never
|
||||
// where they are written skips the execution-deferral walk — position cannot defer something that never
|
||||
// runs, and marking one deferred would hide a real cycle. Absent reads as
|
||||
// `true`, so an undeclared provider is unchanged. See
|
||||
// `LanguageProvider.importsExecuteWhereWritten`.
|
||||
|
|
@ -1049,14 +1053,22 @@ function pass3CollectImports(
|
|||
const parsed = provider.interpretImport(match);
|
||||
if (parsed === null) continue;
|
||||
// The statement's own position, resolved to the innermost scope holding
|
||||
// it. An unlocatable anchor leaves the import unmarked, which reads as
|
||||
// it. Provenance is retained independently of execution timing. An
|
||||
// unlocatable anchor leaves the import unmarked, which reads as
|
||||
// "runs at initialization" — the fail-safe direction, since it can only
|
||||
// make `check --cycles` over-report.
|
||||
const inScopeId = positionCanDefer
|
||||
? positionIndex.atPosition(filePath, anchor.range.startLine, anchor.range.startCol)
|
||||
: undefined;
|
||||
const deferred = inScopeId !== undefined && runsOnlyWhenCalled(scopeTree, inScopeId);
|
||||
parsedImports.push(deferred ? { ...parsed, runsOnlyWhenCalled: true } : parsed);
|
||||
const inScopeId = positionIndex.atPosition(
|
||||
filePath,
|
||||
anchor.range.startLine,
|
||||
anchor.range.startCol,
|
||||
);
|
||||
const deferred =
|
||||
positionCanDefer && inScopeId !== undefined && runsOnlyWhenCalled(scopeTree, inScopeId);
|
||||
parsedImports.push({
|
||||
...parsed,
|
||||
...(inScopeId !== undefined ? { declaredAtScope: inScopeId } : {}),
|
||||
...(deferred ? { runsOnlyWhenCalled: true } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -82,13 +82,14 @@
|
|||
* `handledSites` IFF a `tryEmitEdge` call returned `true` for it.
|
||||
* Sites a pass touched but couldn't resolve do NOT get marked —
|
||||
* they still get a chance from the shared resolver. Exception:
|
||||
* the free-call fallback marks the site unconditionally after
|
||||
* attempting emission (even on dedup-collapse), because the
|
||||
* per-(caller, target) collapse semantics require multiple call
|
||||
* sites in the same caller body not produce multiple edges.
|
||||
* `preEmitInheritanceEdges` also pre-marks every `inherits` site so
|
||||
* the generic bridge cannot remap class heritage into method-owned
|
||||
* EXTENDS edges via `resolveCallerGraphId`.
|
||||
* the free-call fallback marks the site after it decides the site,
|
||||
* including when it emits no edge — dedup-collapse, a visibility
|
||||
* veto (`fallback-refused`), or a selected callable that is deleted
|
||||
* / invisible. The later generic pass must not recreate an edge
|
||||
* that pass just refused. `preEmitInheritanceEdges` also pre-marks
|
||||
* every `inherits` site so the generic bridge cannot remap class
|
||||
* heritage into method-owned EXTENDS edges via
|
||||
* `resolveCallerGraphId`.
|
||||
*
|
||||
* - **I3 — `propagateImportedReturnTypes` mutation timing + ordering.**
|
||||
* The pass mutates `Scope.typeBindings` (a plain `new Map(...)` from
|
||||
|
|
@ -248,8 +249,8 @@
|
|||
* ## Semantic-model source of truth
|
||||
*
|
||||
* `ParsedFile` (from `gitnexus-shared/src/scope-resolution/parsed-file.ts`)
|
||||
* is the single semantic model consumed by both the legacy DAG and the
|
||||
* scope-resolution pipeline. Scope-resolution passes MUST NOT build a
|
||||
* is the single semantic model consumed by the scope-resolution pipeline.
|
||||
* Scope-resolution passes MUST NOT build a
|
||||
* parallel parse representation; if a pass needs AST-level facts that
|
||||
* `ParsedFile` doesn't expose, it should reuse the orchestrator's
|
||||
* `treeCache` (see `RunScopeResolutionInput.treeCache`) rather than
|
||||
|
|
@ -257,23 +258,20 @@
|
|||
*
|
||||
* ## Same-graph guarantee
|
||||
*
|
||||
* Edges emitted by `runScopeResolution` and edges emitted by the legacy
|
||||
* DAG are indistinguishable to downstream consumers:
|
||||
* All language resolvers emit edges through `runScopeResolution` using the
|
||||
* shared graph contract:
|
||||
* - Node identity: same `generateId(...)` helper, same qualified-name
|
||||
* keyspace, same File/Folder/Method/Class node labels.
|
||||
* - Edge vocabulary: `'import-resolved' | 'global' | 'local-call' |
|
||||
* 'same-file' | 'interface-dispatch' | 'read' | 'write'` — both
|
||||
* paths emit the same reasons (see
|
||||
* `gitnexus/src/core/ingestion/call-processor.ts` for the legacy
|
||||
* emitter and `passes/receiver-bound-calls.ts` /
|
||||
* 'same-file' | 'interface-dispatch' | 'read' | 'write' |
|
||||
* 'global-name-fallback'` (see `passes/receiver-bound-calls.ts` /
|
||||
* `passes/free-call-fallback.ts` for the scope-resolution emitters).
|
||||
* - Overload disambiguation: both paths use
|
||||
* - Overload disambiguation: resolvers use
|
||||
* `generateId('Method', ...)` suffixed with `parameterTypes` when a
|
||||
* method has overloads — see `graph-bridge/ids.ts`.
|
||||
*
|
||||
* The CI parity workflow (`.github/workflows/ci-scope-parity.yml`)
|
||||
* runs both paths on every migrated language's fixture corpus and
|
||||
* fails if the graph outputs diverge.
|
||||
* Resolver fixture coverage lives in `ci-tests.yml` (the separate
|
||||
* `ci-scope-parity.yml` gate was removed in RING4-1 #942).
|
||||
*
|
||||
* Plan that introduced most of these invariants:
|
||||
* `docs/plans/2026-04-20-001-refactor-emit-pipeline-generalization-plan.md`.
|
||||
|
|
@ -865,6 +863,88 @@ export interface ScopeResolver {
|
|||
*/
|
||||
readonly allowGlobalFreeCallFallback?: boolean;
|
||||
|
||||
/** Opt in to binding imports at their extracted lexical scope, rather than
|
||||
* publishing every file's imports at module scope. */
|
||||
readonly importsBindAtLexicalScope?: boolean;
|
||||
|
||||
/**
|
||||
* Two `wildcard` re-exports that both DECLARE a name make it AMBIGUOUS in
|
||||
* this language — ECMAScript `export *` semantics, where the module simply
|
||||
* does not export the name and any binding is a guess. Opt-in: for a
|
||||
* language whose wildcard import is `#include`, `require` or a package
|
||||
* fan-out, the same name in two files is an overload set or a redeclaration,
|
||||
* and refusing it would delete real edges (C++ arity-narrowed overloads
|
||||
* across two headers). Forwarded to `FinalizeHooks.wildcardCollisionIsAmbiguous`.
|
||||
*/
|
||||
readonly exclusiveWildcardReexports?: boolean;
|
||||
|
||||
/**
|
||||
* A named import or named re-export can only bind to a MODULE-LEVEL
|
||||
* declaration of the target file — ECMAScript semantics, where
|
||||
* `import { x }` never reaches a class member. Opt-in: languages that bind
|
||||
* module-level members by bare name (static members, module functions)
|
||||
* leave it off. Forwarded to `FinalizeHooks.namedImportsBindTopLevelOnly`.
|
||||
*/
|
||||
readonly namedImportsBindTopLevelOnly?: boolean;
|
||||
|
||||
/**
|
||||
* Veto for a single `allowGlobalFreeCallFallback` guess.
|
||||
*
|
||||
* The fallback picks a callable because its SIMPLE NAME is unique in the
|
||||
* workspace — it consults no import and no scope chain. For most languages a
|
||||
* large share of those guesses are not merely unlikely but IMPOSSIBLE: Go
|
||||
* cannot call an unexported identifier from another package, ESM cannot see a
|
||||
* name it did not import, Rust cannot reach an item with no `use` path. This
|
||||
* hook is where a language states those rules, so the shared pass can drop
|
||||
* the edge instead of publishing a guess that the language forbids.
|
||||
*
|
||||
* Return `false` to REFUSE (no edge, recorded as `fallback-refused`). Return
|
||||
* `true`, or leave the hook undefined, to emit the labeled
|
||||
* `global-name-fallback` edge. **Only answer `false` when the call is
|
||||
* impossible, not when it is merely unproven** — a wrongly-refused candidate
|
||||
* is a lost real edge, whereas a wrongly-allowed one is at least labeled and
|
||||
* excluded from flows.
|
||||
*
|
||||
* Deliberately NOT folded into `isCallableVisibleFromCaller`: that hook also
|
||||
* gates precise dispatch paths (implicit-this, member calls), so a rule
|
||||
* written for the name-guess tier would silently suppress resolved edges too.
|
||||
*
|
||||
* `parsedFileOf` reaches the CANDIDATE's parse result — a language whose rule
|
||||
* depends on declaration-side facts not carried by `SymbolDefinition` (for
|
||||
* example Rust's `pub` form) needs it. `isExported` is the tri-state export
|
||||
* marker when the language supplies one; this hook still needs the parse for
|
||||
* visibility facts that marker does not represent. It returns `undefined`
|
||||
* for a path outside this pass's file set; treat that as "cannot decide"
|
||||
* and allow.
|
||||
*/
|
||||
readonly isGlobalNameFallbackPlausible?: (ctx: {
|
||||
readonly callerParsed: ParsedFile;
|
||||
readonly candidate: SymbolDefinition;
|
||||
readonly parsedFileOf: (filePath: string) => ParsedFile | undefined;
|
||||
/**
|
||||
* Raw source of any parsed file, for languages whose visibility rule needs
|
||||
* a declaration the parse does not carry (Go's package clause: a test file's
|
||||
* `package foo_test` is a different package from its directory's `foo`).
|
||||
* Absent when the pipeline has no contents at hand; hooks must then answer
|
||||
* from paths alone and, when undecidable, allow.
|
||||
*/
|
||||
readonly sourceTextOf?: (filePath: string) => string | undefined;
|
||||
/**
|
||||
* The call site, so a language can tell a BARE name from a PATH-QUALIFIED
|
||||
* one. Both reach this tier — a qualified call whose qualifier the resolver
|
||||
* could not follow falls through to the bare-name search with only its tail
|
||||
* name — but they are not the same claim. Rust's `User::new(...)` named its
|
||||
* type in source, so refusing it for lacking a `use` of the module would
|
||||
* delete an edge the source spells out.
|
||||
*/
|
||||
readonly site: {
|
||||
readonly name: string;
|
||||
readonly rawQualifiedName?: string;
|
||||
/** Lexical call-site scope; absent only for legacy/synthetic hook callers. */
|
||||
readonly inScope?: ScopeId;
|
||||
};
|
||||
}) => boolean;
|
||||
|
||||
/**
|
||||
* In this language every `Method` belongs to a class instance, so a
|
||||
* FREE (receiver-less) call may resolve to a `Method` only when the
|
||||
|
|
|
|||
|
|
@ -505,9 +505,25 @@ function resolveDefGraphIdUncached(
|
|||
if (qualifiedHit !== undefined) return qualifiedHit;
|
||||
}
|
||||
const simpleName = qn.lastIndexOf('.') === -1 ? qn : qn.slice(qn.lastIndexOf('.') + 1);
|
||||
// FAIL CLOSED before the label-agnostic simple key when a FUNCTION-LOCAL
|
||||
// callable of this name exists in the file. The guards above cover a def
|
||||
// that is itself a callable; a NON-callable def (`export const selected =
|
||||
// factory()`, a `Variable` with no graph node of its own) used to fall
|
||||
// through here and alias onto `wrapper.selected`, the function-local one
|
||||
// (#3182 review). Whatever the def's label, a bare name matching a local
|
||||
// callable is the aliasing this key cannot tell apart, and a missing edge
|
||||
// is the correct failure direction.
|
||||
for (const localLabel of LOCAL_CALLABLE_LABELS) {
|
||||
if (nodeLookup.get(localNameKey(filePath, localLabel, simpleName)) !== undefined) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return nodeLookup.get(simpleKey(filePath, simpleName));
|
||||
}
|
||||
|
||||
/** Labels the structure phase registers function-local declarations under. */
|
||||
const LOCAL_CALLABLE_LABELS: readonly NodeLabel[] = ['Function', 'Method'];
|
||||
|
||||
/** Derive the simple (unqualified) name of a def from its `qualifiedName`. */
|
||||
export function simpleQualifiedName(def: SymbolDefinition): string | undefined {
|
||||
const q = def.qualifiedName;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,212 @@
|
|||
/**
|
||||
* Per-language census of global-name fallback decisions: how many call sites
|
||||
* used a unique-name guess, and how many candidates visibility rules refused.
|
||||
* Sites are counted before edge coalescing: a precisely bound site can prove
|
||||
* the same caller/target dependency, so this is not a count of heuristic edges.
|
||||
*
|
||||
* Both halves are needed and neither is meaningful alone. A guess count with no
|
||||
* refusal count cannot distinguish a language with genuinely few impossible
|
||||
* candidates from one whose hook is missing; a refusal count with no guess count
|
||||
* cannot distinguish a working guard from one that rejects everything. The pair
|
||||
* is what makes the guard auditable on a real repository, which is the whole
|
||||
* point of recording it — before this, guessed edges were emitted with the same
|
||||
* reason and confidence as import-resolved ones and the number was unknowable.
|
||||
*
|
||||
* Structural sibling of `unresolved-receivers.ts`'s summary, and persisted the
|
||||
* same way (`RepoMeta.nameFallbackEdges`).
|
||||
*/
|
||||
|
||||
import { getLanguageFromFilename } from 'gitnexus-shared';
|
||||
import { logger } from '../../logger.js';
|
||||
import type { ResolutionOutcome } from './resolution-outcome.js';
|
||||
|
||||
/**
|
||||
* Distinct caller-file/callee-name pairs per language, from the caller→callee-name index the pipeline
|
||||
* builds while its streaming sink is live (so it is complete under `--force`),
|
||||
* bucketed by the CALLER's file language. Gives `NameFallbackSummary.byLanguage`
|
||||
* its denominator: a guess count is only readable as a share of the calls.
|
||||
*/
|
||||
export function countCallsByLanguage(
|
||||
index: ReadonlyMap<string, ReadonlySet<string>> | undefined,
|
||||
nodes: { getNode(id: string): { properties: Record<string, unknown> } | undefined } | undefined,
|
||||
): Readonly<Record<string, number>> | undefined {
|
||||
if (index === undefined || nodes === undefined) return undefined;
|
||||
const counts = new Map<string, number>();
|
||||
const namesByFile = new Map<string, Set<string>>();
|
||||
for (const [callerId, callees] of index) {
|
||||
const filePath = nodes.getNode(callerId)?.properties?.filePath;
|
||||
if (typeof filePath !== 'string') continue;
|
||||
let names = namesByFile.get(filePath);
|
||||
if (names === undefined) {
|
||||
names = new Set<string>();
|
||||
namesByFile.set(filePath, names);
|
||||
}
|
||||
for (const name of callees) names.add(name);
|
||||
}
|
||||
for (const [filePath, names] of namesByFile) {
|
||||
const language = getLanguageFromFilename(filePath) ?? 'unknown';
|
||||
counts.set(language, (counts.get(language) ?? 0) + names.size);
|
||||
}
|
||||
if (counts.size === 0) return undefined;
|
||||
const out: Record<string, number> = {};
|
||||
for (const [language, count] of [...counts.entries()].sort(([a], [b]) =>
|
||||
a < b ? -1 : a > b ? 1 : 0,
|
||||
)) {
|
||||
out[language] = count;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Unattributed bucket for a pass that recorded no language. */
|
||||
const UNKNOWN_LANGUAGE = 'unknown';
|
||||
|
||||
export interface NameFallbackLanguageCounts {
|
||||
/** Call sites resolved by a unique-name guess, before edge coalescing. */
|
||||
readonly guessed: number;
|
||||
/**
|
||||
* Distinct (caller file, callee name) pairs among those sites — the unit
|
||||
* `callsByLanguage` counts in, so `guessedPairs / callsByLanguage[lang]` is a
|
||||
* ratio bounded by 1. Absent on a summary persisted before this field existed.
|
||||
*/
|
||||
readonly guessedPairs?: number;
|
||||
/** Candidates this language's `isGlobalNameFallbackPlausible` hook refused. */
|
||||
readonly refused: number;
|
||||
}
|
||||
|
||||
export interface NameFallbackSummary {
|
||||
/** Language → guessed/refused counts. Languages with neither are absent. */
|
||||
readonly byLanguage: Readonly<Record<string, NameFallbackLanguageCounts>>;
|
||||
/** Guessed call sites, repo-wide. */
|
||||
readonly totalGuessed: number;
|
||||
/** Distinct (caller file, callee name) pairs among the guessed sites. */
|
||||
readonly distinctGuessedPairs?: number;
|
||||
readonly totalRefused: number;
|
||||
/**
|
||||
* Barrel names refused because two `export *` sources both declared them
|
||||
* (`reexport-ambiguous`). Not a guess and not per-language — a name the
|
||||
* finalize pass declined to bind at all — but it belongs in the same census:
|
||||
* it is the other place the resolver used to publish an arbitrary winner as
|
||||
* `import-resolved`.
|
||||
*/
|
||||
readonly totalAmbiguousReexports: number;
|
||||
/**
|
||||
* The refused barrel names themselves (`file:name`), sorted, capped at
|
||||
* `MAX_AMBIGUOUS_NAMES`. A count alone cannot say whether a refusal landed on
|
||||
* a name anyone calls; the list can be joined against the ledger's `byName`.
|
||||
*/
|
||||
readonly ambiguousReexportNames?: readonly string[];
|
||||
/**
|
||||
* Distinct caller-file/callee-name pairs per language (through any path), so `guessedPairs` can be
|
||||
* read as a SHARE of a language's call graph rather than a bare count. Absent
|
||||
* when the caller did not supply the totals.
|
||||
*/
|
||||
readonly callsByLanguage?: Readonly<Record<string, number>>;
|
||||
}
|
||||
|
||||
/** Bound on the persisted ambiguous-name list; the total beside it stays exact. */
|
||||
export const MAX_AMBIGUOUS_NAMES = 200;
|
||||
|
||||
/**
|
||||
* Build the summary, or `undefined` when the run neither guessed nor refused —
|
||||
* a repository with no opt-in language stores no key at all, so the artifact
|
||||
* stays absent rather than recording a row of zeroes.
|
||||
*/
|
||||
export function summarizeNameFallback(
|
||||
outcomes: readonly ResolutionOutcome[],
|
||||
callsByLanguage?: Readonly<Record<string, number>>,
|
||||
): NameFallbackSummary | undefined {
|
||||
const guessed = new Map<string, number>();
|
||||
const guessedPairsByLanguage = new Map<string, number>();
|
||||
const refused = new Map<string, number>();
|
||||
const ambiguousNames = new Set<string>();
|
||||
// Two units, both kept. `guessed` counts call SITES, not final graph edges:
|
||||
// a precise site may prove the same dependency during edge coalescing.
|
||||
// Preserve the unit earlier summaries hold. `guessedPairs` dedupes by (caller file,
|
||||
// callee name), the unit `callsByLanguage` is counted in: ten guessed `foo()`
|
||||
// calls in one file are one pair against a denominator that counts `foo`
|
||||
// once, so the guessy RATIO uses pairs and is bounded by 1. Changing the unit
|
||||
// of `guessed` itself silently read as a large improvement across engines.
|
||||
const guessedPairs = new Set<string>();
|
||||
let totalGuessed = 0;
|
||||
let totalRefused = 0;
|
||||
let totalAmbiguousReexports = 0;
|
||||
|
||||
for (const outcome of outcomes) {
|
||||
if (outcome.kind === 'fallback-guessed') {
|
||||
const language = outcome.language ?? UNKNOWN_LANGUAGE;
|
||||
guessed.set(language, (guessed.get(language) ?? 0) + 1);
|
||||
totalGuessed++;
|
||||
const pair = `${outcome.filePath}\u0000${outcome.name}`;
|
||||
if (!guessedPairs.has(pair)) {
|
||||
guessedPairs.add(pair);
|
||||
guessedPairsByLanguage.set(language, (guessedPairsByLanguage.get(language) ?? 0) + 1);
|
||||
}
|
||||
} else if (outcome.kind === 'fallback-refused') {
|
||||
const language = outcome.language ?? UNKNOWN_LANGUAGE;
|
||||
refused.set(language, (refused.get(language) ?? 0) + 1);
|
||||
totalRefused++;
|
||||
} else if (outcome.kind === 'reexport-ambiguous') {
|
||||
totalAmbiguousReexports++;
|
||||
ambiguousNames.add(`${outcome.filePath}:${outcome.name}`);
|
||||
}
|
||||
}
|
||||
if (totalGuessed === 0 && totalRefused === 0 && totalAmbiguousReexports === 0) return undefined;
|
||||
|
||||
const byLanguage: Record<string, NameFallbackLanguageCounts> = {};
|
||||
for (const language of new Set([...guessed.keys(), ...refused.keys()])) {
|
||||
byLanguage[language] = {
|
||||
guessed: guessed.get(language) ?? 0,
|
||||
guessedPairs: guessedPairsByLanguage.get(language) ?? 0,
|
||||
refused: refused.get(language) ?? 0,
|
||||
};
|
||||
}
|
||||
const sortedNames = [...ambiguousNames].sort();
|
||||
return {
|
||||
byLanguage,
|
||||
totalGuessed,
|
||||
distinctGuessedPairs: guessedPairs.size,
|
||||
totalRefused,
|
||||
totalAmbiguousReexports,
|
||||
...(sortedNames.length > 0
|
||||
? { ambiguousReexportNames: sortedNames.slice(0, MAX_AMBIGUOUS_NAMES) }
|
||||
: {}),
|
||||
...(callsByLanguage !== undefined ? { callsByLanguage } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line readout for the analyze summary. `undefined` when there is nothing to
|
||||
* report, so a run on a repository with no opt-in language prints no line.
|
||||
*/
|
||||
export function formatNameFallbackSummary(
|
||||
summary: NameFallbackSummary | undefined,
|
||||
): string | undefined {
|
||||
if (summary === undefined) return undefined;
|
||||
const perLanguage = Object.entries(summary.byLanguage)
|
||||
.sort(([, a], [, b]) => b.guessed + b.refused - (a.guessed + a.refused))
|
||||
.map(([language, counts]) => `${language} ${counts.guessed}/${counts.refused}`)
|
||||
.join(', ');
|
||||
const ambiguous =
|
||||
summary.totalAmbiguousReexports > 0
|
||||
? `; ${summary.totalAmbiguousReexports} barrel name(s) refused as ambiguous \`export *\``
|
||||
: '';
|
||||
const languages = perLanguage === '' ? 'none' : perLanguage;
|
||||
const pairs =
|
||||
summary.distinctGuessedPairs !== undefined
|
||||
? ` (${summary.distinctGuessedPairs} distinct caller-file/name pairs)`
|
||||
: '';
|
||||
const siteUnit = summary.totalGuessed === 1 ? 'call site' : 'call sites';
|
||||
return `name-fallback resolution: ${summary.totalGuessed} ${siteUnit}${pairs}, ${summary.totalRefused} refused as impossible (guessed/refused by language: ${languages})${ambiguous}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the readout as part of the analyze summary. Unconditional (not behind a
|
||||
* debug env var, unlike the receiver-drop diagnostic): a reader deciding how far
|
||||
* to trust this index's call graph needs to know how much of it is guessed, and
|
||||
* a number nobody sees is the state this work exists to end.
|
||||
*/
|
||||
export function logNameFallbackSummary(summary: NameFallbackSummary | undefined): void {
|
||||
const line = formatNameFallbackSummary(summary);
|
||||
if (line === undefined) return;
|
||||
logger.info(line);
|
||||
}
|
||||
|
|
@ -36,6 +36,7 @@ import type {
|
|||
ResolutionOutcomeRecorder,
|
||||
ResolutionSuppressionReason,
|
||||
} from '../resolution-outcome.js';
|
||||
import { GLOBAL_NAME_FALLBACK_REASON } from '../../../graph/edge-reasons.js';
|
||||
import { resolveCallerGraphId, resolveDefGraphId } from '../graph-bridge/ids.js';
|
||||
import type { CalleeIdSink } from '../graph-bridge/callee-id-sink.js';
|
||||
import {
|
||||
|
|
@ -43,7 +44,9 @@ import {
|
|||
findAllCallableBindingsInScope,
|
||||
findCallableBindingInScope,
|
||||
findCallableBindingsAndAdlBlocker,
|
||||
findClassBindingInScope,
|
||||
findEnclosingClassDef,
|
||||
isClassFileImportGrounded,
|
||||
resolveInheritanceBaseInScope,
|
||||
type CallableBindingCandidate,
|
||||
} from '../scope/walkers.js';
|
||||
|
|
@ -64,6 +67,15 @@ export function emitFreeCallFallback(
|
|||
workspaceIndex: WorkspaceResolutionIndex,
|
||||
options: {
|
||||
readonly allowGlobalFallback?: boolean;
|
||||
/** Language whose pass this is, carried onto the fallback outcome records
|
||||
* so the analyze summary can report guesses/refusals PER LANGUAGE. A
|
||||
* repo-wide total hides which language's rules are the loose ones. */
|
||||
readonly language?: string;
|
||||
/** Per-language veto on a name guess — see
|
||||
* `ScopeResolver.isGlobalNameFallbackPlausible`. */
|
||||
readonly isGlobalNameFallbackPlausible?: ScopeResolver['isGlobalNameFallbackPlausible'];
|
||||
/** Raw source lookup handed to `isGlobalNameFallbackPlausible` (optional). */
|
||||
readonly sourceTextOf?: (filePath: string) => string | undefined;
|
||||
/** When true, `Type(...)` constructor calls link to the Class def
|
||||
* itself rather than its explicit Constructor. Swift opts in. */
|
||||
readonly constructorCallTargetsClass?: boolean;
|
||||
|
|
@ -138,6 +150,14 @@ export function emitFreeCallFallback(
|
|||
let allFilePathsMemo: ReadonlySet<string> | undefined;
|
||||
const allFilePaths = (): ReadonlySet<string> =>
|
||||
(allFilePathsMemo ??= new Set(parsedFiles.map((p) => p.filePath)));
|
||||
// Candidate-side parse lookup for `isGlobalNameFallbackPlausible`. Built
|
||||
// lazily and once, on the same terms as `allFilePaths` above: a language
|
||||
// without the hook never pays for the index.
|
||||
let parsedByPathMemo: ReadonlyMap<string, ParsedFile> | undefined;
|
||||
const parsedFileByPath = (): ((filePath: string) => ParsedFile | undefined) => {
|
||||
parsedByPathMemo ??= new Map(parsedFiles.map((p) => [p.filePath, p]));
|
||||
return (filePath) => parsedByPathMemo!.get(filePath);
|
||||
};
|
||||
// Per-pass memo of pickUniqueGlobalCallable's post-filter candidate list,
|
||||
// keyed (simpleName, callerFilePath). Only created when no per-caller
|
||||
// visibility filter applies (the list is then a pure function of name+file —
|
||||
|
|
@ -183,7 +203,17 @@ export function emitFreeCallFallback(
|
|||
};
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
type PendingRel = { rel: Parameters<KnowledgeGraph['addRelationship']>[0]; gatedAll: boolean };
|
||||
type PendingRel = {
|
||||
rel: Parameters<KnowledgeGraph['addRelationship']>[0];
|
||||
gatedAll: boolean;
|
||||
/**
|
||||
* The confidence/reason a PRECISELY resolved site (a real binding, not a
|
||||
* unique-name guess) proved for this edge; `undefined` while every site
|
||||
* collapsed into it so far was a guess. Decided at flush, not by the
|
||||
* first site the walk met.
|
||||
*/
|
||||
precise: { confidence: number; reason: string } | undefined;
|
||||
};
|
||||
const pending = new Map<string, PendingRel>();
|
||||
const bindingCandidatesByScope =
|
||||
options.freeCallsRequireInstanceOwnership === true
|
||||
|
|
@ -199,13 +229,48 @@ export function emitFreeCallFallback(
|
|||
// to the Class node itself (implicit constructor). Legacy emits
|
||||
// the same two targets; see test expectations.
|
||||
let fnDef: SymbolDefinition | undefined;
|
||||
// Guess flag starts here so the constructor unique-class pick can mark
|
||||
// the same class of guess as `pickUniqueGlobalCallable` below. The
|
||||
// veto/label path keys off this flag; declaring it after that pick left
|
||||
// constructor-form unique-name hits labeled `import-resolved` at 0.85.
|
||||
let fnDefFromGlobalNameFallback = false;
|
||||
// Language visibility vetoes the constructed TYPE (Class/Struct), not a
|
||||
// Constructor child — Go refuses any `qualifiedName` containing `.`.
|
||||
let globalFallbackVetoTarget: SymbolDefinition | undefined;
|
||||
if (site.callForm === 'constructor') {
|
||||
const classDef = resolveInheritanceBaseInScope(
|
||||
// Lexical / written-qualifier first. A bare unique type with no
|
||||
// import/#include evidence is a guess (JS `new UniqueWidget()`,
|
||||
// Go unexported `uniqueWidget{}`). A unique type whose file is
|
||||
// imported, whose import resolved to this def, or whose name
|
||||
// was imported is precise — C++ `#include "user.h"` and Rust
|
||||
// `use`/`pub use` often mint no lexical class binding. An
|
||||
// imported sibling file of a different name is not evidence.
|
||||
let classDef = resolveInheritanceBaseInScope(
|
||||
site.inScope,
|
||||
site.name,
|
||||
scopes,
|
||||
site.rawQualifiedName,
|
||||
undefined,
|
||||
{ uniqueQualifiedNameFallback: site.rawQualifiedName !== undefined },
|
||||
);
|
||||
if (classDef === undefined) {
|
||||
classDef =
|
||||
findClassBindingInScope(site.inScope, site.name, scopes, undefined, {
|
||||
uniqueQualifiedNameFallback: true,
|
||||
}) ??
|
||||
(options.allowGlobalFallback === true
|
||||
? pickUniqueGlobalClass(site.name, globalClassesBySimpleName)
|
||||
: undefined);
|
||||
if (
|
||||
classDef !== undefined &&
|
||||
classDef.type !== 'Interface' &&
|
||||
site.rawQualifiedName === undefined &&
|
||||
!isClassFileImportGrounded(site.inScope, classDef, scopes, site.name)
|
||||
) {
|
||||
fnDefFromGlobalNameFallback = true;
|
||||
globalFallbackVetoTarget = classDef;
|
||||
}
|
||||
}
|
||||
if (classDef !== undefined && classDef.type !== 'Interface') {
|
||||
// Most languages link `Type(...)` to the explicit Constructor def
|
||||
// when one exists (else the Class). Languages that model the call
|
||||
|
|
@ -215,21 +280,6 @@ export function emitFreeCallFallback(
|
|||
options.constructorCallTargetsClass === true
|
||||
? classDef
|
||||
: pickConstructorOrClass(classDef, workspaceIndex, scopes, site.arity);
|
||||
} else if (options.allowGlobalFallback === true) {
|
||||
// The constructed type may live in a sibling/imported file that is
|
||||
// not in the call-site's lexical scope-chain bindings. Fall back to
|
||||
// a unique workspace-wide Class def by simple name (gated on the
|
||||
// same global-fallback opt-in as free calls). Then target the
|
||||
// Class or its Constructor per the language's preference.
|
||||
const globalClass = pickUniqueGlobalClass(site.name, globalClassesBySimpleName);
|
||||
if (globalClass !== undefined) {
|
||||
fnDef =
|
||||
globalClass.type === 'Interface'
|
||||
? undefined
|
||||
: options.constructorCallTargetsClass === true
|
||||
? globalClass
|
||||
: pickConstructorOrClass(globalClass, workspaceIndex, scopes, site.arity);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Module-qualified free call (`mod::fn()`): the source named the module
|
||||
|
|
@ -543,10 +593,17 @@ export function emitFreeCallFallback(
|
|||
}
|
||||
}
|
||||
}
|
||||
// V1: pickUniqueGlobalCallable ignores import context — resolves to any
|
||||
// globally-unique callable. False cross-package edges are possible when
|
||||
// the caller does not import the target package. Same-package calls are
|
||||
// usually caught by nearest-scope lookup before reaching here.
|
||||
// Name-guess tier: pickUniqueGlobalCallable consults no import context —
|
||||
// it resolves to any globally-unique callable. Same-package calls are
|
||||
// usually caught by nearest-scope lookup before reaching here, so what
|
||||
// lands in this tier is disproportionately cross-module, and a
|
||||
// cross-module name match is a guess.
|
||||
//
|
||||
// Two things make that honest rather than a lie. The language's
|
||||
// `isGlobalNameFallbackPlausible` hook refuses candidates its own
|
||||
// visibility rules forbid (below), and every edge that survives is
|
||||
// emitted with `GLOBAL_NAME_FALLBACK_REASON` at 0.5 rather than
|
||||
// masquerading as `import-resolved` at 0.85 (see the emit site).
|
||||
if (fnDef === undefined && options.allowGlobalFallback === true) {
|
||||
fnDef = pickUniqueGlobalCallable(
|
||||
site.name,
|
||||
|
|
@ -570,6 +627,53 @@ export function emitFreeCallFallback(
|
|||
scopeDefsCache,
|
||||
options.conversionOnlyArgTypePrefixes,
|
||||
);
|
||||
fnDefFromGlobalNameFallback = fnDef !== undefined;
|
||||
if (fnDef !== undefined) globalFallbackVetoTarget = fnDef;
|
||||
}
|
||||
if (fnDefFromGlobalNameFallback && fnDef !== undefined) {
|
||||
// An explicit named import cannot bind a declaration proven private
|
||||
// to another module. In particular, a rejected named import must not
|
||||
// reappear as a name guess to a class member or nested function.
|
||||
// Unknown export evidence (e.g. dynamic module exports) keeps the
|
||||
// existing fallback behavior.
|
||||
const vetoCandidate = globalFallbackVetoTarget ?? fnDef;
|
||||
const importsPrivateDeclaration =
|
||||
vetoCandidate.filePath !== parsed.filePath &&
|
||||
vetoCandidate.isExported === false &&
|
||||
parsed.parsedImports.some(
|
||||
(imported) =>
|
||||
(imported.kind === 'named' || imported.kind === 'alias') &&
|
||||
imported.localName === site.name,
|
||||
);
|
||||
if (
|
||||
importsPrivateDeclaration ||
|
||||
options.isGlobalNameFallbackPlausible?.({
|
||||
callerParsed: parsed,
|
||||
candidate: vetoCandidate,
|
||||
parsedFileOf: parsedFileByPath(),
|
||||
sourceTextOf: options.sourceTextOf,
|
||||
site: {
|
||||
name: site.name,
|
||||
rawQualifiedName: site.rawQualifiedName,
|
||||
inScope: site.inScope,
|
||||
},
|
||||
}) === false
|
||||
) {
|
||||
// The language proved this call impossible. Mark the site handled so
|
||||
// `emit-references` does not substitute its own looser guess for the
|
||||
// edge we just refused — the point is no edge, not a different one.
|
||||
options.recordResolutionOutcome?.({
|
||||
kind: 'fallback-refused',
|
||||
candidateId: fnDef.nodeId,
|
||||
language: options.language,
|
||||
phase: 'free-call-fallback',
|
||||
filePath: parsed.filePath,
|
||||
name: site.name,
|
||||
range: site.atRange,
|
||||
});
|
||||
handledSites.add(siteKey(parsed.filePath, site));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (fnDef === undefined) continue;
|
||||
if (fnDef.isDeleted === true) {
|
||||
|
|
@ -617,40 +721,78 @@ export function emitFreeCallFallback(
|
|||
site.atRange.startCol,
|
||||
tgtGraphId,
|
||||
);
|
||||
if (fnDefFromGlobalNameFallback) {
|
||||
options.recordResolutionOutcome?.({
|
||||
kind: 'fallback-guessed',
|
||||
targetId: fnDef.nodeId,
|
||||
language: options.language,
|
||||
phase: 'free-call-fallback',
|
||||
filePath: parsed.filePath,
|
||||
name: site.name,
|
||||
range: site.atRange,
|
||||
});
|
||||
}
|
||||
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 preciseHere = fnDefFromGlobalNameFallback
|
||||
? undefined
|
||||
: {
|
||||
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,
|
||||
),
|
||||
};
|
||||
const pendingRel = pending.get(relId);
|
||||
if (pendingRel !== undefined) {
|
||||
if (site.staticGated !== true) pendingRel.gatedAll = false;
|
||||
// The edge's label is decided at flush time from EVERY site that
|
||||
// collapsed into it, not from whichever the walk met first. One site
|
||||
// resolved through a real binding PROVES the dependency; a guessed
|
||||
// site for the same pair is then redundant evidence, not a taint.
|
||||
if (pendingRel.precise === undefined) pendingRel.precise = preciseHere;
|
||||
continue;
|
||||
}
|
||||
if (seen.has(relId)) continue;
|
||||
seen.add(relId);
|
||||
pending.set(relId, {
|
||||
gatedAll: site.staticGated === true,
|
||||
precise: preciseHere,
|
||||
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,
|
||||
),
|
||||
// Guess values as placeholders; decided at flush from `precise`.
|
||||
confidence: 0.5,
|
||||
reason: GLOBAL_NAME_FALLBACK_REASON,
|
||||
},
|
||||
});
|
||||
emitted++;
|
||||
}
|
||||
for (const { rel, gatedAll } of pending.values()) {
|
||||
graph.addRelationship(gatedAll ? { ...rel, staticGated: true } : rel);
|
||||
for (const { rel, gatedAll, precise } of pending.values()) {
|
||||
// A name guess is not an import resolution and must not be spelled like
|
||||
// one. It used to be emitted at 0.85 / `'import-resolved'`, which made
|
||||
// it indistinguishable from an edge a real import produced — so every
|
||||
// consumer that wanted to discount guesses had no field to do it with.
|
||||
// 0.5 is the deliberate "coin flip" value, and the reason is what the
|
||||
// process/community walks and the MCP tools actually key on, because
|
||||
// 0.5 sits exactly ON their thresholds (see graph/edge-reasons.ts).
|
||||
// An edge is a guess only when EVERY site that collapsed into it was one;
|
||||
// a single precisely resolved site proves it, whatever order the walk
|
||||
// met the sites in. Independent of `gatedAll`.
|
||||
const labeled =
|
||||
precise !== undefined
|
||||
? { ...rel, confidence: precise.confidence, reason: precise.reason }
|
||||
: rel;
|
||||
graph.addRelationship(gatedAll ? { ...labeled, staticGated: true } : labeled);
|
||||
}
|
||||
}
|
||||
return emitted;
|
||||
|
|
|
|||
|
|
@ -120,19 +120,10 @@ export function followChainPostFinalize(
|
|||
* typeBindings — the in-extractor pass-4 ran before propagation and
|
||||
* missed any chain whose terminal lived in a foreign file.
|
||||
*
|
||||
* Scope-chain concern (verified 2026-04-21): `pythonImportOwningScope`
|
||||
* documents that function-local `from x import y` binds `y` to the
|
||||
* inner function scope, which would make a module-only write miss
|
||||
* non-module importers. In practice `finalize-algorithm` hoists those
|
||||
* bindings into `indexes.bindings[moduleScope]` regardless of where
|
||||
* the `import` statement appears — the integration fixture
|
||||
* `python-function-local-import-chain` exercises a chained
|
||||
* receiver-bound call `u = get_user(); u.save()` inside a function
|
||||
* body and emits the expected `do_work → User.save` edge. The
|
||||
* module-scope write is sufficient today. If finalize routing ever
|
||||
* changes to honor the hook's per-scope contract, this pass must
|
||||
* iterate `indexes.bindings` over every scope and mirror into the
|
||||
* binding-owning scope's `typeBindings`, not just the module's.
|
||||
* Finalize can retain lexical import ownership. Mirror into each binding's
|
||||
* owning scope, never hoist local import-derived types into the file scope.
|
||||
* Module ordering remains SCC-based; non-module chains are followed after
|
||||
* all import bindings have been mirrored.
|
||||
*/
|
||||
export function propagateImportedReturnTypes(
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
|
|
@ -140,6 +131,7 @@ export function propagateImportedReturnTypes(
|
|||
index: WorkspaceResolutionIndex,
|
||||
): void {
|
||||
const moduleScopeByFile = index.moduleScopeByFile;
|
||||
const scopesByFile = new Map(parsedFiles.map((file) => [file.filePath, file.scopes]));
|
||||
|
||||
// Walk SCCs in reverse-topological order (`indexes.sccs` is leaves-
|
||||
// first per `tarjanSccs`). For each file we mirror import bindings
|
||||
|
|
@ -167,42 +159,44 @@ export function propagateImportedReturnTypes(
|
|||
// import-derived typeBinding mirror. Both helpers fast-path when
|
||||
// no augmentations exist for the scope, so the common case is
|
||||
// allocation-free. See I8.
|
||||
for (const localName of namesAtScope(importerModule.id, indexes)) {
|
||||
// Skip if importer already has a typeBinding for this name —
|
||||
// an explicit local annotation must win over import-derived.
|
||||
if (importerModule.typeBindings.has(localName)) continue;
|
||||
for (const importerScope of scopesByFile.get(filePath) ?? [importerModule]) {
|
||||
for (const localName of namesAtScope(importerScope.id, indexes)) {
|
||||
// Skip if importer already has a typeBinding for this name —
|
||||
// an explicit local annotation must win over import-derived.
|
||||
if (importerScope.typeBindings.has(localName)) continue;
|
||||
|
||||
const refs = lookupBindingsAt(importerModule.id, localName, indexes);
|
||||
for (const ref of refs) {
|
||||
if (ref.origin !== 'import' && ref.origin !== 'reexport' && ref.origin !== 'wildcard')
|
||||
continue;
|
||||
const sourceModule = moduleScopeByFile.get(ref.def.filePath);
|
||||
if (sourceModule === undefined) continue;
|
||||
const refs = lookupBindingsAt(importerScope.id, localName, indexes);
|
||||
for (const ref of refs) {
|
||||
if (ref.origin !== 'import' && ref.origin !== 'reexport' && ref.origin !== 'wildcard')
|
||||
continue;
|
||||
const sourceModule = moduleScopeByFile.get(ref.def.filePath);
|
||||
if (sourceModule === undefined) continue;
|
||||
|
||||
// The source file's typeBinding is keyed by the def's simple
|
||||
// name (e.g. `get_user`), not the importer's local alias.
|
||||
const qn = ref.def.qualifiedName;
|
||||
if (qn === undefined) continue;
|
||||
const dot = qn.lastIndexOf('.');
|
||||
const sourceName = dot === -1 ? qn : qn.slice(dot + 1);
|
||||
// The source file's typeBinding is keyed by the def's simple
|
||||
// name (e.g. `get_user`), not the importer's local alias.
|
||||
const qn = ref.def.qualifiedName;
|
||||
if (qn === undefined) continue;
|
||||
const dot = qn.lastIndexOf('.');
|
||||
const sourceName = dot === -1 ? qn : qn.slice(dot + 1);
|
||||
|
||||
const sourceTypeRef = sourceModule.typeBindings.get(sourceName);
|
||||
if (sourceTypeRef === undefined) continue;
|
||||
const sourceTypeRef = sourceModule.typeBindings.get(sourceName);
|
||||
if (sourceTypeRef === undefined) continue;
|
||||
|
||||
// Chain-follow inside the source module so we mirror the
|
||||
// terminal type, not an intermediate intra-source reference.
|
||||
const terminal = followChainPostFinalize(sourceTypeRef, sourceModule.id, indexes);
|
||||
// Chain-follow inside the source module so we mirror the
|
||||
// terminal type, not an intermediate intra-source reference.
|
||||
const terminal = followChainPostFinalize(sourceTypeRef, sourceModule.id, indexes);
|
||||
|
||||
// Mutating typeBindings is safe because draftToScope
|
||||
// produced a non-frozen Map (Contract Invariant I3/I8).
|
||||
(importerModule.typeBindings as Map<string, TypeRef>).set(localName, terminal);
|
||||
// First-write-wins for the local alias: if the same
|
||||
// `localName` was registered multiple times via
|
||||
// `mergeBindings` (rare; happens with conflicting
|
||||
// re-exports), only the first ref with a usable
|
||||
// typeBinding source is mirrored. Conflict resolution
|
||||
// among multiple sources is the merger's job, not ours.
|
||||
break;
|
||||
// Mutating typeBindings is safe because draftToScope
|
||||
// produced a non-frozen Map (Contract Invariant I3/I8).
|
||||
(importerScope.typeBindings as Map<string, TypeRef>).set(localName, terminal);
|
||||
// First-write-wins for the local alias: if the same
|
||||
// `localName` was registered multiple times via
|
||||
// `mergeBindings` (rare; happens with conflicting
|
||||
// re-exports), only the first ref with a usable
|
||||
// typeBinding source is mirrored. Conflict resolution
|
||||
// among multiple sources is the merger's job, not ours.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,9 +17,11 @@
|
|||
* registries only consult pre-finalize local bindings — imported names live
|
||||
* in finalized bindings (the same reason free calls need
|
||||
* `emitFreeCallFallback`) — so `resolveReferenceSites` skips `value-ref`
|
||||
* sites and this pass resolves them post-finalize via
|
||||
* `findCallableBindingInScope` (Function/Method/Constructor only — the
|
||||
* callable gate that keeps `{ port: DEFAULT_PORT }` from emitting anything).
|
||||
* sites and this pass resolves them post-finalize (see
|
||||
* `resolveValueRefTarget` — Function/Method/Constructor only, the callable
|
||||
* gate that keeps `{ port: DEFAULT_PORT }` from emitting anything, and
|
||||
* receiver-aware so a qualified reference binds the owner it was written
|
||||
* with).
|
||||
*
|
||||
* Precision posture (mirrors `emitInterfaceDispatchFor`):
|
||||
* - reason `'property-dispatch'` keeps synthesized CALLS auditable;
|
||||
|
|
@ -36,13 +38,29 @@
|
|||
* emits the capture participates) and generic member-call sites.
|
||||
*/
|
||||
|
||||
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type {
|
||||
BindingRef,
|
||||
ParsedFile,
|
||||
ReferenceSite,
|
||||
ScopeId,
|
||||
SymbolDefinition,
|
||||
} from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../../../graph/types.js';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import { tryEmitEdge, type CalleeIdCaptureCtx } from '../graph-bridge/edges.js';
|
||||
import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
|
||||
import type { CalleeIdSink } from '../graph-bridge/callee-id-sink.js';
|
||||
import { findCallableBindingInScope } from '../scope/walkers.js';
|
||||
import {
|
||||
findCallableBindingInScope,
|
||||
findClassBindingInScope,
|
||||
findOwnedMember,
|
||||
isNamespaceNameShadowed,
|
||||
isOwnerNameShadowedBySomethingElse,
|
||||
lookupBindingsAt,
|
||||
} from '../scope/walkers.js';
|
||||
import { VALUE_REF_EDGE_REASON } from '../value-ref-edges.js';
|
||||
import type { SemanticModel } from '../../model/semantic-model.js';
|
||||
import { CALL_TARGET_TYPES } from '../../model/symbol-table.js';
|
||||
|
||||
/**
|
||||
* Keys registered by more than this many distinct functions are skipped —
|
||||
|
|
@ -65,12 +83,282 @@ export const MAX_PROPERTY_DISPATCH_FANOUT = (() => {
|
|||
/** Below the 0.85 resolved baseline; same discount idea as interface-dispatch. */
|
||||
export const PROPERTY_DISPATCH_CONFIDENCE = 0.7;
|
||||
|
||||
/**
|
||||
* Resolve a `value-ref` site to the callable it names.
|
||||
*
|
||||
* Two shapes, and the difference matters:
|
||||
*
|
||||
* - BARE (`{ handler: onClick }`, `register(onTick)`) — the name is resolved
|
||||
* up the lexical chain, which is what an unqualified name means. Be exact
|
||||
* about what that walk does, because it is not a plain lexical lookup:
|
||||
* `findCallableBindingInScope` applies the callable predicate WHILE walking,
|
||||
* so a scope binding the name to a parameter or a local contributes nothing
|
||||
* and the walk continues outward. A nearer non-callable binding is stepped
|
||||
* over — the same shape the qualified path guards against below, unguarded
|
||||
* here. Pre-existing (#2437) and out of scope for #3399; reachable in JS/TS
|
||||
* (`function outer(handler) { return { h: handler }; }` beside a top-level
|
||||
* `function handler`), and narrow in Zig, which rejects a local shadowing a
|
||||
* container declaration.
|
||||
*
|
||||
* - QUALIFIED (`bridge.accessor(Element.getNamespaceUri, …)`) — the source
|
||||
* WROTE the owner, so the lexical chain is the wrong instrument. It gives
|
||||
* local bindings precedence (`walkScopeChain`), so a nested container
|
||||
* holding its own `getNamespaceUri` answers first and the registration is
|
||||
* attached to a DIFFERENT function than the one written. That is a wrong
|
||||
* edge, not a missing one — strictly worse for a tool whose value is that
|
||||
* its edges can be trusted — and it is reachable in any language that
|
||||
* allows a same-named callable in a nested container, Zig included.
|
||||
*
|
||||
* So a qualified site resolves through its receiver: name the owner, then take
|
||||
* the member off that owner. An owner is either a MODULE or a CLASS-like
|
||||
* container — `utils.compare` and `Element.getNamespaceUri` are the same shape
|
||||
* written against the two kinds of namespace a language has, and the member-call
|
||||
* path already resolves both (receiver-bound-calls Case 1 / Case 2). Both are
|
||||
* tried here, MODULE FIRST: see `findNamespaceValueRefTarget` for why a module
|
||||
* receiver cannot simply be declined, and the comment on the call below for why
|
||||
* the container channel must not go first.
|
||||
*
|
||||
* If neither channel names the owner, or the owner is named but owns no such
|
||||
* callable, this DECLINES rather than falling back to the lexical walk.
|
||||
*
|
||||
* Be precise about what declining costs, because it is more than one reference:
|
||||
* a site that emits NO edge leaves no evidence for `impact`'s value-reference
|
||||
* probe to read, so the target keeps `epistemic: "exact"` — silence, not a
|
||||
* hedge. That is why the module channel above exists rather than being waved
|
||||
* through as "just a decline". What remains declined is the case where the
|
||||
* written receiver names nothing this index knows at all (a Zig `@This()` alias
|
||||
* whose name differs from its container's, an owner from outside the workspace):
|
||||
* there the alternative is not a hedge either, it is a confident edge to a
|
||||
* lexically-nearer function that the source did not name, and a wrong edge is
|
||||
* strictly worse than a missing one for a tool whose value is that its edges can
|
||||
* be trusted.
|
||||
*
|
||||
* `CALL_TARGET_TYPES`, not a hand-rolled label set: `findOwnedMember` also
|
||||
* answers with FIELDS, and a field named like the member would otherwise
|
||||
* register as if it were the callable.
|
||||
*
|
||||
* Exported for `bench/value-ref-resolution/measure.mjs`, which gates both the
|
||||
* resolved-target SET and the per-site cost of the four channels above across a
|
||||
* 4x file-count step. It is the per-site half of the pass, so timing it in
|
||||
* isolation is what makes a workspace-size dependency visible; timing
|
||||
* `emitPropertyDispatchCalls` would fold that signal into edge emission, and
|
||||
* re-implementing the channel order in the bench would pin the bench's idea of
|
||||
* this function rather than this function.
|
||||
*/
|
||||
export function resolveValueRefTarget(
|
||||
site: ReferenceSite,
|
||||
filePath: string,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
model: SemanticModel,
|
||||
publishesImportedNames: boolean,
|
||||
): SymbolDefinition | undefined {
|
||||
const receiverName = site.explicitReceiver?.name;
|
||||
if (receiverName === undefined) {
|
||||
return findCallableBindingInScope(site.inScope, site.name, scopes);
|
||||
}
|
||||
// NAMESPACE FIRST, and the order is load-bearing. `findClassBindingInScope`
|
||||
// does not stop at the scope chain: when its `isClassLike` walk misses — and a
|
||||
// namespace handle binds a Module, so it always misses — it falls back to
|
||||
// `scopes.qualifiedNames`, a WORKSPACE-wide index, and answers with the unique
|
||||
// def of that name anywhere in the repo. Trying it first therefore lets a
|
||||
// same-named container in a file this one never imported preempt the `@import`
|
||||
// this file actually wrote:
|
||||
//
|
||||
// const dom_utils = @import("dom_utils.zig"); // namespace-only module
|
||||
// … bridge.accessor(dom_utils.compare, …) // → decoy.zig's compare
|
||||
//
|
||||
// The shadow guard below cannot catch it: the import binds at MODULE scope,
|
||||
// which the guard treats as the floor. An import written in this file is the
|
||||
// strongest statement about what the name means here, so it outranks a global
|
||||
// guess — and when the handle is not an import of this file, this answers
|
||||
// nothing and the container channel runs exactly as before.
|
||||
const viaNamespace = findNamespaceValueRefTarget(
|
||||
site,
|
||||
filePath,
|
||||
receiverName,
|
||||
scopes,
|
||||
publishesImportedNames,
|
||||
);
|
||||
if (viaNamespace !== undefined) {
|
||||
// `'owned'` is NOT "no answer" — it is "this receiver is a namespace handle
|
||||
// this file wrote, and it names no callable member". The two must not be
|
||||
// conflated, because falling through from the second one reaches
|
||||
// `findClassBindingInScope`, whose miss path answers from the WORKSPACE-wide
|
||||
// qualified-name index: a same-named container in a file this one never
|
||||
// imported then supplies the member the written module does not have. That
|
||||
// is a confident edge into an unrelated file, and the owner-shadow guard
|
||||
// below does not stop it — a plain `const utils = @import("utils.zig");`
|
||||
// records a namespace IMPORT EDGE, not a module-scope binding, so the guard
|
||||
// sees nothing bound under the name and reads the container as unshadowed.
|
||||
// Verified with a fixture rather than argued: `dom_utils.onlyOnDecoy`, where
|
||||
// `dom_utils.zig` has no such member and `decoy.zig` declares a same-named
|
||||
// struct that does, minted `JsApi → onlyOnDecoy` before this line existed.
|
||||
//
|
||||
// The file said which module it meant. If that module does not expose the
|
||||
// name as a callable, the honest answer is no edge.
|
||||
return viaNamespace === 'owned' ? undefined : viaNamespace;
|
||||
}
|
||||
|
||||
const owner = findClassBindingInScope(site.inScope, receiverName, scopes);
|
||||
if (owner !== undefined) {
|
||||
// The container lookup is a CLASS-ONLY walk: `walkScopeChain` filters by
|
||||
// `isClassLike`, so it steps over a nearer binding that is a value and keeps
|
||||
// climbing — and past the scope chain entirely, into a qualified-name
|
||||
// fallback that answers with the unique workspace definition of the name.
|
||||
// `fn f(Ticker: u8) { register(Ticker.fire) }` in a file that neither
|
||||
// declares nor imports `Ticker` therefore resolves to some other file's
|
||||
// `Ticker` container. That is the wrong-edge failure R1-2 exists to prevent,
|
||||
// arriving through the class channel instead of the lexical one, and a
|
||||
// registration pointing at a function the source never named is worse than
|
||||
// no registration at all.
|
||||
//
|
||||
// So the name has to still MEAN that container at this site. The namespace
|
||||
// channel below asks the same question through `isNamespaceNameShadowed`;
|
||||
// a container needs the variant that exempts the container ITSELF, because
|
||||
// `fn make() { const Local = struct {…}; register(Local.go); }` binds the
|
||||
// name locally to the very def we resolved, and reading that as its own
|
||||
// shadow would suppress the resolutions this path exists to make.
|
||||
if (isOwnerNameShadowedBySomethingElse(receiverName, owner, site.inScope, scopes))
|
||||
return undefined;
|
||||
const member = findOwnedMember(owner.nodeId, site.name, model);
|
||||
if (member === undefined || !CALL_TARGET_TYPES.has(member.type)) return undefined;
|
||||
return member;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The second kind of owner: a namespace handle.
|
||||
*
|
||||
* `const utils = @import("utils.zig"); register(utils.compare);` — `utils` is a
|
||||
* MODULE, not a class, so `findClassBindingInScope` answers nothing and the
|
||||
* class path above declines. Declining here would be a silent hole rather than
|
||||
* a conservative one: no USES edge is emitted, so
|
||||
* `callableValueReferenceBoundaries` measures a real zero and `impact` on
|
||||
* `compare` republishes `exact` — the very claim this feature exists to stop
|
||||
* making. Nothing downstream can hedge on evidence that was never recorded.
|
||||
*
|
||||
* So resolve it, through the SAME channel the member-CALL path already trusts
|
||||
* for `utils.compare()` (receiver-bound-calls Case 1): the file's namespace
|
||||
* import edges name the target module, and the target module's own local
|
||||
* module-scope bindings name its members. `utils.compare` and `utils.compare()`
|
||||
* disagreeing about what `utils` is would be the anomaly.
|
||||
*
|
||||
* The same three guards Case 1 applies, for the same reasons:
|
||||
* - a LOCAL declaration shadowing the handle suppresses the resolution
|
||||
* (`isNamespaceNameShadowed`) — `fn f(utils: Decoy) { register(utils.compare) }`
|
||||
* names the parameter's member, and resolving through the import would be a
|
||||
* wrong edge rather than a missing one;
|
||||
* - a locally declared member wins, and a name the target file merely IMPORTED
|
||||
* counts only when the provider says its imports ARE its exports
|
||||
* (`ScopeResolver.namespaceExportsIncludeImportedNames`). That opt-in is not
|
||||
* a detail to skip: a Zig HUB — a file made only of re-exports, ghostty's
|
||||
* `src/terminal/`, tigerbeetle's `stdx` — declares nothing, so requiring a
|
||||
* local declaration declines every member reached through one. `hub.fn()`
|
||||
* resolves and `register(hub.fn)` would not, and one name would mean two
|
||||
* things depending on whether a `(` followed it. In languages that do not
|
||||
* opt in, a module's imports are not its exports and this stays closed;
|
||||
* - two distinct defs under one name resolve NOTHING. Never guess a namespace
|
||||
* member — the whole point of reading the written receiver is precision.
|
||||
*
|
||||
* `CALL_TARGET_TYPES` gates the answer for the same reason the class path needs
|
||||
* it: `utils.DEFAULT_PORT` is a module-scope binding too, and a registration
|
||||
* table full of constants must keep emitting nothing.
|
||||
*/
|
||||
function findNamespaceValueRefTarget(
|
||||
site: ReferenceSite,
|
||||
filePath: string,
|
||||
receiverName: string,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
publishesImportedNames: boolean,
|
||||
): SymbolDefinition | 'owned' | undefined {
|
||||
const moduleScopeId = scopes.moduleScopes.get(filePath);
|
||||
if (moduleScopeId === undefined) return undefined;
|
||||
const targetFiles: string[] = [];
|
||||
for (const edge of scopes.imports.get(moduleScopeId) ?? []) {
|
||||
if (edge.kind !== 'namespace' || edge.localName !== receiverName) continue;
|
||||
if (edge.targetFile === null) continue;
|
||||
if (!targetFiles.includes(edge.targetFile)) targetFiles.push(edge.targetFile);
|
||||
}
|
||||
if (targetFiles.length === 0) return undefined;
|
||||
if (isNamespaceNameShadowed(receiverName, site.inScope, scopes)) return undefined;
|
||||
|
||||
/** The unique callable `select` finds across every target file, or nothing. */
|
||||
const uniqueMember = (
|
||||
select: (moduleScope: ScopeId) => readonly BindingRef[],
|
||||
): SymbolDefinition | undefined | 'ambiguous' => {
|
||||
let picked: SymbolDefinition | undefined;
|
||||
for (const targetFile of targetFiles) {
|
||||
const targetScopeId = scopes.moduleScopes.get(targetFile);
|
||||
if (targetScopeId === undefined) continue;
|
||||
for (const ref of select(targetScopeId)) {
|
||||
if (!CALL_TARGET_TYPES.has(ref.def.type)) continue;
|
||||
if (picked !== undefined && picked.nodeId !== ref.def.nodeId) return 'ambiguous';
|
||||
picked = ref.def;
|
||||
}
|
||||
}
|
||||
return picked;
|
||||
};
|
||||
|
||||
// A locally declared member first — same precedence `findExportedDef` states
|
||||
// and `walkScopeChain` applies: what the target file DECLARED beats what it
|
||||
// merely re-published.
|
||||
const localRefs = (scope: ScopeId): readonly BindingRef[] =>
|
||||
(scopes.bindings.get(scope)?.get(site.name) ?? []).filter((ref) => ref.origin === 'local');
|
||||
const local = uniqueMember(localRefs);
|
||||
if (local === 'ambiguous') return 'owned';
|
||||
if (local !== undefined) return local;
|
||||
if (!publishesImportedNames) return 'owned';
|
||||
|
||||
// PRECEDENCE IS DECIDED BEFORE THE TYPE GATE, not by it. `uniqueMember`
|
||||
// applies `CALL_TARGET_TYPES` while it selects, so a target file declaring a
|
||||
// NON-callable under this name answers `undefined` above and would otherwise
|
||||
// fall through to the published channel — publishing a re-exported callable
|
||||
// under a name the module's own declaration owns. `findExportedDef` does not
|
||||
// do that: it returns any local def it finds and lets its caller's type gate
|
||||
// reject it, so `findExportedDefIncludingImportedNames` never reaches the
|
||||
// imported names for a name the file declares. Same rule here, so `x.f` and
|
||||
// `x.f()` cannot disagree about which module owns the name.
|
||||
//
|
||||
// Not reachable through valid Zig today — a container cannot declare a name
|
||||
// twice, so one target file cannot hold both spellings, and Zig is the only
|
||||
// provider that sets `namespaceExportsIncludeImportedNames`. It becomes
|
||||
// reachable the moment a second provider opts in, or a receiver binds more
|
||||
// than one target file; the guard is one `some` and the alternative failure
|
||||
// is a confident edge into the wrong module.
|
||||
const declaredLocally = targetFiles.some((targetFile) => {
|
||||
const targetScopeId = scopes.moduleScopes.get(targetFile);
|
||||
return targetScopeId !== undefined && localRefs(targetScopeId).length > 0;
|
||||
});
|
||||
if (declaredLocally) return 'owned';
|
||||
|
||||
// Then a name the target file publishes but did not declare — the hub case.
|
||||
// `lookupBindingsAt`, not `scopes.bindings`, because a hub's module scope owns
|
||||
// no local binding for these names and the finalized/augmented channel is the
|
||||
// only place they exist; the same read `findExportedDefIncludingImportedNames`
|
||||
// does for the CALL form.
|
||||
const published = uniqueMember((scope) =>
|
||||
lookupBindingsAt(scope, site.name, scopes).filter(
|
||||
(ref) => ref.origin === 'import' || ref.origin === 'namespace' || ref.origin === 'reexport',
|
||||
),
|
||||
);
|
||||
return published === 'ambiguous' || published === undefined ? 'owned' : published;
|
||||
}
|
||||
|
||||
export function emitPropertyDispatchCalls(
|
||||
graph: KnowledgeGraph,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
nodeLookup: GraphNodeLookup,
|
||||
model: SemanticModel,
|
||||
calleeIdSink?: CalleeIdSink,
|
||||
/**
|
||||
* `ScopeResolver.namespaceExportsIncludeImportedNames`, forwarded rather than
|
||||
* re-derived. The pass names no language; it asks the provider the same
|
||||
* question `receiver-bound-calls` asks before resolving a namespace member,
|
||||
* so the CALL and the REGISTRATION forms of `hub.fn` cannot disagree.
|
||||
*/
|
||||
publishesImportedNames = false,
|
||||
): {
|
||||
usesEmitted: number;
|
||||
callsEmitted: number;
|
||||
|
|
@ -86,18 +374,16 @@ export function emitPropertyDispatchCalls(
|
|||
for (const parsed of parsedFiles) {
|
||||
for (const site of parsed.referenceSites) {
|
||||
if (site.kind !== 'value-ref') continue;
|
||||
const def = findCallableBindingInScope(site.inScope, site.name, scopes);
|
||||
const def = resolveValueRefTarget(
|
||||
site,
|
||||
parsed.filePath,
|
||||
scopes,
|
||||
model,
|
||||
publishesImportedNames,
|
||||
);
|
||||
if (def === undefined) continue;
|
||||
|
||||
const ok = tryEmitEdge(
|
||||
graph,
|
||||
scopes,
|
||||
nodeLookup,
|
||||
site,
|
||||
def,
|
||||
'scope-resolution: value-ref',
|
||||
seen,
|
||||
);
|
||||
const ok = tryEmitEdge(graph, scopes, nodeLookup, site, def, VALUE_REF_EDGE_REASON, seen);
|
||||
if (ok) usesEmitted++;
|
||||
|
||||
if (site.propertyKey === undefined) continue;
|
||||
|
|
|
|||
|
|
@ -726,6 +726,7 @@ export function runScopeResolution(
|
|||
const resolutionConfig = input.resolutionConfig;
|
||||
const finalized = finalizeScopeModel(parsedFiles, {
|
||||
hooks: {
|
||||
importsBindAtLexicalScope: provider.importsBindAtLexicalScope === true,
|
||||
resolveImportTarget: (targetRaw, fromFile, _workspaceIndex, parsedImport) =>
|
||||
provider.resolveImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig, {
|
||||
parsedFiles,
|
||||
|
|
@ -737,9 +738,23 @@ export function runScopeResolution(
|
|||
provider.expandsWildcardTo?.(targetModuleScope, parsedFiles) ?? [],
|
||||
mergeBindings: (existing, incoming, scopeId) =>
|
||||
provider.mergeBindings(existing, incoming, scopeId),
|
||||
wildcardCollisionIsAmbiguous: provider.exclusiveWildcardReexports === true,
|
||||
namedImportsBindTopLevelOnly: provider.namedImportsBindTopLevelOnly === true,
|
||||
},
|
||||
});
|
||||
logHeapProbe('sr-post-finalize', `lang=${provider.language}`);
|
||||
// `export *` collisions the shared finalize refused to bind (WS1 C2). Recorded
|
||||
// as outcomes so the refusal is auditable next to the name-fallback census —
|
||||
// a silently unresolved importer is indistinguishable from a resolver gap.
|
||||
for (const refused of finalized.stats.ambiguousWildcardExports) {
|
||||
recordResolutionOutcome({
|
||||
kind: 'reexport-ambiguous',
|
||||
candidateIds: refused.candidateDefIds,
|
||||
phase: 'finalize',
|
||||
filePath: refused.filePath,
|
||||
name: refused.name,
|
||||
});
|
||||
}
|
||||
// One store and ONE writer rule for heritage instantiations (#2912), shared by
|
||||
// the pre-pass below and by the language hook further down — a heritage shape
|
||||
// the pre-pass cannot express (Rust `impl T for S`, Dart `implements`) records
|
||||
|
|
@ -1085,6 +1100,12 @@ export function runScopeResolution(
|
|||
workspaceIndex,
|
||||
{
|
||||
allowGlobalFallback: provider.allowGlobalFreeCallFallback === true,
|
||||
language: provider.language,
|
||||
isGlobalNameFallbackPlausible: provider.isGlobalNameFallbackPlausible,
|
||||
sourceTextOf:
|
||||
provider.isGlobalNameFallbackPlausible !== undefined
|
||||
? (filePath: string) => getFileContents().get(filePath)
|
||||
: undefined,
|
||||
constructorCallTargetsClass: provider.constructorCallTargetsClass === true,
|
||||
markConstructionSites: provider.markConstructionSites === true,
|
||||
isFileLocalDef: provider.isFileLocalDef,
|
||||
|
|
@ -1210,7 +1231,12 @@ export function runScopeResolution(
|
|||
indexes,
|
||||
emitParsedFiles,
|
||||
postHeritageNodeLookup,
|
||||
readonlyModel,
|
||||
calleeIdAccumulator,
|
||||
// Same provider hook the receiver-bound pass consults for a namespace
|
||||
// member (Case 1). Without it a hub module's re-exported callable
|
||||
// resolves when CALLED and declines when REGISTERED.
|
||||
provider.namespaceExportsIncludeImportedNames === true,
|
||||
);
|
||||
if (propertyDispatch.skippedKeys > 0) {
|
||||
// Never drop dispatch coverage silently: a hook table larger than the
|
||||
|
|
|
|||
|
|
@ -108,6 +108,60 @@ export type ResolutionOutcome =
|
|||
* every real codebase and taught readers to ignore it.
|
||||
*/
|
||||
readonly receiverOrigin?: ReceiverOrigin;
|
||||
}
|
||||
/**
|
||||
* The global-name fallback fired: a callable was chosen because its SIMPLE
|
||||
* NAME is unique in the workspace, with no import or scope chain leading to
|
||||
* it. A labeled low-confidence edge WAS emitted.
|
||||
*
|
||||
* Separate from `resolved` because it is not a resolution, and separate from
|
||||
* `suppressed` because an edge exists. Counting it is the only way a reader
|
||||
* can tell how much of a language's call graph rests on name uniqueness — the
|
||||
* number that was previously invisible because these edges were emitted with
|
||||
* the same reason and confidence as import-resolved ones.
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'fallback-guessed';
|
||||
readonly targetId: string;
|
||||
readonly language?: string;
|
||||
readonly phase: string;
|
||||
readonly filePath: string;
|
||||
readonly name: string;
|
||||
readonly range: Range;
|
||||
}
|
||||
/**
|
||||
* A global-name-fallback candidate was REFUSED by the language's plausibility
|
||||
* hook: the language's own visibility rules make that call impossible, so no
|
||||
* edge was emitted.
|
||||
*
|
||||
* The counterpart of `fallback-guessed`, and the pair is what makes the
|
||||
* refusal auditable — a refusal count with no guess count cannot distinguish
|
||||
* "the guard works" from "the guard rejects everything".
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'fallback-refused';
|
||||
readonly candidateId: string;
|
||||
readonly language?: string;
|
||||
readonly phase: string;
|
||||
readonly filePath: string;
|
||||
readonly name: string;
|
||||
readonly range: Range;
|
||||
}
|
||||
/**
|
||||
* A barrel re-exported `name` through two or more `export *` sources that
|
||||
* each declare it, so the language names no winner. The shared finalize pass
|
||||
* REFUSED the binding (see `FinalizeStats.ambiguousWildcardExports`) instead
|
||||
* of publishing the first-listed source as `import-resolved`; every importer
|
||||
* of `name` through `filePath` stays unresolved. No `range`: the collision
|
||||
* belongs to the file's export surface, not to one statement.
|
||||
*/
|
||||
| {
|
||||
readonly kind: 'reexport-ambiguous';
|
||||
readonly candidateIds: readonly string[];
|
||||
readonly phase: string;
|
||||
/** The barrel file whose `export *` sources collide. */
|
||||
readonly filePath: string;
|
||||
readonly name: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -353,6 +353,82 @@ export function isNamespaceNameShadowed(
|
|||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does something between `inScope` and its module scope bind `name` to
|
||||
* ANYTHING other than `def`?
|
||||
*
|
||||
* `isNamespaceNameShadowed` asks the same question for a namespace handle,
|
||||
* where any local binding of the name is by definition not the import. A
|
||||
* CONTAINER receiver needs the extra clause: the container may itself be the
|
||||
* local declaration (`fn make() { const Local = struct {…}; … Local.go … }`),
|
||||
* and reading that as its own shadow would suppress exactly the resolutions it
|
||||
* is meant to permit — the #2723 mistake, one channel over.
|
||||
*
|
||||
* So a scope that binds the name answers immediately, and the answer is "not
|
||||
* shadowed" only when one of that scope's bindings IS `def`. A name bound in a
|
||||
* nearer scope to something else — a parameter, a local, a type binding — wins
|
||||
* the lexical race, which is the whole point: `findClassBindingInScope` filters
|
||||
* the chain by `isClassLike` and therefore cannot see that it lost it.
|
||||
*
|
||||
* The MODULE scope is inspected too, unlike `isNamespaceNameShadowed`, and the
|
||||
* exemption is what makes that safe. That guard stops one rung short because a
|
||||
* namespace import writes its own name into the module scope and would read as
|
||||
* its own shadow (#2723); here the owner is compared by identity, so the binding
|
||||
* that IS the owner exempts itself and only a binding to something ELSE answers
|
||||
* `true`. Stopping short would leave the exact hole this walk exists to close:
|
||||
* `findClassBindingInScope` steps over a module-scope binding that is not
|
||||
* class-like and then answers from a WORKSPACE-wide qualified-name index, so
|
||||
* `const Gauge = @import("other.zig").SOME_CONST;` in a file that never imports
|
||||
* `Gauge.zig` would still resolve `Gauge.read` to that file's container.
|
||||
* `lookupBindingsAt` is used at that scope and only there, because an imported
|
||||
* alias lives in the finalized channel rather than in `scope.bindings`.
|
||||
*
|
||||
* Fail-closed like its sibling: a missing scope or a parent cycle answers
|
||||
* `true`, because suppressing a resolution costs a missing edge while trusting a
|
||||
* corrupt chain costs a wrong one.
|
||||
*/
|
||||
export function isOwnerNameShadowedBySomethingElse(
|
||||
name: string,
|
||||
def: SymbolDefinition,
|
||||
inScope: ScopeId,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
): boolean {
|
||||
let currentId: ScopeId | null = inScope;
|
||||
const visited = new Set<ScopeId>();
|
||||
while (currentId !== null) {
|
||||
if (visited.has(currentId)) return true;
|
||||
visited.add(currentId);
|
||||
const scope = scopes.scopeTree.getScope(currentId);
|
||||
if (scope === undefined) return true;
|
||||
if (scope.kind !== 'Object') {
|
||||
const isModule = scope.kind === 'Module';
|
||||
const imported = isModule ? lookupBindingsAt(currentId, name, scopes) : [];
|
||||
const bindsHere =
|
||||
scope.bindings.has(name) ||
|
||||
scope.typeBindings.has(name) ||
|
||||
scope.lexicalNames?.has(name) === true ||
|
||||
imported.length > 0 ||
|
||||
scope.ownedDefs.some((d) => {
|
||||
const qualifiedName = d.qualifiedName;
|
||||
if (qualifiedName === undefined) return false;
|
||||
const dot = qualifiedName.lastIndexOf('.');
|
||||
return (dot === -1 ? qualifiedName : qualifiedName.slice(dot + 1)) === name;
|
||||
});
|
||||
if (bindsHere) {
|
||||
if ((scope.bindings.get(name) ?? []).some((b) => b.def.nodeId === def.nodeId)) return false;
|
||||
if (scope.ownedDefs.some((d) => d.nodeId === def.nodeId)) return false;
|
||||
if (imported.some((b) => b.def.nodeId === def.nodeId)) return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// The module scope is the last rung, not a rung to skip: nothing above it
|
||||
// can shadow a name for this file.
|
||||
if (scope.kind === 'Module') return false;
|
||||
currentId = scope.parent;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function findReceiverTypeBinding(
|
||||
startScope: ScopeId,
|
||||
receiverName: string,
|
||||
|
|
@ -706,6 +782,20 @@ function soleBoundBaseName(bound: string): string | undefined {
|
|||
return base.length === 0 ? undefined : base;
|
||||
}
|
||||
|
||||
export type ClassBindingLookup = {
|
||||
/**
|
||||
* When false, skip the workspace-unique QualifiedNameIndex hit (and the
|
||||
* dotted-tail variant). Default true — Go inheritance still needs that
|
||||
* hit because namespace-style imports often create no scope binding.
|
||||
*
|
||||
* Constructor-form free calls pass false so a unique type name is not
|
||||
* treated as a precise in-scope binding. Those sites belong to
|
||||
* `pickUniqueGlobalClass`, which labels the edge as a guess and runs
|
||||
* `isGlobalNameFallbackPlausible`.
|
||||
*/
|
||||
readonly uniqueQualifiedNameFallback?: boolean;
|
||||
};
|
||||
|
||||
export function findClassBindingInScope(
|
||||
startScope: ScopeId,
|
||||
receiverName: string,
|
||||
|
|
@ -722,6 +812,7 @@ export function findClassBindingInScope(
|
|||
* selection. Only receiver-chain base and step resolution passes this.
|
||||
*/
|
||||
stripDecoration?: DecorationStripper,
|
||||
lookup?: ClassBindingLookup,
|
||||
): SymbolDefinition | undefined {
|
||||
// A TYPE PARAMETER is not a class, and it is checked before every route below
|
||||
// rather than inside one of them because each route would otherwise reach a
|
||||
|
|
@ -730,7 +821,13 @@ export function findClassBindingInScope(
|
|||
// fallback after stripping. The declaration that introduced the parameter is
|
||||
// the only thing that knows, and it knows for all three.
|
||||
if (bindsTypeParameter(startScope, receiverName, scopes)) {
|
||||
return resolveThroughTypeParameterBound(startScope, receiverName, scopes, stripDecoration);
|
||||
return resolveThroughTypeParameterBound(
|
||||
startScope,
|
||||
receiverName,
|
||||
scopes,
|
||||
stripDecoration,
|
||||
lookup,
|
||||
);
|
||||
}
|
||||
|
||||
const local = walkScopeChain(startScope, receiverName, scopes, (def) => isClassLike(def.type));
|
||||
|
|
@ -739,21 +836,25 @@ export function findClassBindingInScope(
|
|||
// Fallback for languages (Go) where namespace-style imports don't
|
||||
// create scope bindings: resolve via QualifiedNameIndex. Only fires
|
||||
// when the scope-chain walk found nothing; single-match wins.
|
||||
const qnames = scopes.qualifiedNames.get(receiverName);
|
||||
if (qnames.length === 1) {
|
||||
const def = scopes.defs.get(qnames[0]!);
|
||||
if (def !== undefined && isClassLike(def.type)) return def;
|
||||
}
|
||||
// Second fallback: dotted names like "models.User" — try the simple
|
||||
// name (tail after last dot) for languages where defs are indexed by
|
||||
// simple name (Go). Only when the dotted lookup fails.
|
||||
if (receiverName.includes('.')) {
|
||||
const simple = receiverName.slice(receiverName.lastIndexOf('.') + 1);
|
||||
if (simple.length > 0 && simple !== receiverName) {
|
||||
const simpleIds = scopes.qualifiedNames.get(simple);
|
||||
if (simpleIds.length === 1) {
|
||||
const def = scopes.defs.get(simpleIds[0]!);
|
||||
if (def !== undefined && isClassLike(def.type)) return def;
|
||||
// Constructor-form free calls opt out — a unique type name is a guess,
|
||||
// not proof the type is in scope (see `ClassBindingLookup`).
|
||||
if (lookup?.uniqueQualifiedNameFallback !== false) {
|
||||
const qnames = scopes.qualifiedNames.get(receiverName);
|
||||
if (qnames.length === 1) {
|
||||
const def = scopes.defs.get(qnames[0]!);
|
||||
if (def !== undefined && isClassLike(def.type)) return def;
|
||||
}
|
||||
// Second fallback: dotted names like "models.User" — try the simple
|
||||
// name (tail after last dot) for languages where defs are indexed by
|
||||
// simple name (Go). Only when the dotted lookup fails.
|
||||
if (receiverName.includes('.')) {
|
||||
const simple = receiverName.slice(receiverName.lastIndexOf('.') + 1);
|
||||
if (simple.length > 0 && simple !== receiverName) {
|
||||
const simpleIds = scopes.qualifiedNames.get(simple);
|
||||
if (simpleIds.length === 1) {
|
||||
const def = scopes.defs.get(simpleIds[0]!);
|
||||
if (def !== undefined && isClassLike(def.type)) return def;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -799,6 +900,7 @@ function resolveThroughTypeParameterBound(
|
|||
parameterName: string,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
stripDecoration?: DecorationStripper,
|
||||
lookup?: ClassBindingLookup,
|
||||
): SymbolDefinition | undefined {
|
||||
const bound = typeParameterAt(startScope, parameterName, scopes)?.bound;
|
||||
if (bound === undefined) return undefined;
|
||||
|
|
@ -806,7 +908,7 @@ function resolveThroughTypeParameterBound(
|
|||
if (baseName === undefined || baseName === parameterName) return undefined;
|
||||
// A bound naming another parameter terminates here rather than recursing.
|
||||
if (bindsTypeParameter(startScope, baseName, scopes)) return undefined;
|
||||
return findClassBindingInScope(startScope, baseName, scopes, stripDecoration);
|
||||
return findClassBindingInScope(startScope, baseName, scopes, stripDecoration, lookup);
|
||||
}
|
||||
|
||||
function normalizeTemplateArgToken(value: string): string {
|
||||
|
|
@ -1207,6 +1309,7 @@ export function resolveInheritanceBaseInScope(
|
|||
scopes: ScopeResolutionIndexes,
|
||||
rawQualifiedName?: string,
|
||||
enclosingClassDef?: SymbolDefinition,
|
||||
lookup?: ClassBindingLookup,
|
||||
): SymbolDefinition | undefined {
|
||||
// #1982: when the source wrote a qualified base (`Other::Inner`), resolve it
|
||||
// against the full-path QualifiedNameIndex FIRST, so a same-tail nested base
|
||||
|
|
@ -1226,7 +1329,7 @@ export function resolveInheritanceBaseInScope(
|
|||
if (qualified !== undefined) return qualified;
|
||||
}
|
||||
return (
|
||||
findClassBindingInScope(startScope, baseName, scopes) ??
|
||||
findClassBindingInScope(startScope, baseName, scopes, undefined, lookup) ??
|
||||
resolveAmbiguousInheritanceBaseViaImports(startScope, baseName, scopes)
|
||||
);
|
||||
}
|
||||
|
|
@ -1452,6 +1555,43 @@ function dirnameOf(filePath: string): string {
|
|||
return idx === -1 ? '' : filePath.slice(0, idx);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the caller can see `classDef` through import evidence, not
|
||||
* merely because the class name is workspace-unique.
|
||||
*
|
||||
* Exact imported file (`#include "user.h"`) and a finalize-resolved
|
||||
* `targetDefId` (named `use` / `pub use` followed to the def) are
|
||||
* precise. An import that *names* this class (`targetExportedName`)
|
||||
* is also precise — a barrel's `targetFile` is the re-exporting module,
|
||||
* not the defining file. A sibling-file import of a *different* name
|
||||
* is not: `use crate::a::bar` does not make `Foo` in `a/foo.rs` visible.
|
||||
*
|
||||
* Directory-only matching is intentionally omitted. The same-directory
|
||||
* tier on `resolveAmbiguousInheritanceBaseViaImports` is for picking
|
||||
* among *ambiguous* names (C# namespace `using`); it is not proof a
|
||||
* unique constructor type is in scope.
|
||||
*/
|
||||
export function isClassFileImportGrounded(
|
||||
startScope: ScopeId,
|
||||
classDef: Pick<SymbolDefinition, 'filePath' | 'nodeId'>,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
importedName: string,
|
||||
): boolean {
|
||||
const callerModule = moduleScopeIdOf(startScope, scopes);
|
||||
const callerFile = scopes.scopeTree.getScope(startScope)?.filePath;
|
||||
if (callerFile !== undefined && classDef.filePath === callerFile) return true;
|
||||
if (callerModule === null) return false;
|
||||
const importEdges = scopes.imports.get(callerModule);
|
||||
if (importEdges === undefined || importEdges.length === 0) return false;
|
||||
for (const edge of importEdges) {
|
||||
if (edge.targetDefId !== undefined && edge.targetDefId === classDef.nodeId) return true;
|
||||
if (edge.targetFile === null) continue;
|
||||
if (edge.targetFile === classDef.filePath) return true;
|
||||
if (importedName.length > 0 && edge.targetExportedName === importedName) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Predicate for value-receiver bridge: the labels for which
|
||||
* `reconcileOwnership` registers methods/fields under the def's
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* Language-agnostic primitives for `ScopeResolver.isGlobalNameFallbackPlausible`
|
||||
* implementations.
|
||||
*
|
||||
* The hook itself is per-language — the RULES here are not. What every
|
||||
* implementation needs is the same small set of path arithmetic: which
|
||||
* directory a file sits in, and whether a module path a caller wrote can name
|
||||
* a given file or directory. Those questions are about paths, not about any
|
||||
* language, so they live in shared code (see AGENTS.md: shared ingestion must
|
||||
* not name languages) and the language files supply only the semantics.
|
||||
*/
|
||||
|
||||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
|
||||
/** POSIX-style parent directory. `''` for a file at the repo root. */
|
||||
export function directoryOf(filePath: string): string {
|
||||
const slash = filePath.lastIndexOf('/');
|
||||
return slash === -1 ? '' : filePath.slice(0, slash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a module path into segments, accepting the three separators languages
|
||||
* spell module nesting with: `/` (Go, Node), `::` (Rust, C++) and `.` (JVM,
|
||||
* Python). Empty segments and `.` are dropped, so a leading `./` contributes
|
||||
* nothing. Named root prefixes are NOT stripped here — `crate::a` yields
|
||||
* `['crate', 'a']`; a language whose paths carry one (Rust's `crate::` /
|
||||
* `super::`) removes it before calling, see `rustUsePathOf`.
|
||||
*
|
||||
* `.` is only treated as a separator when the path contains no `/`: a Node
|
||||
* specifier like `./util/parse.js` must not split on the extension dot.
|
||||
*/
|
||||
export function moduleSegments(modulePath: string): readonly string[] {
|
||||
const bySlashOrColon = modulePath.split(/\/|::/).filter((s) => s !== '' && s !== '.');
|
||||
if (modulePath.includes('/')) return bySlashOrColon;
|
||||
return bySlashOrColon.flatMap((s) => s.split('.').filter((p) => p !== ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Does a module path a caller wrote reach `targetPath`?
|
||||
*
|
||||
* True when either side's segments are a SUFFIX of the other's. Both directions
|
||||
* are needed and neither alone is sufficient:
|
||||
*
|
||||
* - The written path is usually longer than the repo-relative one, because it
|
||||
* carries a module/package prefix that is not a directory
|
||||
* (`github.com/org/svc/internal/models` → `internal/models`).
|
||||
* - The repo-relative path is longer when the manifest that defines the module
|
||||
* root sits in a subdirectory of the analyzed tree (`svc/go.mod`, so
|
||||
* `svc/internal/models` is written `mod/internal/models`).
|
||||
*
|
||||
* So the match is on ALIGNED TRAILING SEGMENTS, and it succeeds when either the
|
||||
* shorter side is fully contained in the longer, or at least two segments align.
|
||||
* Both conditions are needed: full containment covers a one-segment package
|
||||
* (`mod/models` reaching `models`), while the two-segment floor covers the case
|
||||
* where neither side contains the other because each carries a different root
|
||||
* (`mod/internal/models` vs `svc/internal/models`). A SINGLE aligned segment
|
||||
* with neither side contained is not enough — `handlers` appearing at the end of
|
||||
* two unrelated trees says nothing.
|
||||
*
|
||||
* Tolerance is the SAFE direction here: this predicate is consulted to decide
|
||||
* whether to REFUSE an edge, so over-matching loses a refusal (the edge stays,
|
||||
* labeled and excluded from flows) while under-matching loses a real edge.
|
||||
*/
|
||||
export function modulePathReaches(writtenPath: string, targetPath: string): boolean {
|
||||
const written = moduleSegments(writtenPath);
|
||||
const target = moduleSegments(targetPath);
|
||||
if (written.length === 0 || target.length === 0) return false;
|
||||
const shorter = Math.min(written.length, target.length);
|
||||
let aligned = 0;
|
||||
while (
|
||||
aligned < shorter &&
|
||||
written[written.length - 1 - aligned] === target[target.length - 1 - aligned]
|
||||
) {
|
||||
aligned++;
|
||||
}
|
||||
return aligned === shorter || aligned >= 2;
|
||||
}
|
||||
|
||||
/** Every module path this file's import statements named, in source order. */
|
||||
function importedModulePaths(parsed: ParsedFile): readonly string[] {
|
||||
return parsed.parsedImports.map((imp) => imp.targetRaw);
|
||||
}
|
||||
|
||||
/** True when any of the caller's imports reaches `targetPath`. */
|
||||
export function anyImportReaches(parsed: ParsedFile, targetPath: string): boolean {
|
||||
for (const written of importedModulePaths(parsed)) {
|
||||
if (modulePathReaches(written, targetPath)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Strip a trailing file extension. `a/b.rs` → `a/b`; `a/b` → `a/b`. */
|
||||
export function stripExtension(filePath: string): string {
|
||||
const slash = filePath.lastIndexOf('/');
|
||||
const dot = filePath.lastIndexOf('.');
|
||||
return dot > slash ? filePath.slice(0, dot) : filePath;
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* The `reason` text stamped on the USES edge a `value-ref` site emits.
|
||||
*
|
||||
* A leaf module on purpose. Two modules need this string and they sit on
|
||||
* opposite sides of the product: `passes/property-dispatch.ts` writes it at
|
||||
* analysis time, and `mcp/local/local-backend.ts` reads it back at query time
|
||||
* to decide whether an answer is `exact`. Re-typing the literal in the reader
|
||||
* would make the epistemic signal fail SILENTLY the day the writer's text is
|
||||
* reworded — the query would simply match nothing and every answer would go
|
||||
* back to claiming certainty, which is the exact defect (#3399) this constant
|
||||
* exists to close. Importing `property-dispatch.ts` for it instead would drag
|
||||
* the whole scope-resolution emit graph into the MCP backend for one string.
|
||||
*/
|
||||
export const VALUE_REF_EDGE_REASON = 'scope-resolution: value-ref';
|
||||
|
|
@ -2243,18 +2243,26 @@ export const DART_QUERIES = `
|
|||
(identifier) @name . (formal_parameter_list))) @definition.constructor
|
||||
|
||||
; ── Field declarations (String name = '', Address address = Address()) ──────
|
||||
; @name is ANCHORED to the first named child. An initialized_identifier whose
|
||||
; value is a constructor call parses the callee as a SECOND (identifier) sibling
|
||||
; of the name — \`final TextEditingController _t = TextEditingController();\` is
|
||||
; initialized_identifier[identifier "_t", identifier "TextEditingController",
|
||||
; selector]. Unanchored, \`(identifier) @name\` matched both and minted a phantom
|
||||
; Property named after the TYPE alongside the real field. The same shape applies
|
||||
; to static_final_declaration (class static and top-level final/const), so every
|
||||
; rule below anchors. languages/dart/query.ts already anchors its mirror rules.
|
||||
(declaration
|
||||
(type_identifier)
|
||||
(initialized_identifier_list
|
||||
(initialized_identifier
|
||||
(identifier) @name))) @definition.property
|
||||
. (identifier) @name))) @definition.property
|
||||
|
||||
; ── Nullable field declarations (String? name) ──────────────────────────────
|
||||
(declaration
|
||||
(nullable_type)
|
||||
(initialized_identifier_list
|
||||
(initialized_identifier
|
||||
(identifier) @name))) @definition.property
|
||||
. (identifier) @name))) @definition.property
|
||||
|
||||
; ── static const / static final / const class fields ────────────────────────
|
||||
; A "static const a = 1;" / "static final String b = ..., c = ...;" field parses
|
||||
|
|
@ -2267,7 +2275,7 @@ export const DART_QUERIES = `
|
|||
(declaration
|
||||
(static_final_declaration_list
|
||||
(static_final_declaration
|
||||
(identifier) @name))) @definition.property
|
||||
. (identifier) @name))) @definition.property
|
||||
|
||||
; ── Getters ──────────────────────────────────────────────────────────────────
|
||||
(method_signature
|
||||
|
|
@ -2290,7 +2298,7 @@ export const DART_QUERIES = `
|
|||
(program
|
||||
(initialized_identifier_list
|
||||
(initialized_identifier
|
||||
(identifier) @name)) @definition.variable)
|
||||
. (identifier) @name)) @definition.variable)
|
||||
; Closure bindings: \`var f = (x) => x;\` binds a CALLABLE, so it emits Function
|
||||
; rather than Variable, matching TS/JS. Overlap with the pattern above is
|
||||
; collapsed by the parse-worker dedup (#2687). Since #2693 this node is also
|
||||
|
|
@ -2336,7 +2344,7 @@ export const DART_QUERIES = `
|
|||
(program
|
||||
(static_final_declaration_list
|
||||
(static_final_declaration
|
||||
(identifier) @name)) @definition.variable)
|
||||
. (identifier) @name)) @definition.variable)
|
||||
|
||||
; ── Imports ──────────────────────────────────────────────────────────────────
|
||||
(import_or_export
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue