* feat(ingestion): shadow-mode parity harness + static dashboard (#923, RFC #909 Ring 2 PKG)
Side-car observability for the RFC #909 registry rollout. Callers that
dual-run legacy-DAG + `Registry.lookup` feed their result pairs into
the harness; the harness diffs each pair via shared `diffResolutions`
(#918), aggregates via `aggregateDiffs`, and persists a per-language
parity report that the static dashboard can render offline.
## Shipped
### `gitnexus/src/core/ingestion/shadow-harness.ts` (new)
```ts
createShadowHarness(): ShadowHarness
```
API:
- `enabled` — `true` iff `GITNEXUS_SHADOW_MODE` is truthy at
construction. Captured once; later env-var mutations don't flip it.
- `record({ language, callsite, legacy, newResult, primary })` —
accumulator. No-op when `enabled === false` (near-zero overhead).
- `size()` — diagnostic counter.
- `snapshot(now?)` — deterministic `ShadowParityReport` from the
accumulated diffs.
- `persist(outputDir, now?)` — writes BOTH a timestamped
`<runId>.json` and a `latest.json` pointer. Creates outputDir if
absent. Returns the per-run file path.
- `clear()` — resets the accumulator; preserves `enabled`.
Activation: `GITNEXUS_SHADOW_MODE` accepts `'true'` / `'1'` / `'yes'`
(case-insensitive, trimmed); same truthy convention as
`REGISTRY_PRIMARY_<LANG>` from #924. Typos → disabled (fail-safe).
Persisted payload (`PersistedShadowReport`) is schema-versioned (`v1`):
```jsonc
{
"schemaVersion": 1,
"runId": "YYYYMMDD-HHMMSS-xxxxxxxx",
"generatedAt": "ISO 8601",
"primaryByLanguage": { "python": "legacy", ... },
"report": { /* ShadowParityReport from #918 aggregateDiffs */ }
}
```
`runId` prefix is the timestamp so files sort chronologically; the
entropy suffix prevents collisions within a clock-second.
### `gitnexus/shadow-parity-dashboard/index.html` (new)
Minimal static dashboard — one HTML file, zero build step, zero runtime
deps. Fetches `./latest.json` and renders:
- Overall summary cards (total calls, both agree, disagree, overall parity %)
- Per-language table: language tag ("primary: legacy" / "primary:
registry" pill) + total / agree / only-legacy / only-new / disagree
/ both-empty / parity%
- Parity cells colored by threshold: ≥95% green, ≥80% amber, <80% red
- Light / dark via `prefers-color-scheme`
- Empty-state message when no records yet
File-serving is static: `cp .gitnexus/shadow-parity/latest.json
gitnexus/shadow-parity-dashboard/` + open in a browser.
## Tests (14, all passing)
- **Flag detection** (5): default off · truthy variants case-insensitive ·
falsy / typo → off · record() is no-op when disabled · env flip
AFTER construction doesn't enable (constructed-once semantics)
- **Record + snapshot** (4): multi-language accumulation ·
per-language rows with correct outcomes · snapshot determinism ·
`clear()` resets accumulator + `primaryByLanguage`
- **Persistence** (5): mkdir-p on missing outputDir · per-run +
latest.json match byte-for-byte · schema v1 payload shape ·
runId timestamp prefix sorts chronologically · empty report
persists gracefully
Tests use a per-test tmpdir (`fs.mkdtemp`), cleaned in `afterEach`,
so parallel vitest runs don't collide. `GITNEXUS_SHADOW_MODE` is
saved + restored per-test.
## What's deliberately NOT in this PR (call-out in harness docstring)
- **Dual-run dispatch.** The harness is a side-car — it does NOT
invoke either resolution path. Call-processor integration that
actually runs both legacy + registry paths lands as a follow-up.
Without that integration, `record()` is never called in production
today. The harness is tested in isolation with synthetic inputs.
- **CI artifact publishing.** Config work to upload
`latest.json` + the dashboard HTML per CI run. Tracked separately;
the harness + dashboard are ready when the CI job wires in.
- **Fixture-level drill-down.** The issue mentions per-fixture AST
snippet + evidence trace drill-down. MVP dashboard shows per-language
rows only; drill-down extends the static JSON format + the dashboard
JS in a focused follow-up.
## Verification
- `tsc --noEmit` clean (both `gitnexus-shared` and `gitnexus`)
- 14/14 new tests pass
- Full scope-resolution / shadow / model / flag suite: **335/335 pass**
## Part of
- Parent: #909
- Depends on (code): #917 (registries), #918 (diff + aggregate)
- Unblocks Ring 3 language flips: the parity dashboard becomes the
checkpoint before flipping `REGISTRY_PRIMARY_<LANG>=true` for a
language — once per-language parity stabilizes, the flip ships.
* chore: prettier format on shadow-parity-dashboard index.html
Bridges the CLI's existing per-language `ImportResolverFn`s (16 languages
already implemented) to the shared `FinalizeHooks.resolveImportTarget`
contract consumed by `finalize()` (#915) and
`finalizeScopeModel` (#921).
No resolver logic is reimplemented — the adapter wraps
`provider.importResolver` from each `LanguageProvider` verbatim.
## Shipped
### `import-target-adapter.ts` (new)
```ts
buildImportTargetWorkspace(providers, resolveCtx): ImportTargetWorkspace
resolveImportTargetAcrossLanguages(targetRaw, fromFile, workspaceIndex): string | null
```
- `ImportTargetWorkspace` is the opaque `workspaceIndex` shape the
adapter recognizes: `{ perLanguage: Map<SupportedLanguages,
{ resolver, ctx }> }`. Callers build it once per ingestion run from
the active language providers.
- `resolveImportTargetAcrossLanguages` is the `FinalizeHook`
implementation. It:
1. Reads `getLanguageFromFilename(fromFile)`.
2. Looks up the per-language entry.
3. Calls the existing `ImportResolverFn` — same signature, same
code path the legacy DAG uses today.
4. Picks `result.files[0]` (covers both `'files'` and `'package'`
result kinds; the legacy pipeline's richer multi-file + dirSuffix
semantics stay accessible through `importResolver` directly).
5. Returns `null` on any null result, empty files[], unknown
extension, missing workspace, or resolver exception.
- Exceptions from resolvers are swallowed — the finalize algorithm
treats `null` as `linkStatus: 'unresolved'`, which is the right
fallback for malformed inputs.
### What's deliberately NOT here
- **Re-implementation of any per-language resolver.** Wraps the
existing `importResolver` field on each provider.
- **Dynamic-import handling.** The shared finalize algorithm short-
circuits `ParsedImport { kind: 'dynamic-unresolved' }` before
calling `resolveImportTarget`, so the adapter never sees them.
- **`importPathPreprocessor`.** Preprocessing belongs inside the
provider's `interpretImport` hook that produces
`ParsedImport.targetRaw`; the adapter forwards that verbatim.
## Tests (12, all passing)
- **`buildImportTargetWorkspace`** (3): registers providers with
importResolver · skips providers without · threads shared ctx
into every entry
- **`resolveImportTargetAcrossLanguages`** (9): forwards targetRaw +
fromFile · dispatches by extension · null resolver result →
null · `package`-kind takes first file · empty files[] → null ·
no registered resolver → null · unknown extension → null ·
undefined/malformed workspace → null · resolver throw → null
Real per-language resolver correctness is covered by the existing
per-language resolver test suites — the adapter is the bridge layer.
## Verification
- `tsc --noEmit` clean (both `gitnexus-shared` and `gitnexus`)
- `gitnexus-shared` build clean
- 12/12 new tests pass
- Full scope-resolution / shadow / model / flag suite: **333/333 pass**
## Integration flow
```ts
const workspace = buildImportTargetWorkspace(providers, resolveCtx);
const indexes = finalizeScopeModel(parsedFiles, {
hooks: { resolveImportTarget: resolveImportTargetAcrossLanguages },
workspaceIndex: workspace,
});
model.attachScopeIndexes(indexes);
```
## Closes part of #909. Unblocks
- Ring 3 language migrations (#926+): a language flipping to
`REGISTRY_PRIMARY_<LANG>=true` now has correct import-target
resolution out of the box via its existing `importResolver`.
- #923 shadow harness — can run the dual-path comparison knowing
both sides use the same per-language resolution semantics.
Ties the Ring 2 pipeline together. Takes the `ParsedFile[]` produced by
#920's parse-worker integration, feeds them to shared `finalize()`
(#915), and bundles every workspace-wide index for attachment onto
`MutableSemanticModel`. Thin integration glue per issue #884's boundary
— all algorithm lives in `gitnexus-shared`.
## Shipped
### `model/scope-resolution-indexes.ts` (new)
```ts
interface ScopeResolutionIndexes {
readonly scopeTree: ScopeTree;
readonly defs: DefIndex;
readonly qualifiedNames: QualifiedNameIndex;
readonly moduleScopes: ModuleScopeIndex;
readonly methodDispatch: MethodDispatchIndex;
readonly imports: ReadonlyMap<ScopeId, readonly ImportEdge[]>;
readonly bindings: ReadonlyMap<ScopeId, ReadonlyMap<string, readonly BindingRef[]>>;
readonly referenceSites: readonly ReferenceSite[];
readonly sccs: readonly FinalizedScc[];
readonly stats: FinalizeStats;
}
```
The bundle produced by the orchestrator, consumed by the resolution
phase. `ReferenceIndex` is deliberately NOT here — it's populated in
the next phase (#925).
### `model/semantic-model.ts` — extended
- `SemanticModel.scopes?: ScopeResolutionIndexes` — undefined until
attached; once attached, frozen.
- `MutableSemanticModel.attachScopeIndexes(indexes)` — one-shot write.
Throws on second call; `Object.freeze`s the bundle on write. `clear()`
resets the slot back to `undefined` so re-ingestion can re-attach.
### `finalize-orchestrator.ts` (new)
```ts
finalizeScopeModel(parsedFiles, options?): ScopeResolutionIndexes
```
Orchestration steps:
1. Map `ParsedFile[]` → `FinalizeInput` (`FinalizeFile` is a structural
subset, so no shape-shifting).
2. Call shared `finalize()` with provider hooks (defaults provided for
the zero-provider case today).
3. Build the four workspace indexes (`DefIndex`, `QualifiedNameIndex`,
`ModuleScopeIndex`, `ScopeTree`) from per-file unions.
4. Build an empty `MethodDispatchIndex` as a placeholder (owners=[],
both callbacks return []). Real MRO wiring lands with the
per-language adapters in #922.
5. Bundle + return.
**Empty-input safety.** Zero parsedFiles → valid but empty bundle with
all zero-sized indexes and `stats.totalFiles === 0`. Downstream code
can consult `model.scopes` without branching on presence — only on
`stats`.
**Hook defaults** (`withDefaultHooks`) for missing provider hooks:
- `resolveImportTarget: () => null` — every import goes `unresolved`
- `expandsWildcardTo: () => []` — wildcards don't materialize
- `mergeBindings: (a, b) => [...a, ...b]` — append without precedence
Providers override these in #922 (per-language import adapters).
## Tests (10, all passing)
- **Empty input** (1): zero parsedFiles → valid empty bundle
- **Single file** (2): all per-file indexes populated · referenceSites
aggregated
- **Cross-file imports** (3): resolveImportTarget threads through +
links · default-null resolver → unresolved · stats reflect graph
- **MutableSemanticModel integration** (4): undefined initially · attach
once · Object.freeze applied · throws on re-attach · clear() resets
## Verification
- `tsc --noEmit` clean in both packages
- `gitnexus-shared` build clean
- 10/10 new tests pass
- Full scope-resolution / shadow / model / flag suite: **321/321 pass**
## What's deferred (not this PR, per RFC #909 scope)
- **Per-language hook adapters** (#922): `resolveImportTarget` +
`expandsWildcardTo` + `mergeBindings` wired per language.
- **MethodDispatchIndex wiring via HeritageMap**: populate MRO + implements
via the existing CLI-package HeritageMap strategies. Likely companion
to #922 or a focused follow-up.
- **Pipeline invocation**: actually calling `finalizeScopeModel` from
the real ingestion pipeline. The orchestrator is callable today; the
ingestion entry point wiring lands with the shadow harness (#923).
- **`ReferenceIndex` population**: RFC §3.2 Phase 4 / #925.
## Closes part of #909. Unblocks
- #923 shadow harness — now has a fully materialized `model.scopes` to
query against the legacy DAG for parity measurement
- #925 ReferenceIndex → LadybugDB emission — consumes `model.scopes`
- Ring 3 language migrations (#926+) — a language flipping to
`REGISTRY_PRIMARY_<LANG>=true` can now expect `model.scopes` to be
populated when the pipeline wires the orchestrator in
Plumbs the ScopeExtractor (#919) into the real parsing pipeline.
`ParsedFile` artifacts now flow from workers to the parsing-processor
without changing any legacy-DAG behavior.
## Shipped
### `gitnexus/src/core/ingestion/scope-extractor-bridge.ts` (new)
- `extractParsedFile(provider, sourceText, filePath, onWarn?)`
- Short-circuits (returns `undefined`) when the provider has not
implemented `emitScopeCaptures`. True for every language today —
this is the default no-op path.
- Invokes the hook + `ScopeExtractor.extract`, returns a `ParsedFile`.
- **Swallows exceptions on both sides.** Failures route through the
optional `onWarn` callback (or `console.warn`) and return
`undefined`. Scope-extraction errors NEVER break legacy parsing on
the same file.
- Standalone module (not nested in `parse-worker.ts`) so tests can
import it directly without triggering the worker's top-level
`parentPort!.on(...)`.
### `gitnexus/src/core/ingestion/workers/parse-worker.ts`
- `ParseWorkerResult.parsedFiles: ParsedFile[]` added.
- `processFileGroup` calls `extractParsedFile` AFTER tree parse,
BEFORE legacy extraction. Worker provides an `onWarn` callback that
routes bridge warnings through `parentPort.postMessage({ type:
'warning', message })`.
- `mergeResult` includes `parsedFiles` in the sub-batch merge.
- Initial + reset accumulator templates include `parsedFiles: []`.
### `gitnexus/src/core/ingestion/parsing-processor.ts`
- `WorkerExtractedData.parsedFiles: ParsedFile[]` added.
- Empty-result branch and the across-chunk aggregation both include
`parsedFiles`. Aggregation is tolerant of workers that don't emit
the field (older builds / partial rollouts).
### Ring 1 tweak: `emitScopeCaptures` sync return
`readonly CaptureMatch[]` (was `Promise<readonly CaptureMatch[]>`).
Tree-sitter and COBOL's regex tagger are both synchronous; no
foreseeable need for async work inside this hook. Sync lets the
already-sync worker pipeline invoke it inline without cascading
`async` up through the batch driver + IPC handler.
## Tests (9 new; full suite 311/311)
`gitnexus/test/unit/scope-resolution/parse-worker-scope-integration.test.ts`:
- Not-migrated (2): undefined-returning hook · never-invokes-extractor
- Migrated (3): happy path · argument threading · honors
`shouldCreateScope` override
- Error resilience (4): hook throws · extractor throws (no Module) ·
extractor throws (sibling overlap) · `onWarn` gets routed
message with filePath + error body
## Verification
- `tsc --noEmit` clean in both packages
- `gitnexus-shared` build clean
- 311/311 combined scope-resolution / shadow / model / flag suite
- 9/9 new bridge tests
## What's NOT in this PR (still deferred to #921)
- Actually using the `parsedFiles` — that's the finalize orchestrator.
- `ModuleScopeIndex.byFilePath` materialization — belongs alongside
the rest of the SemanticModel indexes in #921.
## Closes part of #909. Unblocks
- #921 finalize-orchestrator — consumes `WorkerExtractedData.parsedFiles`
Adds the per-language feature flag primitive that gates the Ring 3
registry-primary rollout. Single source of truth for whether a given
language uses `Registry.lookup` (new) or the legacy DAG (current).
## Shipped
### `gitnexus/src/core/ingestion/registry-primary-flag.ts`
- `isRegistryPrimary(lang): boolean` — reads
`REGISTRY_PRIMARY_<UPPER(enum-value)>` from `process.env`.
- `envVarNameFor(lang): string` — exposed for CI tooling that
cross-references flag flips (and for test assertions).
- `primaryLanguages(): ReadonlySet<SupportedLanguages>` — all
currently-on languages; useful for startup logging + the #923
shadow dashboard which distinguishes "primary: legacy" vs
"primary: registry" rows.
### Contract
- Default: `false` for every language. A language must explicitly
opt in by setting its env var.
- Truthy: `'true'`, `'1'`, `'yes'` (case-insensitive, whitespace-
trimmed). Anything else — typos, empty string, `'off'` — is
`false`. Fail-safe posture: a misspelled flag doesn't accidentally
flip a language.
- No per-process caching. `process.env` is read per call; overhead
is negligible (one lookup per file at resolution time), and
test isolation is lexical (no cache-reset coordination).
### Env-var mapping
Uses the enum VALUE, not the TS key, for the env-var suffix:
- `SupportedLanguages.Python` → `REGISTRY_PRIMARY_PYTHON`
- `SupportedLanguages.CPlusPlus` → `REGISTRY_PRIMARY_CPP` (value `'cpp'`)
- `SupportedLanguages.CSharp` → `REGISTRY_PRIMARY_CSHARP`
Users flip languages by their canonical name, not the TS symbol.
## Tests (16, all passing)
- `envVarNameFor` (3): upper-casing · enum-VALUE-not-KEY mapping ·
all-languages uniqueness smoke-test
- `isRegistryPrimary` (9): default false · `'true'` / `'1'` / `'yes'`
truthy · mixed-case + whitespace-padded · falsy-looking values ·
unrecognized tokens (typo-safe) · per-language isolation · no
stale cache on mid-process mutation · CPlusPlus mapping
- `primaryLanguages` (3): empty · exact membership · Set instanceof
Tests scrub every `REGISTRY_PRIMARY_*` env var in `beforeEach` +
`afterEach` so parallel vitest runs on the same process don't bleed state.
## What's NOT in this PR (deferred by design)
The actual integration in `call-processor.ts` belongs in #921
(finalize-orchestrator). Reason: the "new path" requires a populated
`SemanticModel` to call `Registry.lookup` against, and the model
becomes accessible only after #921 orchestrates finalize. Wiring a
dead branch now would just get rewritten then.
This PR ships the flag primitive in isolation so #921 has a clean,
tested utility to consult — and so `#923` (shadow harness) has a
stable boolean to read for its "which row is primary?" rendering.
## Closes part of #909. Unblocks
- #921 finalize-orchestrator — can now consult `isRegistryPrimary`
at resolution time
- #923 shadow harness — can distinguish primary-flipped rows
* feat(ingestion): ScopeExtractor driver — 5-pass CaptureMatch → ParsedFile (#919, RFC #909 Ring 2 PKG)
Kicks off Ring 2 PKG. Implements RFC §5.3 + §3.2 Phase 1: the central,
source-agnostic driver that turns a language provider's `CaptureMatch[]`
into a `ParsedFile` — the per-file artifact the finalize orchestrator
(#921) feeds into the shared `finalize()` algorithm (#915).
## Files
### New shared contracts
- `gitnexus-shared/src/scope-resolution/parsed-file.ts`
Per-file extraction artifact: scopes, parsedImports, localDefs,
referenceSites. Structural superset of `FinalizeFile` so the
finalize orchestrator threads `ParsedFile` through unchanged.
- `gitnexus-shared/src/scope-resolution/reference-site.ts`
Pre-resolution usage fact: name, atRange, inScope, kind, optional
callForm/explicitReceiver/arity. Converted to `Reference` records
by the resolution phase (populates `ReferenceIndex`).
### Ring 1 collateral tweak
- `language-provider.ts: emitScopeCaptures` now returns
`Promise<readonly CaptureMatch[]>` (was `readonly Capture[]`).
Pre-grouping per tree-sitter match is the provider's job — the
extractor expects coherent matches, not flat captures. No
consumers yet (all languages still on legacy DAG), so no breakage.
Docstring updated.
### New CLI module
- `gitnexus/src/core/ingestion/scope-extractor.ts`
Single entry point: `extract(matches, filePath, provider): ParsedFile`.
Five-pass pipeline:
Pass 1 — Build scope tree. `@scope.*` → `ScopeDraft[]` via
range-containment parent derivation. Honors
`provider.shouldCreateScope` (skip-but-reparent-children) and
`provider.resolveScopeKind`. Throws `ScopeTreeInvariantError`
via `buildScopeTree` on malformed input.
Pass 2 — Attach declarations + local bindings. `@declaration.*`
→ `SymbolDefinition` + `BindingRef { origin: 'local' }`.
Default attachment: innermost containing scope. Hoisting via
`provider.bindingScopeFor`.
Pass 3 — Collect raw imports. `@import.*` → `ParsedImport` via
`provider.interpretImport`. Attached to ParsedFile
(finalize resolves owning scope in Phase 2).
Pass 4 — Collect type bindings. `@type-binding.*` →
`TypeRef` via `provider.interpretTypeBinding` →
`scope.typeBindings`. Hoistable via `bindingScopeFor`.
Pass 5 — Collect reference sites. `@reference.*` →
`ReferenceSite[]`. Call form from declarative sub-tag
(`@reference.call.member`) or `provider.classifyCallForm`.
### Tests
- `gitnexus/test/unit/scope-resolution/scope-extractor.test.ts`
23 tests organized by pass + one end-to-end fixture exercising
all 5 passes together. MockProvider emits synthetic
`CaptureMatch[]` with no AST — extractor is pure given those.
## Design notes
- **Source-agnostic.** No `Tree` / `SyntaxNode` types leak into the
driver. Works for tree-sitter providers and COBOL's regex tagger.
- **One AST walk per language.** Providers do the walk inside
`emitScopeCaptures`; this driver does zero traversal.
- **Invariants delegated.** `ScopeTree.buildScopeTree` enforces
structural rules (non-Module has parent, parent contains child,
siblings don't overlap). The extractor doesn't try to repair
malformed captures.
- **Sub-tag whitelist.** `@reference.receiver`, `@declaration.name`,
`@import.source`, etc. are known sub-tags — excluded from anchor
selection so the broadest-range heuristic doesn't mis-identify them
as anchors for their topic. Bug surfaced in the end-to-end fixture
test (member call with a large-range receiver) and was fixed before
commit.
## Verification
- `tsc --noEmit` clean (both `gitnexus-shared` and `gitnexus`)
- `gitnexus-shared` build clean
- 23/23 new tests pass
- Full scope-resolution / model / shadow suite: **285/285 pass**
## Closes part of #909. Unblocks
- #920 parse-worker integration (emit ParsedFile from the worker)
- #921 finalize orchestrator (consume ParsedFile[] workspace-wide)
- #922 per-language import adapters
* chore(ingestion): address #919 review findings on the extractor
Addresses all 5 items from the PR #965 review in-PR.
## Structural changes
- **Extract `ScopeExtractorHooks` as the narrow dependency surface.**
The extractor now declares its dependency on a `Pick`-narrowed subset
of `LanguageProvider` (just the 6 scope-resolution hooks it actually
reads). Test mocks implement exactly that interface — no more
`as unknown as LanguageProvider` cast hiding missing-field bugs.
Adding a new hook read becomes a compile error, not a silent test
pass. (Finding 3.2)
- **Remove dead `ownerDefIdFor` stub + `isOwnerKind` helper.** The
function always returned `undefined` with `void innermost; void
drafts;` suppressors — an incomplete-implementation signal. The code
path was also misleading: creating a clone of the def with
`ownerId: undefined` is structurally identical to keeping the
original. Pass 2 now keeps the def as-is. Contract is documented in
a code comment: providers that need `ownerId` set it from their
declaration hook; `finalize` (via #914 `MethodDispatchIndex`) fills
in method/field `ownerId` in a post-extraction pass that has full
def visibility. (Finding 2.1)
- **Standardize `filePath` threading across passes 4 and 5.** Pass 4
was reading `drafts[0]!.filePath`; pass 5 was reading
`anyFilePathFromScopeTree(scopeTree)`. Both equivalent but
inconsistent. Both now take `filePath` as a parameter from the
top-level `extract()` call. The `anyFilePathFromScopeTree` helper is
removed. (Finding 2.2)
## Documentation
- **Snapshot-semantics comment on `scopeTree` + `positionIndex`.** The
hooks called during Passes 2-5 receive a `scopeTree` built BEFORE any
bindings/ownedDefs/typeBindings were written. Hooks MUST NOT rely on
`scope.bindings` etc. being populated — they're for parent/range/kind
queries only. Added a doc block at the `scopeTree`/`positionIndex`
construction site so future Ring 3 implementers don't write a
`classifyCallForm` that reads bindings. (Finding 2.3)
## Tests
- **Regression for the anchor-vs-receiver bug** (Finding 3.1): a
member-call match where `@reference.receiver` spans columns 0-10
(wider) and the call name spans 11-15 (narrower). Without the
`KNOWN_SUB_TAGS` exclusion, the broadest-range heuristic would have
picked the receiver; the test pins that the call name is the one
that ends up in `referenceSites[0].name`.
- **Mock provider now types exactly `ScopeExtractorHooks`**, no more
double-cast. Any future hook added to `extract()` that isn't in
`ScopeExtractorHooks` is a compile error.
## Verification
- `tsc --noEmit` clean in both `gitnexus-shared` and `gitnexus`
- `gitnexus-shared` build clean
- 24/24 scope-extractor tests pass (+1 regression)
- Full scope-resolution / model / shadow suite: **286/286 pass**
* chore(shared): apply Ring 2 SHARED review follow-ups in one diff
Aggregates all actionable follow-ups from the 9 Ring 2 SHARED PRs
(#949–#963) before proceeding to Ring 2 PKG. No behavior changes;
docstring edits, test refinements, and one structural cleanup.
## #913 (DefIndex / ModuleScopeIndex / QualifiedNameIndex)
- Rename `freezeIndex` → `wrapIndex` across all three index builders.
The old name implied `Object.freeze` on the wrapper, which we never
applied; `wrapIndex` more accurately describes the lightweight
readonly-interface wrap. Safety surface (frozen bucket arrays,
frozen miss-empty array, readonly Maps) is unchanged.
- Document in `buildModuleScopeIndex` JSDoc that callers must
pre-normalize `filePath` keys (no path-separator canonicalization
happens here). Prevents silent cross-platform misses.
- Add an explicit hit-path freeze assertion in
`qualified-name-index.test.ts` (the existing test covered only the
miss-path `EMPTY` array).
## #914 (MethodDispatchIndex)
- Differentiate the C3 and BFS test cases: both tests now use
distinct MRO orderings so they prove the materializer stores
whatever order the `computeMro` callback produces (not that C3 and
BFS yield identical output).
- Add `implementsOfCalls` counter in the first-write-wins test, and
document the call-count contract in `MethodDispatchInput.implementsOf`
JSDoc: `implementsOf` fires **per occurrence** in `input.owners`
(not per unique owner); `computeMro` fires at most once per unique
owner. Callers with expensive `implementsOf` implementations should
pre-dedupe `owners`.
## #916 (resolveTypeRef)
- Document the deliberate exclusion of `'Type'` from `TYPE_KINDS`
(verified no extractor in `gitnexus/src/core/ingestion/` emits
`type: 'Type'` for annotation-relevant symbols).
- Rename the namespace-origin test from `'resolves ...'` to
`'returns null for a namespace-origin binding whose def is not a
type kind'`, matching the failure-case intent.
## #918 (shadow diff + aggregate)
- Remove the partial re-export `export type { ShadowAgreement, ShadowDiff };`
from `aggregate.ts` — it omitted `ShadowCallsite` and diverged
from the top-level barrel. Consumers import all three from the
`gitnexus-shared` entry point.
- Fix the invalid `'wildcard'` evidence kind in `diff.test.ts` fixture
(that kind is not a valid `ResolutionEvidence.kind`). Replaced with
`'global-name'`, a real kind the test treats identically.
## #912 (ScopeTree / PositionIndex / makeScopeId)
- Document the touching-boundary semantics on `PositionIndex.atPosition`:
when siblings share a boundary point, the right (later-start) sibling
wins per the existing innermost-wins sort contract.
- Resolve the layer-inversion flagged by review: move `ScopeLookup`
from `resolve-type-ref.ts` to `types.ts` (its natural home in the
data-model layer). `scope-tree.ts` now imports `ScopeLookup` from
`types.js` directly; the old re-export from `resolve-type-ref.ts`
is removed per repo convention (`feedback_no_reexport`). Barrel
export moved alongside.
## #917 (ClassRegistry / MethodRegistry / FieldRegistry)
- Replace the dangling "try a name-match among class-like defs"
comment in `lookupReceiverType` with explicit prose that callers
must pre-resolve via `resolveTypeRef` if they want richer semantics.
No behavior change — the function already returned `undefined` on
ambiguous/missing qnames.
- Fix `tieBreakKey.origin` default for pure Step-2 candidates.
Type-binding-only hits no longer falsely inherit `'local'` from
`ensureCandidate`'s neutral default; they now demote to `'import'`
on their first type-binding hit, and only a later Step-1 lexical
hit can upgrade them back to `'local'`. Keeps the Appendix B
cascade faithful to the true origin.
- Document `'global-name'` in `evidence.ts`: currently reserved for
Ring 3's byName global index; `lookupCore` never emits it today.
The weight stays live so `composeEvidence` remains exhaustive over
the origin union.
- Rename the mislabeled Step-7 test from `'confidence DESC is the
primary key'` (which actually tested hard-shadow baseline) to
`'inner scope shadows outer, yielding single result'`, and add a
separate test that actually exercises multi-candidate confidence
ordering (local vs wildcard at the same scope).
## Verification
- `tsc --noEmit` clean (both `gitnexus-shared` and `gitnexus`)
- `gitnexus-shared` build clean
- Combined scope-resolution / model / shadow suite: **260/260 pass**
(+1 from the new multi-candidate ordering test in #917)
## Not addressed (non-actionable)
- #949 CI "failure with zero failing tests": pre-existing Swift Node 22
grammar flake unrelated to #910 scope.
- #950: the two non-blocking findings were already addressed in
follow-up commit `cbac32ba` (ParsedImport discriminated union +
`ScopeId | null` on the two hooks).
- #915: the five in-scope findings were already addressed in
follow-up commit `54515a7e` (dead code, unused params, multi-hop
docs, cap-hit test, stats granularity).
- #915 LanguageProvider.resolveImportTarget signature divergence +
`findDefById` O(F×D) perf: tracked separately as follow-up issues
for the Ring 3 migration window.
* chore(shared): address ce:review findings on the follow-up diff
ce:review (interactive) on PR #964 surfaced two P2s and several P3s. This
commit applies all `safe_auto` fixes + both manual tests in-line so the
PR ships with a cleaner review trail.
## P2 fixes
- **Complete `freezeIndex` → `wrapIndex` rename.** The prior commit renamed
3 of 5 sibling index files; `method-dispatch-index.ts` and
`position-index.ts` still carried the old name. Now all 5 helpers use
the consistent `wrapIndex` naming.
(maintainability + project-standards reviewers both flagged this.)
- **Add regression tests for the `recordTypeBindingHit` origin demotion.**
The prior commit introduced the `tieBreakKey.origin = 'import'`
demotion for Step-2-only candidates without a direct test. Added:
- `registries.test.ts`: two Step-2-only siblings under the same
interface, asserting deterministic DefId.localeCompare tie-break
AND the stronger invariant that composeEvidence never emits a
where-found signal for Step-2-only candidates (no `signals.origin`).
- `position-index.test.ts`: touching-boundary test proving the
right-sibling-wins rule documented in the new JSDoc.
(testing + kieran-typescript + api-contract reviewers all flagged these gaps.)
## P3 fixes
- Fix wrong comment in `recordTypeBindingHit` that claimed Step 1 could
later upgrade a demoted origin. Step 1 runs BEFORE Step 2 — the actual
upgrade path is Step 3 (`seedFromOwnerScopedContributor`). Comment now
describes execution order correctly.
- Fix inaccurate "re-exported there" comment in `index.ts`. `types.ts`
*defines* ScopeLookup natively; it's not a re-export. Phrasing now
says "defined in types.ts and exported from the type-export block
above — not from this module."
- Update stale `scope-tree.ts` file-header prose that still referenced
`ScopeLookup` as living in #916/resolve-type-ref.ts. Now points to
`./types.js` with a cross-ref to both #916 and #917 consumers.
- Expand `atPosition` touching-boundary JSDoc to name the mechanism
(backward scan through start-sorted array) so readers can trace the
binary-search code to the claim.
- Add breadcrumb to `aggregate.ts` module header pointing future readers
to `./diff.ts` / the top-level barrel for `ShadowAgreement`,
`ShadowCallsite`, and `ShadowDiff`.
- Remove unnecessary non-null assertion in `recordTypeBindingHit`. Local
`const existingMroDepth = ...` lets TS narrow to `number` in the
else-branch, eliminating the `!` without behavior change.
## Verification
- `tsc --noEmit` clean (both `gitnexus-shared` and `gitnexus`)
- `gitnexus-shared` build clean
- Combined scope-resolution / model / shadow suite: **262/262 pass** (+2
from the new origin-demotion + touching-boundary regression tests)
Capstone of Ring 2 SHARED. Implements RFC §4 — the shared, scope-aware
resolution surface the rest of the semantic model feeds into.
## Modules (`gitnexus-shared/src/scope-resolution/registries/`)
- `context.ts` — `RegistryContext` bundling ScopeTree / DefIndex
/ QualifiedNameIndex / ModuleScopeIndex /
MethodDispatchIndex + provider hooks.
Narrows Ring 1's opaque `RegistryContributor`
to concrete `OwnerScopedContributor`.
- `tie-breaks.ts` — `compareByConfidenceWithTiebreaks`, the RFC
Appendix B cascade: confidence DESC → scope
depth ASC → MRO depth ASC → ORIGIN_PRIORITY
ASC → DefId.localeCompare.
- `evidence.ts` — `composeEvidence(signals)` / `confidenceFromEvidence`.
Translates raw walk signals into the typed
`ResolutionEvidence[]` using authoritative
`EvidenceWeights`. No magic numbers.
- `lookup-qualified.ts`— RFC §4.5. Qualified-name fast path consumed
by `resolveTypeRef` dotted fallback and by
Step 6 of lookup-core.
- `lookup-core.ts` — The 7-step canonical algorithm. Pure. Param-
eterized by `CoreLookupParams`.
- `{class,method,field}-registry.ts`
— Thin wrappers over `lookupCore` that fix
`acceptedKinds` + `useReceiverTypeBinding` per
kind. `buildClassRegistry` / `buildMethodRegistry`
/ `buildFieldRegistry` factory functions.
## RFC §4.2 algorithm contract (honored verbatim)
1. Lexical scope-chain walk. Hard shadow on any `scope.bindings.has(name)`
regardless of kind survivorship.
2. Type-binding resolution (methods/fields only, opt-in via
`useReceiverTypeBinding`). MRO walk via `MethodDispatchIndex.mroFor`.
MRO-depth-decayed weight via `typeBindingWeightAtDepth`.
3. Owner-scoped contributor — when the caller knows the receiver owner,
its direct members merge in as `origin: 'local'`.
4. Kind filter — `acceptedKinds` per registry; `kind-match` evidence
at weight 0 is always emitted for debuggability.
5. Arity filter — `provider.arityCompatibility` per candidate. When at
least one compatible candidate exists, incompatibles are dropped;
otherwise the −0.15 penalty alone disambiguates (they stay in the
result, just ranked lower).
6. Global fallback — fires only when Steps 1-3 produced NO candidates
AND the name is dotted. Delegates to `lookupQualified`.
7. Rank + tie-break — evidence list sorted by the Appendix B cascade.
## §4.7 invariants asserted in tests
- No tier vocabulary in the return type (`Resolution`, not `TierXResult`).
- Confidence is per-candidate (not per-tier).
- Shadowing is a HARD filter; globals are consulted ONLY when lexically
empty.
- Caller can read `[0]` for one-shot answers.
- `Resolution.confidence` is capped at 1.0.
- `kind-match` is always emitted (weight 0).
## Unresolved-import + dynamic-unresolved evidence shape
- `BindingRef.via.linkStatus === 'unresolved'` applies the
`unlinkedImportMultiplier` (0.5×) to the where-found signal only.
Corroborators (`arity-match`, `owner-match`, `type-binding`) remain
unaffected — the RFC §4v2 capped-signal rule applies per-signal, not
per-candidate.
- `BindingRef.via.kind === 'dynamic-unresolved'` adds a degraded
`dynamic-import-unresolved` evidence signal at weight 0.02.
## Tests (28 in registries.test.ts, 259/259 combined)
Organized per RFC §4.2 step so a regression localizes to the step it broke:
- Step 1: local + walk-to-parent + hard-shadow + origin=import
- Step 2: explicit receiver type-binding + MRO depth decay on ancestor
- Step 3: owner-scoped contributor + owner-match
- Step 5: drop-incompatible-when-compatible-exists + soft-penalty-when-all-
incompatible + unknown-when-no-provider
- Step 6: global-qualified fires only when lexically empty + never for
non-dotted names + not consulted when lexical hit exists
- Step 7: tie-break cascade (inner shadows outer; defId.localeCompare
final)
- Corroborators: unresolved-import 0.5× cap per-signal + dynamic-
unresolved 0.02 degraded signal
- §4.5: lookupQualified kind filter + empty on miss + deterministic defId
order for partial classes
- §4.7: invariants — confidence per-candidate, capped at 1.0, kind-match
always present, [0]-for-one-shot
## Known follow-up optimizations
`collectOwnedMembers` in `lookup-core.ts` iterates `defs.byId.values()`
for each MRO hop — O(D) per call. Acceptable for Ring 2 fixtures; a
by-owner index should land before Ring 3 migrates large-workspace
languages. Tracked alongside the existing `findDefById` follow-up from
#915 review.
## Module placement
All under `gitnexus-shared/src/scope-resolution/registries/` — consistent
with the Ring 2 SHARED folder layout (#912/#913/#914/#915/#916/#918).
Slight deviation from the issue's `gitnexus-shared/src/registries/`
suggestion for consistency with siblings.
## Part of
- Parent: #909
- Depends on (code): #910, #911, #912, #913, #914, #915, #916, #918.
- Closes the Ring 2 SHARED delivery band. Unblocks Ring 2 PKG (#919–#925
bridges to the gitnexus/ CLI package) and Ring 3 language migrations.
* feat(shared): SCC-aware finalize algorithm with bounded fixpoint (#915, RFC #909 Ring 2 SHARED)
Implements RFC §3.2 Phase 2 as pure logic in `gitnexus-shared`. Takes
per-file parse output and returns linked `ImportEdge[]` + materialized
module-scope bindings, fully language-agnostic (target resolution,
wildcard expansion, and binding precedence all go through caller hooks).
Three-phase algorithm:
1. Tarjan SCC over the file-level import graph (iterative, deterministic
node order, O(V+E)). Returns SCCs in reverse-topological order so
leaves finalize before dependents — and so disjoint SCCs are
explicitly surfaced for parallel-processing callers.
2. Per-SCC bounded fixpoint. For each SCC in topo order, iterate up to
`N = |intra-SCC edges|`; each pass tries to resolve every still-
unlinked edge by looking up the imported name in the target file's
local defs. Stops early when no progress. Edges still unlinked after
the cap get `linkStatus: 'unresolved'` — keeps malformed inputs
bounded and preserves the RFC §4v2 capped-signal contract for
unresolved markers.
3. Wildcard expansion + module-scope binding materialization. For each
`wildcard` ParsedImport that linked to a module, expand via
`expandsWildcardTo` into one `wildcard-expanded` ImportEdge per
exported name. Bindings per module scope are the merge of local defs
(`origin: 'local'`), named / alias / reexport imports
(`origin: 'import' | 'reexport'`), namespace imports (`origin:
'namespace'`), and wildcard expansions (`origin: 'wildcard'`), with
precedence delegated to `provider.mergeBindings`.
Dynamic imports rule: `kind: 'dynamic-unresolved'` passes through as an
ImportEdge with `targetFile: null` and no BindingRef.
Re-export flattening: reexport edges land with `transitiveVia: [targetFile]`.
Multi-hop chains settle iteratively across the fixpoint.
Types:
- Adds `'wildcard'` variant to ParsedImport (parse-time signal for
`import * from M`). The finalize-only `'wildcard-expanded'` ImportEdge
kind is unchanged and remains finalize output only, as documented.
- Exports `finalize` + `FinalizeFile` / `FinalizeInput` / `FinalizeHooks`
/ `FinalizeOutput` / `FinalizedScc` / `FinalizeStats`.
Simple-name derivation: `deriveSimpleName` uses `def.qualifiedName` as the
authoritative source (tail after the last `.`). Defs without a
qualifiedName are not name-resolvable by this algorithm — an explicit
design choice that trades strictness for predictability (no heuristic
nodeId parsing).
Tests (20, all passing):
- Trivial: empty workspace · acyclic resolution · unresolvable target
(file + name) · dynamic-unresolved passthrough.
- Cycles: A↔B two-file cycle linked · cycles packed into SCC with
isCycle=true · disjoint cycles produce disjoint SCCs · mixed
linked/unresolved edges reported correctly in stats.
- Wildcards: one ImportEdge per exported name · unresolved wildcards
survive as single edges · expanded bindings carry origin='wildcard'.
- Reexports: transitiveVia carries the intermediate file path.
- Aliased + namespace: alias preserves targetExportedName under its
local name · namespace links to module scope even without a module-def.
- Bindings: locals land as origin='local' · imports layer on via
mergeBindings · mergeBindings can drop existing (last-write-wins
precedence honored).
- SCC-DAG: reverse-topological ordering verified (leaf first).
Combined scope-resolution / model / shadow suite: 229/229 pass.
`tsc --noEmit` clean in both `gitnexus-shared` and `gitnexus`.
Closes part of #909. Unblocks #917 (Registry.lookup's import-chain fast
path consumes finalized ImportEdges); unblocks Ring 3 language migrations
(per-language providers supply FinalizeHooks implementations).
* chore(shared): address #915 review findings — dead code, docs, tests
Review thread on PR #962.
Code changes:
- Remove dead `resolvedTargets` map + `keyFor` + `ParsedImportKey`
type alias. The map was populated but never read; originally intended
to cache / dedup resolutions for later phases but that path was never
wired (finding 1.1).
- Drop unused params (`_edgeIndex`, `_hooks`, `_workspace`) from
`tryFinalize`. No planned fixpoint-state consultation; no reason to
keep them reserved (finding 2.1).
Documentation:
- `FinalizeFile.localDefs` now documents the multi-hop re-export
contract explicitly: `finalize` looks names up in the target's
static `localDefs`; if B only re-exports from C and doesn't surface
the name in its own localDefs, A's import of that name from B will
hit the cap and be marked unresolved. Parsers that want multi-hop
chains to settle end-to-end must include re-exported names in the
intermediate file's localDefs (finding 1.2).
- `FinalizeStats` now documents its counting granularity: all edge
counters are per-`ParsedImport`, not per-materialized-`ImportEdge`.
A wildcard expanding to N exports counts as one linked edge;
dynamic-unresolved pass-throughs count as linked. The bindings map
is the authoritative "has a BindingRef" source (finding 3.2).
Tests (2 added, 22 total in finalize-algorithm.test.ts, 231/231 combined):
- Explicit cap-hit → `linkStatus: 'unresolved'` assertion for a cycle
where the name-level lookup never succeeds (distinct from
`targetFile: null`; cap exhaustion path) (finding 3.1).
- Multi-hop re-export contract test: demonstrates both variants —
intermediate B WITHOUT X in localDefs → unresolved; B WITH X in
localDefs → resolved to the original source DefId (finding 1.2).
Not addressed (filed as follow-up issues):
- LanguageProvider.resolveImportTarget vs FinalizeHooks signature
divergence (finding 1.3) — pre-Ring-3 concern.
- findDefById O(F×D) scan in Phase 5 (finding 4.1) — acceptable for
Ring 2; optimize before large-workspace Ring 3 migrations.
Implements the scope-tree spine and position-indexed lookup as pure logic
in `gitnexus-shared`. Generalizes the `enclosingFunctions` pattern from
closed PR #902 to arbitrary `ScopeKind`s.
Three modules under `gitnexus-shared/src/scope-resolution/`:
1. `scope-id.ts` — `makeScopeId({filePath, range, kind})` builds the
canonical RFC §2.2 shape
`scope:{filePath}#{startLine}:{startCol}-{endLine}:{endCol}:{kind}`
and interns the result through a process-local pool so repeated calls
with structurally identical inputs return the same string reference.
`clearScopeIdInternPool()` exported for test isolation.
2. `scope-tree.ts` — `buildScopeTree(scopes)` validates invariants and
returns an immutable `ScopeTree`:
- `getScope(id)` / `getParent(id)` / `getChildren(id)` / `getAncestors(id)`
- Implements the `ScopeLookup` contract from #916, so `resolveTypeRef`
can consume a `ScopeTree` directly (test included).
Invariants enforced (throw `ScopeTreeInvariantError` on violation):
- Non-Module scopes must have a parent.
- Parent must exist in the supplied set.
- Parent range STRICTLY contains child range (equal ranges rejected).
- Sibling ranges under the same parent do not overlap. Ranges that
merely touch at the boundary (`a.end == b.start`) are accepted.
- Parent and child live in the same filePath.
- Duplicate scope ids are rejected.
3. `position-index.ts` — `buildPositionIndex(scopes)` produces a
`PositionIndex` with `atPosition(filePath, line, col)`. Per-file sorted
array; binary-search the upper bound of `start ≤ query`, scan backward
through the prefix, return the first containing hit.
Complexity: `O(log N_file + D)` typical (D = lexical depth ≤ ~10);
degrades to `O(N_file)` only under pathological inputs (many scopes
starting at the same position). "Innermost wins" falls out of the sort
+ backward-scan contract because `ScopeTree`'s invariants guarantee
that scopes containing a point form an ancestor chain.
Types:
- `ScopeTree` now exported from `scope-tree.ts`. The Ring 1 opaque
placeholder in `types.ts` has been removed; LanguageProvider hooks
that previously took `ScopeTree = unknown` now receive the concrete
interface (CLI `tsc --noEmit` passes — no existing callers rely on
the opaque shape).
Tests (39, all passing):
- scope-id: canonical shape · all six ScopeKinds encoded · identity
equality (same inputs → same reference) · distinguished by
filePath / range / kind · purity under repeated calls · intern-pool
clear preserves canonical shape.
- scope-tree: empty tree · single module · nested Module→Class→Function
· multiple siblings input-order preserved · ScopeLookup integration
with resolveTypeRef · frozen children and ancestor arrays · all six
invariant violations (non-Module orphan, parent-not-found, parent
doesn't contain, parent == child, siblings overlap, cross-file parent,
duplicate id) · boundary-touching siblings accepted.
- position-index: empty · unindexed filePath · before/after-file
queries · start/end inclusivity · innermost-wins for nested / co-
starting / co-ending / same-line scopes · sibling dispatch · multi-
file isolation · size · id-dedup.
Combined scope-resolution / model / shadow suite: 190/190 pass.
`tsc --noEmit` clean in both `gitnexus-shared` and `gitnexus`.
Closes part of #909. Unblocks #917 (`Registry.lookup` needs the scope
spine); makes `ScopeLookup` in #916 concrete without API churn.
* feat(search): per-phase timing instrumentation for the query pipeline
The eval harness already measures search-pipeline latency per phase,
but the *product* query() tool has no timing visibility. That leaves
production latency opaque:
- Is BM25 the tail, or vector search?
- How much Promise.all overlap do concurrent searches actually save?
- Does symbol_lookup dominate when per-symbol Cypher round-trips pile up?
None of this is answerable from the outside, which blocks the
latency-quality Pareto work tracked in #546 / #553.
Changes:
* New PhaseTimer class at src/core/search/phase-timer.ts.
Supports three APIs:
- start(phase) / stop() for sequential phases (per issue spec)
- mark(phase, durationMs) for pre-measured durations
- time(phase, promise) to wrap a promise inside Promise.all
The issue's original spec was sequential-only, which doesn't work
for BM25 + vector inside Promise.all — the second start() would
auto-stop the first and only one phase would get timed. The mark()
and time() variants resolve that without changing the sequential
API for the other phases.
* local-backend.ts query() instrumented across seven phase markers:
bm25, vector (concurrent via timer.time inside Promise.all)
merge (RRF reciprocal-rank-fusion)
symbol_lookup (per-symbol process + cohesion + content Cypher)
ranking (in-memory priority sort)
formatting (response object construction + dedup)
wall (end-to-end; separate mark so callers can compare
sum(phases) vs wall and see Promise.all savings)
* logQueryTiming() helper next to logQueryError(), same console-based
pattern (repo has no structured logger). Emits
GitNexus [query:timing] query="..." totalMs=N phases={...}
to stdout — greppable prefix, JSON-parseable payload, no new deps.
* timing: Record<string, number> added as a top-level field on the
query() response. Strict superset of the previous shape — existing
tests only assert field presence, so no regression. Other MCP tools
use the same top-level-metadata convention (status, row_count,
warning) rather than a nested _meta wrapper.
Tests:
- 6 new unit tests for PhaseTimer covering start/stop, implicit
stop-on-start, additive mark(), Promise.all-safe time(),
negative/NaN rejection, and totalMs auto-stop.
- 3 new assertions on the existing query integration test verifying
timing.wall is a non-negative number and at least one of
bm25/vector fired.
Verification:
npx vitest run test/unit/phase-timer.test.ts -> 6 pass
npx vitest run test/unit/calltool-dispatch.test.ts -> 65 pass
npx vitest run test/integration/local-backend-calltool.test.ts -> 18 pass
npm run test:unit -> 3777 pass
(4 pre-existing env failures unchanged: skip-git-cli needs
built dist/, git-utils tmpdir on Windows worktree)
npx tsc --noEmit -> clean
Scope declined for v1:
- In-process histogram aggregation — the log line is enough for
external tooling
- Pareto curve generation — issue asks to enable it, not generate it
- Sub-phases of symbol_lookup (process vs cohesion vs content) —
issue lists them under one bucket; can split later if demand surfaces
Closes#553
* fix(search): route query:timing log to stderr to preserve stdio MCP contract
CI (#953) failed the `query: JSON appears on stdout, not stderr`
e2e test in test/integration/cli-e2e.test.ts with:
SyntaxError: Unexpected token 'G', "GitNexus [..." is not valid JSON
Root cause: my initial logQueryTiming() in 63fbdc4 used console.log,
which writes to stdout. The MCP stdio transport uses stdout
exclusively for JSON-RPC responses (#324), and the CLI e2e test
guards that contract by asserting stdout parses as JSON on every
tool invocation. The "GitNexus [query:timing] ..." line was
interleaving with the response JSON and breaking the parse.
Fix: route logQueryTiming through console.error instead. stderr is
the correct channel for human-readable diagnostics and it is what
the sibling logQueryError already uses for the same reason. The log
line format is otherwise unchanged -- still greppable, still
JSON-parseable payload.
Verification (local, with dist built):
npx vitest run test/integration/cli-e2e.test.ts -t "query: JSON"
-> now passes (was failing across ubuntu/windows/macos in CI)
npx tsc --noEmit -> clean
Two unrelated pre-existing failures on non-git
directory handling remain (same on upstream/main).
Closes the CI regression introduced in 63fbdc4.
Implements RFC §3.1 `MethodDispatchIndex`: a two-way materialized view
keyed by `DefId` for O(1) method-dispatch resolution:
- `mroByOwnerDefId` — owner class → full MRO ancestor chain
(excludes self, per-language strategy order)
- `implsByInterfaceDefId` — interface/trait → classes that implement it
**Not an MRO implementation.** `buildMethodDispatchIndex` is a pure
aggregator that calls back into caller-provided `computeMro` and
`implementsOf` functions. The five existing strategies (Python C3, Ruby
kind-aware, Java/Kotlin linear, Rust qualified-syntax, COBOL none) stay
where they are today (`model/resolve.ts`, `languages/ruby.ts`); this index
does not reimplement them.
Why callbacks rather than a shared registry: the strategies depend on the
CLI's `HeritageMap` + `SemanticModel`. Migrating both to `gitnexus-shared`
is out of scope for #914; callbacks let the shared build stay pure.
Module placement: `gitnexus-shared/src/scope-resolution/method-dispatch-index.ts`
for consistency with the other RFC §3.1 indexes (#913 DefIndex /
ModuleScopeIndex / QualifiedNameIndex; #916 resolveTypeRef).
Safety surface mirrors sibling indexes:
- First-write-wins on duplicate owners.
- Repeated (interface, owner) pairs deduplicated.
- Stored arrays are `Object.freeze`d; caller mutation of the source
array does not leak into the index.
- Miss returns a shared frozen empty array.
Tests (19, all passing): empty input, single-inheritance chain, Python
C3 diamond, Java BFS, Ruby kind-aware mixin, Rust qualified-syntax empty,
interface inversion (single, multiple, ordered), dedup within and across
callback calls, frozen miss + bucket arrays, callback-array isolation,
readonly Map iteration.
Closes part of #909.
Implements RFC §4.6: a strict, pure resolver for `TypeRef`s used by
`Registry.lookup` Step 2 (type-binding propagation) and by any caller that
wants the single best type-target for an annotation without paying for the
full evidence pipeline.
Algorithm (strict):
1. Walk the scope chain from `ref.declaredAtScope`:
- Return the first binding for `rawName` whose origin is in
`{'local','import','namespace','reexport'}` AND whose `def.type` is a
type-kind (class-like, interface-like, enum-like, alias-like).
- If bindings exist but none qualify (non-type shadow, wildcard-only
origin), return null immediately — do NOT fall through to the global
qualified-name index.
2. If `rawName` is dotted and the scope walk produced no match, consult
`QualifiedNameIndex.byQualifiedName`. Only accept a UNIQUE type-kind
hit; ambiguous or non-type results return null.
`'wildcard'` is deliberately excluded from strict origins — a
wildcard-expanded name is too loose to anchor type resolution.
Module placement: `gitnexus-shared/src/scope-resolution/resolve-type-ref.ts`
(alongside sibling indexes) rather than the issue's suggested
`gitnexus-shared/src/resolve-type-ref.ts`, for consistency with the rest of
the RFC §2/§3 surface.
A minimal `ScopeLookup` interface is declared inline so #916 ships
standalone; #912's `ScopeTree` will satisfy this contract without change.
Closes part of #909.
Three flat O(1) indexes + pure build functions over per-file artifacts.
Contract-only; no runtime behavior change yet — consumers (#917 Registry
lookups, #915 SCC finalize, #919 ScopeExtractor) wire in later.
Each index follows the same shape:
- build function: flat input list → frozen immutable index
- public interface: readonly Map + get/has/size accessors
- first-write-wins on id/filePath collisions (upstream bug signal)
- pure, side-effect-free, safe to call repeatedly
DefIndex — the global "what is this id?" lookup
gitnexus-shared/src/scope-resolution/def-index.ts
buildDefIndex(defs: readonly SymbolDefinition[]): DefIndex
byId: ReadonlyMap<DefId, SymbolDefinition>
Consumed by Registry.lookup (#917) to materialize DefId[] hits back to
full SymbolDefinition records.
ModuleScopeIndex — `filePath → moduleScopeId` for cross-file hops
gitnexus-shared/src/scope-resolution/module-scope-index.ts
buildModuleScopeIndex(entries): ModuleScopeIndex
byFilePath: ReadonlyMap<string, ScopeId>
Consumed by the SCC finalize link pass (#915) to resolve
ImportEdge.targetFile to a concrete module scope in constant time.
QualifiedNameIndex — cross-kind qualified-name fast path
gitnexus-shared/src/scope-resolution/qualified-name-index.ts
buildQualifiedNameIndex(defs: readonly SymbolDefinition[]): QualifiedNameIndex
byQualifiedName: ReadonlyMap<string, readonly DefId[]>
Returns DefId[] (not a single DefId) because partial classes, method
overloads, and cross-kind collisions can legitimately share a
qualifiedName. Callers filter by acceptedKinds at the lookup site.
Consumed by Registry.lookup qualified fast path + resolveTypeRef
dotted fallback (#916, #917).
Barrel re-exports added to gitnexus-shared/src/index.ts so consumers
import from 'gitnexus-shared' rather than deep paths.
Tests (gitnexus/test/unit/scope-resolution/, 23 total):
def-index.test.ts (6):
empty, single def, multiple distinct, first-write-wins collision,
missing id returns undefined, byId direct iteration
module-scope-index.test.ts (6):
empty, single entry, multiple files, first-write-wins on duplicate
filePath, missing returns undefined, byFilePath direct iteration
qualified-name-index.test.ts (11):
empty, single qnamed def, partial classes accumulate, input-order
preservation, qname separation, skip undefined/empty qname, pair
dedup, cross-kind indexing, frozen-empty-array on miss, direct
iteration
Verification:
- gitnexus-shared + gitnexus build clean (tsc + scripts/build.js)
- test/unit/scope-resolution: 23/23 pass
- model + shadow + scope-resolution combined: 129/129 pass
- No runtime consumer wiring yet — indexes are standalone library
functions that #915, #917, #919 will import when ready
Depends on #910 (SymbolDefinition, DefId, ScopeId types — already on main).
Unblocks #915 (finalize algorithm), #917 (Registry.lookup), #919
(ScopeExtractor materialization).
Replaces the scaffold stubs with working pure-logic implementations plus
unit-test coverage for both functions. Unblocks Ring 2 PKG #923 (shadow
harness) to consume a concrete library instead of throwing scaffolds.
gitnexus-shared/src/scope-resolution/shadow/diff.ts
`diffResolutions(callsite, legacy, newResult): ShadowDiff`
- [0] on each side is the top match
- both empty → 'both-empty', delta []
- legacy empty only → 'only-new', delta = new top evidence
- new empty only → 'only-legacy', delta = legacy top evidence
- same top nodeId → 'both-agree', delta []
- different nodeIds → 'both-disagree',
delta = symmetric difference of evidence kinds
(legacy-only first in input order, then new-only)
Evidence identity is `ResolutionEvidence.kind` — weight/note differences
for the same kind do NOT produce delta entries. Rationale: the aggregator
wants to know which *signals* explain a disagreement, not fluctuations
in calibration values.
gitnexus-shared/src/scope-resolution/shadow/aggregate.ts
`aggregateDiffs(diffs, now?): ShadowParityReport`
- buckets by `SupportedLanguages`
- tallies agreements, evidence-breakdown (divergences only — agree and
empty rows do not contribute)
- parity = bothAgree / (totalCalls - bothEmpty), yields 0 (not NaN)
when the denominator is 0
- perLanguage sorted alphabetically by enum value for stable output
- evidenceBreakdown internally sorted by kind for stable output
- overall = column-wise sum across languages
- `now` parameter makes generatedAt deterministic in tests
gitnexus-shared/src/index.ts
Re-exports the full shadow API: diffResolutions, aggregateDiffs, and all
their types (ShadowAgreement, ShadowCallsite, ShadowDiff,
LanguageParityRow, ShadowParityReport).
gitnexus/test/unit/shadow/diff.test.ts (13 tests)
- 5 agreement outcomes
- symmetric-by-kind evidence delta (disjoint, overlapping, fully-overlapping)
- weight-only differences produce no delta
- top-match only (ignores indices beyond [0])
- callsite passthrough
- delta ordering (legacy-only first, input order preserved)
gitnexus/test/unit/shadow/aggregate.test.ts (9 tests)
- empty input
- single language, all agree / mixed / all empty
- multi-language bucketing + overall sum
- alphabetical language sort
- evidence breakdown scope
- determinism via injected `now` + JSON round-trip identity
Verification:
- gitnexus-shared + gitnexus build clean (tsc + scripts/build.js)
- test/unit/shadow: 22/22 pass
- test/unit/model + test/unit/shadow combined: 106/106 pass
- No runtime behavior changes (shadow is invoked by #923, not yet wired)
Stacked on main (af1d278a). Depends on types from #910 (merged).
Unblocks: #923 (Ring 2 PKG — shadow harness wiring) — concrete library
to consume instead of scaffold stubs.
Plan: docs/plans/2026-04-18-001-refactor-911-senior-hooks-redesign-plan.md
is about #911; #918's scope is the scaffold+fill-in described in the PR
description of #951.
Adds the 14 optional scope-resolution hooks from RFC #909 §5.2 to
`LanguageProviderConfig` plus the supporting input/output types in
`gitnexus-shared`. Contract-only; no runtime behavior changes.
Review-driven refinements (addresses two non-blocking review comments on #950):
1. `ParsedImport` is now a 5-variant discriminated union, not a flat
record. Each variant carries only its legal fields so invalid shapes
are compile errors:
- 'named', 'alias', 'namespace', 'reexport', 'dynamic-unresolved'
'wildcard-expanded' is deliberately excluded — finalize materializes
that kind; a provider must never emit it at parse time.
'reexport' is a first-class parse-phase variant so syntactically-
detectable re-exports (TS `export { X } from './y'`, Rust
`pub use foo::bar`) keep their parse-time signal through to finalize
rather than being re-derived by the SCC pass.
`namespace` gains an `importedName` field so `import numpy as np`
can carry both `localName: 'np'` and `importedName: 'numpy'`.
`dynamic-unresolved.targetRaw` is `string | null` (was mandatory
null) so providers can emit the unresolvable expression text for
diagnostics when available.
2. `bindingScopeFor` and `importOwningScope` return type changed from
`ScopeId` to `ScopeId | null`, aligning with the X | null convention
used by the 12 sibling optional hooks (receiverBinding,
resolveScopeKind, interpretTypeBinding, …). `null` = delegate to the
central default. Enables partial overrides — a JS provider can
return a hoisted scope for `var` and `null` for `let`/`const`
without re-implementing the default lookup.
Both hooks also gain a purity JSDoc contract: same inputs yield the
same ScopeId (or null) across invocations; no closure over mutable
state. Required to keep scope-tree construction deterministic.
A richer callable-defaults pattern (typed BindingScopeDefaults /
ImportOwningDefaults helper interfaces on a `defaults` parameter)
was considered and deferred to Ring 2 PKG #919, where the concrete
ScopeExtractor will exist to inform the helper shape. Designing that
pattern before the first consumer would set cross-hook precedent
based on a single motivating example.
Supporting types added to gitnexus-shared/src/scope-resolution/types.ts:
- CaptureMatch, ParsedImport, ParsedTypeBinding
- WorkspaceIndex, ScopeTree (opaque placeholders until Ring 2)
- Callsite
14 hooks added to LanguageProviderConfig (all optional):
Parse phase: emitScopeCaptures, interpretImport, receiverBinding,
interpretTypeBinding, resolveScopeKind, shouldCreateScope,
bindingScopeFor
Finalize phase: resolveImportTarget, expandsWildcardTo,
importOwningScope, mergeBindings
Reference-extraction phase: classifyCallForm
Resolution phase: shouldShadow, arityCompatibility
Verification:
- gitnexus-shared builds clean (tsc)
- gitnexus builds clean (scripts/build.js)
- test/unit/model: 84/84 pass — no regressions
- No provider needs updating (all hooks optional)
- No BindingScopeDefaults/ImportOwningDefaults/defaults parameter
introduced (deferred to #919)
Stacked on #910 (merged as afc0a8b6); rebased on main.
Tracking: #909 (meta). Unblocks Ring 2 PKG (#919 ScopeExtractor,
#922 import adapters) and all Ring 3 per-language migrations.
Plan: docs/plans/2026-04-18-001-refactor-911-senior-hooks-redesign-plan.md
Lands the authoritative data model and constants for the pure scope-based
resolution RFC (#909) as Ring 1, part 1. No runtime behavior changes —
types + constants only.
New in gitnexus-shared/src/scope-resolution/:
- types.ts — Scope, ScopeKind, ScopeId, DefId, Range, Capture,
BindingRef, ImportEdge, TypeRef, Resolution, ResolutionEvidence,
Reference, ReferenceIndex, LookupParams, RegistryContributor
- evidence-weights.ts — EvidenceWeights constant map + typeBindingWeightAtDepth
(RFC Appendix A)
- origin-priority.ts — ORIGIN_PRIORITY constant map for deterministic
tie-breaks (RFC Appendix B)
- language-classification.ts — LanguageClassification type +
LanguageClassifications map (production × 14, experimental × 2
for vue/cobol; governs Ring 4 DAG-retirement gate)
- symbol-definition.ts — SymbolDefinition moved from
gitnexus/src/core/ingestion/model/symbol-table.ts so scope-resolution
types can reference it from the shared package
Consumer updates:
- symbol-table.ts: removes local SymbolDefinition declaration; imports
from gitnexus-shared
- model/index.ts: drops SymbolDefinition from barrel re-export per
"direct imports from gitnexus-shared" convention (see
gitnexus-shared feedback in project memory)
- 9 source files + 5 test files: import SymbolDefinition directly
from 'gitnexus-shared'
Verification:
- gitnexus-shared builds clean (tsc)
- gitnexus builds clean (scripts/build.js)
- 131/132 unit test files pass; 3767 tests green
- Zero behavior changes; SymbolDefinition shape unchanged
Blocks: #911 (LanguageProvider hook interface extensions) and all of
Ring 2 (#912-#925). Closes part of #909.
Deterministic fix for the Windows-flaky pipeline-graph-golden test.
Root cause
cli-e2e.test.ts wrote into the SHARED fixture directory
(test/fixtures/mini-repo/) — git init, analyze run that creates
AGENTS.md, CLAUDE.md, .claude/, .gitnexus/. When pipeline-graph-golden
ran in parallel, its `cpSync` of the source directory could capture
the mid-flight pollution before cli-e2e's afterAll cleanup fired.
macOS/Ubuntu won the race often enough that the flake presented as
Windows-only.
Fix
cli-e2e now copies mini-repo into a fresh `mkdtemp`'d parent whose
basename is `mini-repo` (preserving `--repo mini-repo` CLI lookup by
basename), runs git-init there, and rm's the whole tmpdir in afterAll.
The shared fixture source is never touched.
Fallout from the cwd change: bare `--import tsx` specifiers (2
spawnSync + 1 spawn) can't resolve `tsx` from an os.tmpdir cwd where
there is no node_modules. Switched them to the already-existing
`tsxImportUrl` (absolute file:// URL to the tsx loader), matching
the `runCliOutsideProject` pattern that was already set up for this
exact case.
Updated the "MINI_REPO is inside the project tree" comment in the
`status on non-indexed repo` test — MINI_REPO is now in os.tmpdir,
so the rationale for using a separate throwaway tmp git repo is
different (but still valid: previous tests in the suite create
MINI_REPO/.gitnexus, which findRepo() would pick up).
Also updated pipeline-graph-golden's comment explaining WHY it
copies to tmp — it's now defense-in-depth rather than a necessity,
so a future test that adds files to the source can't silently
regress the golden.
Verification
- 5x consecutive `cli-e2e + pipeline-graph-golden` runs: 20/20 pass
(deterministic)
- 3x full suite including pipeline.test: 27/27 pass
- test/fixtures/mini-repo/ post-run contents: only `src/` —
zero pollution from any test
- macOS/Ubuntu behavior unchanged (they were passing; tmpdir
isolation is purely additive)
* feat(mcp): rank context/impact disambiguation candidates and expose kind/file_path hints
The `context` MCP tool already returned `{ status: 'ambiguous', candidates }`
when a name hit multiple symbols, but the candidates were returned in
arbitrary DB order and the only hint it accepted was file_path. The
`impact` tool was worse: when its name resolver found multiple viable
matches it silently picked the first one from a priority UNION, with no
signal back to the caller that a different symbol might have been
intended.
Both failure modes were flagged in issue #470 and reconfirmed in the
comments by a second user who described impact as returning "incorrect
parsing results and meaningless tool calls" in the multi-match case.
Changes:
* Add `resolveSymbolCandidates(repo, query, hints)` private helper on
LocalBackend. Single place that:
- Short-circuits on direct uid (zero-ambiguity)
- Runs the same name-or-qualified-id match as before, with LIMIT 20
(was 10) so the ranker has headroom instead of arbitrary truncation
- Preserves the #480 Class/Constructor preference -- when the only
ambiguity is a Class and its own Constructor, the Class wins
silently
- Scores each candidate (pure TS, no extra DB round-trip): base 0.50,
+0.40 for file_path match, +0.20 for kind match, plus a small
kind-priority tiebreaker (Class > Interface > Function > Method >
Constructor) when no explicit kind hint is given
- Sorts desc by score with stable tiebreakers (shorter filePath,
then lex uid)
- Promotes to a single confident resolve when the top score is
>= 0.95 AND beats the runner-up by >= 0.10 -- lets a strong hint
cut through without forcing the caller through a disambiguation
round-trip
* Rewire `context()` to use the shared helper. Response shape is a
strict superset of today's: candidates gain a `score` field, the
existing `{ uid, name, kind, filePath, line }` keys are preserved so
every downstream consumer (rename, eval-server formatter, etc.) keeps
working. New `kind` input hint accepted.
* Rewire `impact()` to use the shared helper. Now emits the same
`{ status: 'ambiguous', candidates, impactedCount: 0, risk: 'UNKNOWN' }`
shape instead of silent first-pick. New inputs accepted:
`target_uid`, `file_path`, `kind`.
* Update tool schemas in mcp/tools.ts to advertise the new inputs and
describe ranked disambiguation.
Backward compatibility:
The #480 Class/Constructor collapse is preserved and covered by the
existing java-class-impact integration test (still green). The
ambiguous response shape is a strict superset -- `eval-formatters`
unit test that parses the old shape is unchanged and still passes.
`impact` going from silent-first-pick to structured ambiguous is a
semantic improvement that is the entire point of the issue; callers
relying on silent first-pick now get an actionable response.
Scope declined for v1:
module/community hint -- the issue lists it as one of several hints,
but kind + file_path cover the vast majority of disambiguation needs
in practice, and a community-label filter requires an extra graph
query per candidate. Natural v2 follow-up.
Tests: calltool-dispatch.test.ts gains 5 new cases covering file_path
boost, kind hint boost, impact ambiguous shape, impact target_uid
short-circuit, and score field presence on the existing ambiguous
test. Plus the extended assertions on the existing
`context tool returns disambiguation for multiple matches`.
Verification:
npx vitest run test/unit/calltool-dispatch.test.ts -> 64 pass
npx vitest run test/integration/java-class-impact.test.ts -> pass
npm run test:unit -> 3642 pass
(4 pre-existing env failures unchanged: skip-git-cli needs built
dist/, git-utils tmpdir on Windows worktree -- same on main)
npx tsc --noEmit -> clean
Closes#470
* fix(mcp): enrich labels from UNION when labels(n)[0] is empty; address review findings
CI on PR #888 caught 13 integration-test failures I did not cover locally:
my resolver refactor collected candidates via `labels(n)[0] AS type`, but
LadybugDB returns an empty string for that projection on certain node
types (most importantly Class). With an empty `type`, impact's downstream
`_runImpactBFS` no longer recognised `symType === 'Class' | 'Interface'`
and stopped seeding Constructor + File nodes into the frontier, so the
"impact(upstream) surfaces the file importer" assertion broke across 11
language fixtures plus 2 OVERRIDES filter tests.
The original impact resolver worked around this by running a prioritised
UNION across Class/Interface/Function/Method/Constructor and picking the
first hit. My refactor dropped that. Fix: keep the simple candidate MATCH
but enrich types afterward via a single scoped UNION query, so every
candidate carries an accurate label for both scoring and downstream
BFS seeding. The UID direct-lookup path is patched the same way.
Also addresses the findings from the senior reviewer on PR #888:
* MIGRATION.md: document the `impact` behavioural change (silent first-
pick → structured `{ status: 'ambiguous', candidates }`) so downstream
callers know to branch on `result.status` before reading byDepth/
summary. `context` is unchanged shape-wise (strict superset).
* New test: `context tool promotes top candidate via scoring when
multiple rows survive DB pre-filter`. The review flagged that the
existing file_path test works only because the mock ignores WHERE
parameters -- the scored-promotion path (top ≥ 0.95 AND gap > 0.09)
wasn't directly exercised. The new test uses two candidates both in
App.tsx-containing paths plus a kind hint so promotion is decided by
scoring, not DB pre-filtering. Also tightened the comment on the
earlier file_path test to describe the mock vs production divergence
honestly.
* NIT: added a paragraph explaining why `scored.length >= 2` is kept as
a defensive guard even though the `normalized.length === 1` early
return already covers the single-candidate path.
* Integration: two tests in `local-backend-calltool.test.ts` targeted
`'authenticate'`, which now correctly resolves as ambiguous (two
Method nodes: AuthService.authenticate and BaseService.authenticate).
Updated both to pass `file_path: 'src/auth.ts'` so they exercise the
new disambiguation API and still assert the METHOD_OVERRIDES filtering
they were originally about.
Edge case fix in the promotion gap check: IEEE754 makes 0.50 + 0.40 +
0.20 - 0.90 = 0.09999999999999998 instead of exactly 0.10, which would
otherwise break the "winner clearly dominates" intent for legitimate
1.00 vs 0.90 cases. Changed `>= 0.10` to `> 0.09`; same user-facing
intent, no floating-point sensitivity.
Verification (all from gitnexus/):
npx vitest run test/integration/class-impact-all-languages.test.ts
-> 52 pass (was 11 FAIL on CI before this fix)
npx vitest run test/integration/local-backend-calltool.test.ts
-> 18 pass (was 2 FAIL on CI before this fix)
npx vitest run test/integration/java-class-impact.test.ts
-> 10 pass (regression guard for #480 preserved)
npx vitest run test/unit/calltool-dispatch.test.ts
-> 65 pass (1 new test + 4 from original #470 PR)
npm run test:unit
-> 3626 pass, 4 pre-existing env failures unchanged
npx tsc --noEmit
-> clean
* fix: keep worker warnings non-terminal
Treat parse-worker warning messages as informational so a warning can be surfaced without short-circuiting the worker result protocol.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* style: apply prettier formatting
---------
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
* feat: add docker support
* feat: move docker files to root
* feat: add docker build and push workflow
* fix: pin docker action SHAs to verified commits
Made-with: Cursor
* fix: remove redundant --platform=$TARGETPLATFORM from runtime stage
Made-with: Cursor
* fix: upgrade docker actions to Node.js 24-compatible versions
Made-with: Cursor
* docs: updated readme
* fix: update docker references
* fix(docker-server): reject null bytes in resolvePath
Defensively harden the path traversal guard by returning null
early when the URL contains a null byte, before normalization runs.
Made-with: Cursor
* fix(docker-server): handle createReadStream errors
Attach an error listener before piping so mid-flight read errors
(truncated file, permission change) cleanly destroy the response
instead of being silently swallowed.
Made-with: Cursor
* fix(docker-server): replace existsSync with async stat
Eliminates the TOCTOU race between the initial stat call and the
subsequent existsSync check. Reuses the async stat pattern already
in place and removes the now-unused existsSync import.
Made-with: Cursor
* test(docker-server): add integration tests; fix %00 null-byte bypass
Decode the URL before the null-byte check so percent-encoded null
bytes (%00) are also rejected with 400 instead of falling through
to the SPA fallback. Adds 5 node:test integration tests covering
valid assets, SPA fallback, path traversal, null bytes, and 404.
Made-with: Cursor
* style: fix prettier formatting in docker-server files
Made-with: Cursor
* fix(docker): wire tests into CI, fix resolvePath separator, correct image namespace
- Add `node --test docker-server.test.mjs` step to ci-tests.yml so the
path-traversal guard tests run in every CI pass instead of being silently skipped.
- Fix resolvePath containment check: `startsWith(root)` would allow sibling
directories like `/app/dist-evil/`; now guards with `root + sep` or exact match.
- Update docker-compose.yaml default image from `abhigyanpatwari` namespace to
`brainifii` to match what docker.yml publishes to GHCR.
* fix(docker): update apt-get commands and set user permissions
- Modify Dockerfile and Dockerfile.test to include options for apt-get to bypass validity checks during updates.
- Set ownership of the /app directory to the 'node' user in the runtime stage for improved security and proper permission handling.
* fix(docker): switch to Alpine base images for smaller footprint
- Update Dockerfile to use Alpine-based Node.js images for both builder and runtime stages, reducing image size and improving performance.
- Replace apt-get commands with apk for package installation in the runtime stage.
* fix(docker): update Node.js version in Dockerfile
- Change base image from node:20-alpine to node:22-alpine
* fix(docker): update Node.js version in Dockerfile to 22-alpine for runtime
---------
Co-authored-by: kritik.b <kritik.b@media.net>