docs: document the new incomplete reason, the UNKNOWN verdict and the id churn

Review found the code changes landed without the guidance around them, and an
agent following this repo's own rules would have been told the wrong thing.

`graph-write-collapsed` joined `INDEX_INCOMPLETE_REASONS` with no Sign block
and no recovery section, while the precedent it cites
(`embedding-checkpoint-pending`) has both — so `gitnexus status` would surface a
new string naming silent wrong answers with nothing explaining trigger or
remedy. Added to RUNBOOK and GUARDRAILS, including why this reason alone also
fails the exit code.

`AGENTS.md` said MUST warn on HIGH or CRITICAL and never mentioned UNKNOWN, and
the shipped impact skill's risk table had no UNKNOWN row and still implied
few-callers ⇒ LOW. An agent obeying those rules literally sees `risk: UNKNOWN`
and proceeds, which negates the change the verdict exists to make. Both copies
of both skills updated.

`MIGRATION.md` now records that process ids do not survive this release —
positional ids plus depth-first tracing, source-order siblings and round-robin
selection mean `proc_7_handle` is a different flow afterwards. Bounded honestly:
nothing in-repo joins on a raw process id, so it is index churn, not a broken
consumer.

`ARCHITECTURE.md`'s scope-resolution stage list gains the two new stages.
The guide skill's node list gains `Property` and `TypeAlias` — the two node
types this work most prominently creates.

Also, on the pair-CSV preflight review asked to confirm: the hard abort IS
deliberate, because a fallback recovering zero rows is the confident-empty
failure this work targets. But the transient the message itself names — a second
concurrent analyze sharing `.gitnexus/csv` — is a race, so the check now
re-looks three times over ~150ms before declaring the file gone. Long enough to
ride out a rename, far too short to mask a file that is genuinely missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ReidenXerx 2026-08-06 21:58:43 +03:00
parent 118d1ef7e8
commit 411cac9b90
16 changed files with 125 additions and 46 deletions

View file

@ -139,7 +139,7 @@ Lightweight reads (~100-500 tokens) for navigation:
## Graph Schema
**Nodes:** File, Folder, Function, Class, Interface, Method, CodeElement, Community, Process, Route, Tool, plus language-specific types (Struct, Enum, Trait, Impl, Namespace, Module, …) and BasicBlock (`--pdg` indexes only). The full node list lives in `gitnexus://repo/{name}/schema`.
**Nodes:** File, Folder, Function, Class, Interface, Method, Property, TypeAlias, CodeElement, Community, Process, Route, Tool, plus language-specific types (Struct, Enum, Trait, Impl, Namespace, Module, …) and BasicBlock (`--pdg` indexes only). The full node list lives in `gitnexus://repo/{name}/schema`.
**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, CONTAINS, MEMBER_OF, HAS_METHOD, HAS_PROPERTY, ACCESSES, METHOD_OVERRIDES, METHOD_IMPLEMENTS, STEP_IN_PROCESS, HANDLES_ROUTE, FETCHES, HANDLES_TOOL, ENTRY_POINT_OF, WRAPS, QUERIES, INJECTS, plus `--pdg`-only types (CFG, REACHING_DEF, TAINTED, SANITIZES, TAINT_PATH, CDG — zero rows on a default index).
Read `gitnexus://repo/{name}/schema` before writing Cypher — it is the authoritative schema for the indexed repo.

View file

@ -53,6 +53,14 @@ description: "Use when the user wants to know what will break if they change som
| 5-15 symbols, 2-5 processes | MEDIUM |
| >15 symbols or many processes | HIGH |
| Critical path (auth, payments) | CRITICAL |
| **Zero callers found** | **UNKNOWN** |
`UNKNOWN` is not a low rung on this scale — it means the walk could not answer.
An empty caller set is equally consistent with "genuinely unused" and "the
callers are not resolvable by the index" (plain-object property access, dynamic
dispatch, cross-language calls), so few-callers ⇒ LOW does **not** apply. The
result carries a `riskNote` saying so. Confirm with a text search before
treating the symbol as safe to change or delete.
## Tools

View file

@ -120,6 +120,7 @@ This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 rela
- **MUST run impact analysis before editing.** Use `impact({target: "symbolName", direction: "upstream"})` (MCP) or `node .gitnexus/run.cjs impact "symbolName" --direction upstream --repo .` (CLI fallback); report callers, processes, and risk. Never substitute grep for graph analysis. For unified PDG impact, add `mode: "pdg"` with optional `line: <N>` — it returns statement-level `affectedStatements` over CDG + REACHING_DEF and inter-procedural symbols in `interproceduralByDepth`/`byDepth`; no-layer/degraded PDG results are UNKNOWN-risk notes (`--pdg` layer). CLI equivalent: `node .gitnexus/run.cjs impact "symbolName" --direction upstream --mode pdg --line <N> --repo .`.
- **MUST analyze graph changes before committing.** Use `detect_changes({scope: "all"})` (MCP) or `node .gitnexus/run.cjs detect-changes --scope all --repo .` (CLI fallback). For regression review: `detect_changes({scope: "compare", base_ref: "main"})` or `node .gitnexus/run.cjs detect-changes --scope compare --base-ref "main" --repo .`.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- **MUST treat `risk: UNKNOWN` as unresolved, not as low.** An empty caller set is not evidence the symbol is unused — it can also mean the callers are not resolvable by the index (plain-object property access, dynamic dispatch, cross-language calls). `impact` pairs `UNKNOWN` with a `riskNote` saying so. Confirm with a text search before treating the symbol as safe to change or delete; do not proceed on the strength of a zero.
- When exploring unfamiliar code, use `query({search_query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
- For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`).
@ -128,7 +129,7 @@ This project is indexed by GitNexus as **GitNexus** (248612 symbols, 565510 rela
## Never Do
- NEVER edit a function, class, or method before MCP/CLI impact analysis.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis, and never read `UNKNOWN` as an all-clear — it means the walk could not answer, which is the one verdict that requires confirming by other means.
- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
- NEVER commit before MCP/CLI graph change analysis.

View file

@ -214,6 +214,9 @@ Language-agnostic scope-resolution resolver. This is the resolution path for eve
│ emitReferencesViaLookup ── uses handledSites + deferred-site skip set
│ emitPropertyDispatchCalls ── registration USES + conservative CALLS
│ emitCallableValueFlow ── assigned/passed callable invocation CALLS
│ emitImportedValueReferences ── cross-file value reads via finalized imports
│ emitUniqueNamePropertyAccesses ── LAST-RESORT property reads by name,
│ narrowed same-file → direct-import, refusing to choose otherwise
│ emitImportEdges
KnowledgeGraph (IMPORTS / CALLS / ACCESSES / INHERITS / USES)

View file

@ -52,6 +52,12 @@ Format: **Trigger → Instruction → Reason**. Append new Signs when the same m
- **Do:** Re-run plain `npx gitnexus analyze` — no `--embeddings` flag needed. A retained `embeddingCheckpoint` in the index metadata forces embedding generation for exactly the pending nodes regardless of flags, and clears once they succeed. `--drop-embeddings` abandons the pending nodes instead of retrying them; `--force` also discards the checkpoint (with a warning) and rebuilds without resuming it.
- **Why:** A long analyze run against a flaky HTTP embedding endpoint tolerates bounded sub-batch failures instead of aborting the whole run: it deletes the affected nodes' embedding rows (so they hold zero rows, never a partial set) and records those nodes as pending in `embeddingCheckpoint`. `stats.embeddings` stays an honest, non-zero count of everything that did succeed, so this state never trips the "Embeddings vanished" Sign above — `embedding-checkpoint-pending` is the only reliable signal.
### Analyze reports INCOMPLETE with a collapsed graph write
- **Trigger:** `npx gitnexus status` reports `incompleteReasons: ["graph-write-collapsed"]`; the analyze summary printed `Repository indexed INCOMPLETELY` naming an expected and a persisted relationship count, and the CLI exited non-zero.
- **Do:** Re-run `npx gitnexus analyze --force`. If it recurs, check free disk space on the volume holding `.gitnexus/`, confirm no second `analyze` is running against the same repo (both stage through `.gitnexus/csv`), then run `npx gitnexus doctor`.
- **Why:** The run finished and wrote metadata, but far fewer relationships are readable back than the pipeline produced. Nothing throws: the DB holds rows and the metadata is valid, so every query answers with missing edges rather than an error — a confident empty answer, which is worse than a failure because it looks like a result. Unlike `incremental-in-progress` and `embedding-checkpoint-pending`, which describe a run that did what it said and left work for next time, this one means most of your edges are gone, so it is the one incomplete reason that also fails the exit code. The check compares in-memory totals (including rows streamed out of the heap) against the post-write count, refuses to answer when the count cannot be read, and is skipped on incremental runs where whole-scope counts are not comparable.
### MCP lists no repos
- **Trigger:** MCP stderr says no indexed repos.

View file

@ -106,6 +106,19 @@ Running `npx gitnexus analyze` writes both `gitnexus.json` and `meta.json`
with identical content. A pre-existing repo that only has `meta.json` gets
`gitnexus.json` bootstrapped from it on the first run.
### Process ids are not stable across this release
`Process` ids are positional (`proc_<idx>_<entry>`), and this release changes
both which execution flows are detected and the order they are selected in:
tracing is depth-first, sibling branches follow source order, and selection
round-robins across terminals so one flow cannot take every slot. A given
`proc_7_handle` before the upgrade is not the same flow afterwards.
Nothing in GitNexus persists or joins on a raw process id across a re-index —
the MCP resource keys by label — so this is one-time index churn rather than a
broken consumer. If you have external tooling that stored a process id, re-
resolve it by label after the next analyze.
### What about rollback?
Downgrading to an older GitNexus version is safe: `meta.json` is always

View file

@ -66,6 +66,16 @@ npx gitnexus analyze
No `--embeddings` flag needed — a retained checkpoint forces embedding generation for the pending nodes regardless of flags, and clears once they succeed. `--drop-embeddings` abandons the pending nodes instead of retrying them; `--force` also discards the checkpoint (with a warning) and rebuilds without resuming it.
**Collapsed graph write (analyze exits NON-ZERO and says INCOMPLETE):** A run can finish writing metadata while only a fraction of the relationships it produced are readable back from the index — edges collapsing to a small share of what was built, or a `CodeRelation` table that never materialized (which reads as a persisted count of zero). Because the metadata IS written and the DB does hold rows, nothing looks broken: queries answer with missing edges rather than an error, which is a confident empty answer rather than a failure. `npx gitnexus status` reports `incompleteReasons: ["graph-write-collapsed"]`, the analyze summary prints `Repository indexed INCOMPLETELY` with the expected and persisted counts, and the CLI exits non-zero so automation is not told an unusable index is fine.
Recovery is a full rebuild:
```bash
npx gitnexus analyze --force
```
If it recurs, the cause is almost always environmental rather than a code defect: check free disk space on the volume holding `.gitnexus/`, make sure no second `analyze` is running against the same repo (both use `.gitnexus/csv` for staging), then run `npx gitnexus doctor`. The check compares in-memory relationship totals (including streamed rows) against what the DB hands back, and is deliberately skipped on incremental runs, where the two counts are not comparable.
**Large repos:** Analyze may skip or limit embedding work when node counts are very high; watch CLI output.
---

View file

@ -139,7 +139,7 @@ Lightweight reads (~100-500 tokens) for navigation:
## Graph Schema
**Nodes:** File, Folder, Function, Class, Interface, Method, CodeElement, Community, Process, Route, Tool, plus language-specific types (Struct, Enum, Trait, Impl, Namespace, Module, …) and BasicBlock (`--pdg` indexes only). The full node list lives in `gitnexus://repo/{name}/schema`.
**Nodes:** File, Folder, Function, Class, Interface, Method, Property, TypeAlias, CodeElement, Community, Process, Route, Tool, plus language-specific types (Struct, Enum, Trait, Impl, Namespace, Module, …) and BasicBlock (`--pdg` indexes only). The full node list lives in `gitnexus://repo/{name}/schema`.
**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, CONTAINS, MEMBER_OF, HAS_METHOD, HAS_PROPERTY, ACCESSES, METHOD_OVERRIDES, METHOD_IMPLEMENTS, STEP_IN_PROCESS, HANDLES_ROUTE, FETCHES, HANDLES_TOOL, ENTRY_POINT_OF, WRAPS, QUERIES, INJECTS, plus `--pdg`-only types (CFG, REACHING_DEF, TAINTED, SANITIZES, TAINT_PATH, CDG — zero rows on a default index).
Read `gitnexus://repo/{name}/schema` before writing Cypher — it is the authoritative schema for the indexed repo.

View file

@ -53,6 +53,14 @@ description: "Use when the user wants to know what will break if they change som
| 5-15 symbols, 2-5 processes | MEDIUM |
| >15 symbols or many processes | HIGH |
| Critical path (auth, payments) | CRITICAL |
| **Zero callers found** | **UNKNOWN** |
`UNKNOWN` is not a low rung on this scale — it means the walk could not answer.
An empty caller set is equally consistent with "genuinely unused" and "the
callers are not resolvable by the index" (plain-object property access, dynamic
dispatch, cross-language calls), so few-callers ⇒ LOW does **not** apply. The
result carries a `riskNote` saying so. Confirm with a text search before
treating the symbol as safe to change or delete.
## Tools

View file

@ -28,6 +28,7 @@ import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexe
import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
import { tryEmitEdge } from '../graph-bridge/edges.js';
import { findValueBindingInScope } from '../scope/walkers.js';
import { callableFlowSiteKey } from './callable-value-flow.js';
/**
* Confidence for a reference resolved through a finalized import binding.
@ -58,7 +59,7 @@ export function emitImportedValueReferences(
// A member read (`obj.field`) is the receiver-bound passes' business;
// this pass exists for the BARE identifier an import binds.
if (site.explicitReceiver !== undefined) continue;
const siteKey = `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`;
const siteKey = callableFlowSiteKey(parsed.filePath, site.atRange);
if (skipSites.has(siteKey)) continue;
const def = findValueBindingInScope(site.inScope, site.name, indexes);

View file

@ -68,6 +68,7 @@ import type { KnowledgeGraph } from '../../../graph/types.js';
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
import { resolveCallerGraphId } from '../graph-bridge/ids.js';
import { callableFlowSiteKey } from './callable-value-flow.js';
/**
* Confidence for a workspace-unique name match. Deliberately the global tier's
@ -277,7 +278,7 @@ export function emitUniqueNamePropertyAccesses(
// is no object whose member this could be, and matching one by name
// would link a local variable to an unrelated object's key.
if (site.explicitReceiver === undefined) continue;
const siteKey = `${parsed.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`;
const siteKey = callableFlowSiteKey(parsed.filePath, site.atRange);
if (skipSites.has(siteKey)) continue;
const candidates = byName.get(site.name);

View file

@ -163,9 +163,7 @@ function preEmitInheritanceEdges(
if (site.kind !== 'inherits') continue;
const scope = scopes.scopeTree.getScope(site.inScope);
const siteKey =
scope?.filePath !== undefined
? `${scope.filePath}:${site.atRange.startLine}:${site.atRange.startCol}`
: undefined;
scope?.filePath !== undefined ? callableFlowSiteKey(scope.filePath, site.atRange) : undefined;
if (siteKey !== undefined) {
// Intentionally suppress every `inherits` site from the generic
// reference bridge, even when this pre-pass can't emit an EXTENDS
@ -441,12 +439,12 @@ interface RunScopeResolutionStats {
* #2437 false-safe gap for exactly those keys (names are in the warn log).
*/
readonly propertyDispatchSkippedKeys: number;
/**
* ACCESSES edges recovered by workspace-unique property name (A1/A5) the
* last-resort pass for receivers no precise pass could type.
*/
/** Cross-file value references resolved through finalized import bindings. */
readonly importedValueRefEdges: number;
/**
* ACCESSES edges recovered by property NAME (A1/A5) the last-resort pass
* for receivers no precise pass could type.
*/
readonly uniqueNamePropertyEdges: number;
/**
* Read/write sites left unresolved because two or more `Property` defs share
@ -1001,7 +999,7 @@ export function runScopeResolution(
const fromFilePath = indexes.scopeTree.getScope(fromScope)?.filePath;
if (fromFilePath === undefined) continue;
for (const ref of refs) {
uniqueNameSkipSites.add(`${fromFilePath}:${ref.atRange.startLine}:${ref.atRange.startCol}`);
uniqueNameSkipSites.add(callableFlowSiteKey(fromFilePath, ref.atRange));
}
}
// Gated on the language's own field-name-fallback policy. A statically-typed

View file

@ -978,14 +978,6 @@ const copyCsvWithRetry = async (
}
};
/**
* Bulk-COPY every node CSV sequentially on the single writable connection
* (LadybugDB allows one write txn at a time). Extracted from loadGraphToLbug so
* it can run either at the node-phase boundary overlapping the relationship
* emit pass (#2203) or after emit in the serial escape-hatch path. Each COPY
* keeps the IGNORE_ERRORS=true retry; a hard failure throws (no node rows the
* relationship COPY would dangle on missing endpoints).
*/
/**
* A staging CSV named in the COPY manifest is gone by the time COPY runs.
*
@ -1005,6 +997,38 @@ export const missingStagingCsvError = (table: string, csvPath: string, rows: num
`\`gitnexus analyze --force\`.`,
);
/**
* Bulk-COPY every node CSV sequentially on the single writable connection
* (LadybugDB allows one write txn at a time). Extracted from loadGraphToLbug so
* it can run either at the node-phase boundary overlapping the relationship
* emit pass (#2203) or after emit in the serial escape-hatch path. Each COPY
* keeps the IGNORE_ERRORS=true retry; a hard failure throws (no node rows the
* relationship COPY would dangle on missing endpoints).
*/
/**
* Re-check a staging CSV a few times before declaring it gone.
*
* Review asked whether turning a silent degrade into a hard abort was
* deliberate. It is a fallback that recovers zero rows is exactly the
* confident-empty failure this work is about, so failing loud is right. But the
* transient the error message itself names, a second concurrent `analyze`
* sharing `.gitnexus/csv`, is a RACE, and aborting a multi-minute rebuild on
* one stat() is a harsh answer to a file that may reappear microseconds later.
*
* Bounded and short: three extra looks over ~150ms total. Long enough to ride
* out a rename or a slow network filesystem, far too short to mask a file that
* is genuinely gone.
*/
const stagingCsvExists = async (csvPath: string): Promise<boolean> => {
const RETRY_DELAYS_MS = [25, 50, 75];
if (existsSync(csvPath)) return true;
for (const delay of RETRY_DELAYS_MS) {
await new Promise((resolve) => setTimeout(resolve, delay));
if (existsSync(csvPath)) return true;
}
return false;
};
const copyNodeCSVs = async (
targetConn: lbug.Connection,
nodeFileEntries: [NodeTableName, { csvPath: string; rows: number }][],
@ -1016,7 +1040,7 @@ const copyNodeCSVs = async (
stepsDone++;
log(`Loading nodes ${stepsDone}/${totalSteps}: ${table} (${rows.toLocaleString()} rows)`);
if (!existsSync(csvPath)) throw missingStagingCsvError(table, csvPath, rows);
if (!(await stagingCsvExists(csvPath))) throw missingStagingCsvError(table, csvPath, rows);
const copyQuery = getCopyQuery(table, normalizeCopyPath(csvPath));
await copyCsvWithRetry(targetConn, copyQuery, (retryErr) => {
@ -1238,7 +1262,7 @@ export const loadGraphToLbug = async (
// Same guarantee as the node COPY: a pair file only reaches this loop
// with rows on it, so an absent file means it vanished mid-run. This is
// the `rel_Folder_File.csv` ENOENT the field reports end on.
if (!existsSync(pairCsvPath)) {
if (!(await stagingCsvExists(pairCsvPath))) {
throw missingStagingCsvError(`${fromLabel} -> ${toLabel}`, pairCsvPath, rows);
}
const normalizedPath = normalizeCopyPath(pairCsvPath);

View file

@ -303,15 +303,6 @@ export interface RepoMeta {
* Map keys are repo-relative paths.
*/
fileHashes?: Record<string, string>;
/**
* Crash-recovery dirty flag a generic marker written to the metadata
* file (gitnexus.json + its meta.json mirror) BEFORE any destructive DB
* mutation by BOTH writeback branches (incremental since its introduction;
* full rebuilds over an existing meta since #2099 F1); cleared on success
* by overwriting the metadata file. If a run crashes between, the next
* run sees the flag and forces a full rebuild the cheapest path back
* to a known-good index.
*/
/**
* Set when a run finished but the persisted edge count came back far short
* of what the pipeline produced the B2 "refresh reports SUCCESS while the
@ -331,6 +322,15 @@ export interface RepoMeta {
/** Relationships readable from the DB after the write. */
persisted: number;
};
/**
* Crash-recovery dirty flag a generic marker written to the metadata
* file (gitnexus.json + its meta.json mirror) BEFORE any destructive DB
* mutation by BOTH writeback branches (incremental since its introduction;
* full rebuilds over an existing meta since #2099 F1); cleared on success
* by overwriting the metadata file. If a run crashes between, the next
* run sees the flag and forces a full rebuild the cheapest path back
* to a known-good index.
*/
incrementalInProgress?: {
/** When the run started (epoch ms). */
startedAt: number;

View file

@ -16,7 +16,7 @@
import { it, expect, beforeAll, vi } from 'vitest';
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
import { listRegisteredRepos } from '../../src/storage/repo-manager.js';
import { withTestLbugDB } from '../helpers/test-indexed-db.js';
import { withTestLbugDB, type IndexedDBHandle } from '../helpers/test-indexed-db.js';
vi.mock('../../src/storage/repo-manager.js', () => ({
listRegisteredRepos: vi.fn().mockResolvedValue([]),
@ -38,12 +38,22 @@ const SEED = [
`CREATE (t2:Function {id: 'Function:src/b.ts:orphanTwin', name: 'orphanTwin', filePath: 'src/b.ts', startLine: 1, endLine: 3, isExported: true, content: '', description: ''})`,
];
type BackendHandle = IndexedDBHandle & { _backend?: LocalBackend };
withTestLbugDB(
'impact-zero-caller-risk',
(handle) => {
let backend: LocalBackend;
beforeAll(() => {
backend = (handle as any)._backend;
// Typed and null-checked, matching `caller-identity-regression.test.ts`
// in this directory. An `as any` read here turns "the harness never
// attached the backend" into an undefined-property crash several lines
// later instead of a message naming the cause.
const ext = handle as BackendHandle;
if (!ext._backend) {
throw new Error('LocalBackend not initialized — afterSetup did not attach _backend');
}
backend = ext._backend;
});
it('reports UNKNOWN, not LOW, when an upstream walk resolves no callers', async () => {

View file

@ -59,20 +59,16 @@ describe('TypeScript type-alias and interface members (A4)', () => {
expect(nodesOfLabel('Property')).toContain('ifaceSlots');
});
// The EDGES are not landed yet — nodes and declarations are.
// Member edges land through the PRECISE path only. The shape is a class-like
// scope (`interface_declaration` and `type_alias_declaration value:
// (object_type)` both emit `@scope.class`) and `property_signature` emits
// `@declaration.property`, so a typed receiver resolves to the shape's scope
// and finds the member there.
//
// Established: the shape is a class-like scope already (`interface_declaration`
// and `type_alias_declaration value:(object_type)` both emit `@scope.class`),
// and `property_signature` now emits `@declaration.property` alongside the
// pre-existing `method_signature` -> `@declaration.method`. So the receiver
// has a scope and the scope has members, yet no ACCESSES forms — the missing
// link is owner/type-binding, i.e. the member def carrying an `ownerId` that
// the typed receiver resolves to via `findOwnedMember`.
//
// There is deliberately NO name-based safety net here: TypeScript sets
// There is deliberately NO name-based safety net: TypeScript sets
// `fieldFallbackOnMethodLookup: false` (scope-resolver.ts) because name
// matching over-connects in a typed language, and the unique-name pass
// honors that opt-out. The precise path is the only route for TS, by design.
// matching over-connects in a typed language, and the unique-name pass honors
// that opt-out. The precise path is the only route for TS, by design.
it('links an interface field to its consumer', () => {
expect(readersOf('ifaceSlots')).toContain('renderIface');
});