mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-29 06:32:28 +00:00
feat(resolution): type receivers from structure, and bump INCREMENTAL_SCHEMA_VERSION to 26
U10. Wires the fold into resolution. Three TypeScript receiver spellings that
emitted no CALLS edge now resolve:
svc?.getUser().save()
svc!.getUser().save()
svc.getTyped<User>().save()
WIRED IN ONE PLACE, not four. The fold runs at the top of
`resolveCompoundReceiverClass`, ahead of the text cascade, so every caller
inherits it — Case 0, the Case 4 fallback, and the duplicate walk alike — rather
than four call sites each having to remember. Only at `depth === 0`: the chain
describes THIS site's receiver, not the inner expressions the cascade recurses
into. A failed fold falls THROUGH rather than returning, so structure is an
additional route to an answer and never a veto on the existing one.
Group A only, which settles U11 by construction. The two sites that consume
`typeRef.rawName` cannot be routed: they have no reference site in scope, so
there is no `receiverChain` to pass. That is not a decision deferred, it is the
shape of the code — Group B needs the type-binding capture layer to carry
structure, which is a separate piece of work. U12 needs no code either: the
`gitnexus-shared` registry `lookupReceiverType` is a name-keyed map miss, not a
text-sniff site, and threading the fold into `RegistryContext` would be a shared
contract change across `LookupParams` and both registry option types.
`extractMixedChain` also needed a transparent-wrapper unwrap. For `svc!` the walk
reached `non_null_expression`, which is not a chain node, so the wrapper's own
TEXT became the base — `"svc!"`, matching no binding, silently costing the chain
its base. `non_null_expression` and `parenthesized_expression` denote exactly
their operand and are now read through. A cast is deliberately NOT unwrapped:
`x as T` changes the type an expression denotes, so reading through one would
type the receiver as the operand instead of the cast target.
`INCREMENTAL_SCHEMA_VERSION` 25→26 lands HERE, at the first commit where edge
semantics change. A changed-files top-up against a pre-v26 index keeps the
pre-fix graph for every unchanged file — and worse than merely incomplete, the
drop summary is a whole-repo recompute while the edges are a changed-files
write, so the two would actively disagree. The reuse-gate ladder test is updated
with that rationale; it is the guard that caught the missing bump.
MEASURED, and the result is the argument for how this was measured. The three
shapes flipped to RESOLVES while the call-drop count did not move at all: 99
before, 99 after. The committed fixture corpus contains none of those three
spellings, so a gate reading only the drop count would have scored this as "no
improvement" and stopped the series. No edge was lost and no new drop appeared.
Two shapes remain unresolved, both genuinely and both recorded rather than
glossed: `(await svc.getUserAsync()).save()` (the walk reaches `await …`, not a
chain node) and `repos[0].save()` (Case 0's punctuation gate never fires for a
subscript receiver).
One test changed meaning rather than breaking. The #2744 drop-recorder test used
`svc!.getUser().save()` as its call-kind example; that shape now resolves, so
there is no drop to record. It moves to the await-parenthesized shape, which
still drops. The write case is untouched — a write receiver mints no chain at
all, since the emitter gates on CALL_TAGS — so calls and writes stay separable,
which was the point of the test.
334 tests green across all six touched suites.
This commit is contained in:
parent
c3544fe52c
commit
303791b698
8 changed files with 257 additions and 17 deletions
|
|
@ -1,5 +1,25 @@
|
|||
# Receiver-resolution baseline
|
||||
|
||||
> **Updated after U10** (structural receiver typing wired into Case 0). Three
|
||||
> TypeScript shapes flipped to `RESOLVES` — `svc?.getUser().save()`,
|
||||
> `svc!.getUser().save()`, `svc.getTyped<User>().save()` — and the call-drop
|
||||
> count did **not** move: 99 before, 99 after.
|
||||
>
|
||||
> That is the whole argument for the shape arm, now demonstrated rather than
|
||||
> predicted. The committed fixture corpus contains none of those three
|
||||
> spellings, so a gate reading only the drop count would have scored a working
|
||||
> change as "no improvement" and stopped the series. Nothing regressed: no edge
|
||||
> was lost and no new drop appeared.
|
||||
>
|
||||
> Still gaps after U10, both genuine:
|
||||
> - `(await svc.getUserAsync()).save()` — `extractMixedChain` reaches
|
||||
> `await …`, which is not a chain node, so no chain is minted. Remains a
|
||||
> VISIBLE-GAP and is now the call-kind fixture in the drop-recorder test.
|
||||
> - `repos[0].save()` — Case 0's punctuation gate never fires for a subscript
|
||||
> receiver, so it stays INVISIBLE.
|
||||
>
|
||||
> The tables below are the pre-U10 measurement, kept as the reference point.
|
||||
|
||||
Measured with `bench/receiver-resolution/measure.mjs` on `f87b2cbe`.
|
||||
|
||||
Hygiene (a run without both steps is void — `analyze --force` clears neither cache,
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@
|
|||
"typescript": {
|
||||
"plainChain": "RESOLVES",
|
||||
"plainDeepChain": "RESOLVES",
|
||||
"optionalChain": "INVISIBLE-GAP",
|
||||
"nonNullAssert": "VISIBLE-GAP",
|
||||
"optionalChain": "RESOLVES",
|
||||
"nonNullAssert": "RESOLVES",
|
||||
"awaitParen": "VISIBLE-GAP",
|
||||
"explicitTypeArgs": "INVISIBLE-GAP",
|
||||
"explicitTypeArgs": "RESOLVES",
|
||||
"indexElement": "INVISIBLE-GAP"
|
||||
},
|
||||
"php": {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexe
|
|||
import type { WorkspaceResolutionIndex } from '../workspace-index.js';
|
||||
import { stripTemplateArguments } from '../../utils/template-arguments.js';
|
||||
import type { DecodedReceiverChain } from '../../utils/receiver-chain-codec.js';
|
||||
import { decodeReceiverChain } from '../../utils/receiver-chain-codec.js';
|
||||
import {
|
||||
findClassBindingInScope,
|
||||
findEnclosingClassDef,
|
||||
|
|
@ -99,6 +100,13 @@ interface ResolveCompoundReceiverOptions {
|
|||
* rather than re-declared, so a future sub-field cannot be added there
|
||||
* and silently ignored here (#2708). */
|
||||
readonly constructionSyntax?: ScopeResolver['constructionSyntax'];
|
||||
/** Compact receiver chain for THIS site (`ReferenceSite.receiverChain`), when
|
||||
* the language's capture emitter produced one. Present ⇒ the structural fold
|
||||
* is tried before the text cascade; absent ⇒ behaviour is exactly as before.
|
||||
* Consumed only at `depth === 0`: it describes the site's own receiver, so
|
||||
* carrying it into a recursive call would re-fold it against an inner
|
||||
* expression it does not describe. */
|
||||
readonly receiverChain?: string;
|
||||
}
|
||||
|
||||
/** Is this hop the language's construction selector applied to the class
|
||||
|
|
@ -283,9 +291,13 @@ export function foldReceiverChain(
|
|||
if (chain.truncated) return undefined;
|
||||
if (chain.steps.length === 0) return undefined;
|
||||
|
||||
// `receiverChain` is dropped before resolving the base: it describes the
|
||||
// whole receiver, and handing it back to the resolver would re-enter this
|
||||
// fold on the base and never terminate.
|
||||
let current = resolveCompoundReceiverClass(chain.baseReceiverName, inScope, scopes, index, {
|
||||
...options,
|
||||
fieldFallback: false,
|
||||
receiverChain: undefined,
|
||||
});
|
||||
if (current === undefined) return undefined;
|
||||
|
||||
|
|
@ -311,6 +323,26 @@ export function resolveCompoundReceiverClass(
|
|||
if (text.length === 0) return undefined;
|
||||
const fieldFallback = options.fieldFallback ?? true;
|
||||
|
||||
// ── Structural fold, ahead of the text cascade ───────────────────
|
||||
// When the capture layer produced a chain for this site, type the receiver
|
||||
// from that structure. The cascade below dispatches on enumerated textual
|
||||
// shapes, so every new spelling (`?.`, `!`, `<T>`, `->`) needs another branch;
|
||||
// the AST already knew the answer and the chain carries it.
|
||||
//
|
||||
// Only at depth 0 — the chain describes THIS site's receiver, not the inner
|
||||
// expressions the cascade recurses into.
|
||||
//
|
||||
// A failed fold falls through rather than returning: structure is an
|
||||
// additional route to an answer, never a veto on the existing one, so a site
|
||||
// the cascade could already resolve keeps resolving.
|
||||
if (depth === 0 && options.receiverChain !== undefined) {
|
||||
const decoded = decodeReceiverChain(options.receiverChain);
|
||||
if (decoded !== undefined) {
|
||||
const folded = foldReceiverChain(decoded, inScope, scopes, index, options);
|
||||
if (folded !== undefined) return folded;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pre-processing: strip C-style cast expressions (opt-in) ──────
|
||||
// Cast-wrapped receivers like ((Type)((Object)this.field)).method()
|
||||
// produce parenthesized-expression receiver text. For languages that
|
||||
|
|
|
|||
|
|
@ -407,7 +407,9 @@ export function emitReceiverBoundCalls(
|
|||
site.inScope,
|
||||
scopes,
|
||||
index,
|
||||
compoundOpts,
|
||||
// Group A: the receiver IS this site's expression, so the site's
|
||||
// captured chain describes it and the structural fold applies.
|
||||
{ ...compoundOpts, receiverChain: site.receiverChain },
|
||||
);
|
||||
compoundReceiverUnresolved = currentClass === undefined;
|
||||
if (currentClass !== undefined) {
|
||||
|
|
@ -1046,7 +1048,8 @@ export function emitReceiverBoundCalls(
|
|||
site.inScope,
|
||||
scopes,
|
||||
index,
|
||||
compoundOpts,
|
||||
// Group A, same reasoning as Case 0 above.
|
||||
{ ...compoundOpts, receiverChain: site.receiverChain },
|
||||
);
|
||||
}
|
||||
if (ownerDef !== undefined) {
|
||||
|
|
|
|||
|
|
@ -454,7 +454,10 @@ export function extractCallChain(
|
|||
current = innerReceiver; // continue walking
|
||||
} else {
|
||||
// Reached a simple identifier — the base receiver
|
||||
return { chain, baseReceiverName: innerReceiver.text || undefined };
|
||||
return {
|
||||
chain,
|
||||
baseReceiverName: unwrapTransparentReceiver(innerReceiver).text || undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -477,6 +480,35 @@ export function extractCallChain(
|
|||
*
|
||||
* Pure field chains and pure call chains are special cases (all steps same kind).
|
||||
*/
|
||||
/**
|
||||
* Node types that wrap an expression without changing what it denotes, so a
|
||||
* receiver chain's BASE can be read through them.
|
||||
*
|
||||
* `svc!` (TS non-null assertion) and `(svc)` denote exactly `svc`; taking the
|
||||
* wrapper's own text instead yields `svc!` / `(svc)`, which matches no binding
|
||||
* and silently costs the chain its base.
|
||||
*
|
||||
* Deliberately EXCLUDES a cast (`x as T`, `(T)x`): a cast changes the type an
|
||||
* expression denotes, so reading through one would type the receiver as the
|
||||
* operand rather than as the cast target.
|
||||
*/
|
||||
const TRANSPARENT_RECEIVER_WRAPPERS = new Set([
|
||||
'non_null_expression', // TypeScript `svc!`
|
||||
'parenthesized_expression', // `(svc)`
|
||||
]);
|
||||
|
||||
/** Peel transparent wrappers off a base receiver node. */
|
||||
function unwrapTransparentReceiver(node: SyntaxNode): SyntaxNode {
|
||||
let current = node;
|
||||
// Bounded: `((x))` nests twice; nothing real nests deeply.
|
||||
for (let i = 0; i < MAX_CHAIN_DEPTH && TRANSPARENT_RECEIVER_WRAPPERS.has(current.type); i++) {
|
||||
const inner = current.namedChildren?.find((c) => c !== null);
|
||||
if (inner === undefined || inner === null) break;
|
||||
current = inner;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export function extractMixedChain(
|
||||
receiverNode: SyntaxNode,
|
||||
): { chain: MixedChainStep[]; baseReceiverName: string | undefined } | undefined {
|
||||
|
|
@ -546,7 +578,10 @@ export function extractMixedChain(
|
|||
) {
|
||||
current = innerReceiver;
|
||||
} else {
|
||||
return { chain, baseReceiverName: innerReceiver.text || undefined };
|
||||
return {
|
||||
chain,
|
||||
baseReceiverName: unwrapTransparentReceiver(innerReceiver).text || undefined,
|
||||
};
|
||||
}
|
||||
} else if (FIELD_ACCESS_NODE_TYPES.has(current.type)) {
|
||||
// ── Field/member access: extract property name + inner object ─────────
|
||||
|
|
@ -595,7 +630,10 @@ export function extractMixedChain(
|
|||
) {
|
||||
current = innerObject;
|
||||
} else {
|
||||
return { chain, baseReceiverName: innerObject.text || undefined };
|
||||
return {
|
||||
chain,
|
||||
baseReceiverName: unwrapTransparentReceiver(innerObject).text || undefined,
|
||||
};
|
||||
}
|
||||
} else if (current.type === 'selector') {
|
||||
// ── Dart: flat selector siblings (user.address.save() uses selector nodes) ──
|
||||
|
|
|
|||
|
|
@ -568,6 +568,20 @@ export interface RepoMeta {
|
|||
* — for every unchanged Rust file, which is exactly the symptom #2730
|
||||
* reported. Force a full re-analyze.
|
||||
*
|
||||
* v27: receiver expressions are typed from captured structure rather than from
|
||||
* their source text. `svc?.getUser().save()`, `svc!.getUser().save()` and
|
||||
* `svc.getTyped<User>().save()` previously emitted NO `CALLS` edge — the text
|
||||
* cascade split the receiver on punctuation it could not parse — and two of the
|
||||
* three recorded no drop either, because a later case marked the site handled,
|
||||
* which suppresses the drop record. So the caller was missing from
|
||||
* `impact(direction: "upstream")` and `context()` AND the count still claimed
|
||||
* `epistemic: 'exact'`. Same v11/v12 contract: the incremental write set covers
|
||||
* only CHANGED files, so a top-up against a pre-v26 index keeps serving the
|
||||
* pre-fix graph — and the pre-fix confident count — for every unchanged file.
|
||||
* Worse than merely incomplete: the drop summary is a whole-repo recompute while
|
||||
* the edges are a changed-files write, so the two would disagree. Force a full
|
||||
* re-analyze.
|
||||
*
|
||||
* v24: inline constructor receivers resolve — `Service(db).do_work()` (Python),
|
||||
* `new Service(db).doWork()` (JS/TS, C#), `Service.new.do_work` (Ruby), plus the
|
||||
* generic, qualified, chain-head and keyword-trivia spellings of the same shape
|
||||
|
|
@ -580,6 +594,7 @@ export interface RepoMeta {
|
|||
* the same-commit "already up to date" fast path — keeps returning the pre-fix
|
||||
* graph for every unchanged file, which is exactly the missing-caller symptom
|
||||
* #2708 reported. Force a full re-analyze.
|
||||
*
|
||||
* v26: unresolved-receiver member names are persisted
|
||||
* (`unresolvedReceiverMembers`) so `impact()`/`context()` can report
|
||||
* `epistemic: 'lower-bound'` instead of a confident `'exact'` when a call site
|
||||
|
|
@ -600,7 +615,7 @@ export interface RepoMeta {
|
|||
* index holds ids an incremental top-up cannot reconcile and would simply
|
||||
* strand. Force a full re-analyze.
|
||||
*/
|
||||
export const INCREMENTAL_SCHEMA_VERSION = 26;
|
||||
export const INCREMENTAL_SCHEMA_VERSION = 27;
|
||||
|
||||
export interface IndexedRepo {
|
||||
repoPath: string;
|
||||
|
|
|
|||
|
|
@ -3337,15 +3337,24 @@ export class Service {
|
|||
getUser(): User {
|
||||
return new User();
|
||||
}
|
||||
async getUserAsync(): Promise<User> {
|
||||
return new User();
|
||||
}
|
||||
}
|
||||
`,
|
||||
'main.ts': `import { Service } from './models';
|
||||
|
||||
export function droppedCall(svc: Service | null): void {
|
||||
svc!.getUser().save();
|
||||
export async function droppedCall(svc: Service): Promise<void> {
|
||||
// An await-parenthesized receiver. Structural typing does NOT cover this
|
||||
// shape — \`extractMixedChain\` reaches \`await …\`, which is not a chain node,
|
||||
// so no chain is minted and the site still reaches the drop recorder. The
|
||||
// \`!\` spelling used to serve here until structural typing resolved it.
|
||||
(await svc.getUserAsync()).save();
|
||||
}
|
||||
|
||||
export function droppedWrite(svc: Service | null): void {
|
||||
// A write receiver mints no chain at all (the emitter gates on CALL_TAGS),
|
||||
// so this keeps dropping and stays separable from the call above.
|
||||
svc!.getUser().name = 'x';
|
||||
}
|
||||
`,
|
||||
|
|
@ -3371,3 +3380,119 @@ export function droppedWrite(svc: Service | null): void {
|
|||
expect(drops).toContainEqual(expect.objectContaining({ name: 'name', siteKind: 'write' }));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structural receiver typing: receiver spellings the text cascade cannot parse
|
||||
// now resolve by folding the captured chain.
|
||||
//
|
||||
// Both shapes below emitted NO edge and recorded NO drop before this work:
|
||||
// Case 0's gate fired, `resolveCompoundReceiverClass` returned undefined, and a
|
||||
// later case marked the site handled — which suppresses the drop record too, so
|
||||
// the loss was invisible to the epistemic signal as well as to the graph.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('TypeScript structural receiver chains', () => {
|
||||
let repoDir: string;
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-ts-receiver-chain-'));
|
||||
writeFixtureRepo(repoDir, {
|
||||
'models.ts': `export class Address {
|
||||
persist(): void {}
|
||||
}
|
||||
|
||||
export class User {
|
||||
address: Address = new Address();
|
||||
save(): void {}
|
||||
}
|
||||
|
||||
export class Service {
|
||||
getUser(): User {
|
||||
return new User();
|
||||
}
|
||||
getTyped<T>(): User {
|
||||
return new User();
|
||||
}
|
||||
}
|
||||
`,
|
||||
'main.ts': `import { Service } from './models';
|
||||
|
||||
export function viaOptionalChain(svc: Service | null): void {
|
||||
svc?.getUser().save();
|
||||
}
|
||||
|
||||
export function viaTypeArgs(svc: Service): void {
|
||||
svc.getTyped<User>().save();
|
||||
}
|
||||
|
||||
export function viaNonNull(svc: Service | null): void {
|
||||
svc!.getUser().save();
|
||||
}
|
||||
|
||||
export function viaMixedChain(svc: Service): void {
|
||||
svc.getUser().address.persist();
|
||||
}
|
||||
|
||||
export function alreadyWorked(svc: Service): void {
|
||||
svc.getUser().save();
|
||||
}
|
||||
`,
|
||||
});
|
||||
result = await runPipelineFromRepo(repoDir, () => {}, {});
|
||||
}, 60000);
|
||||
|
||||
afterAll(() => {
|
||||
if (repoDir !== undefined) fs.rmSync(repoDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('resolves an optional-chained receiver — svc?.getUser().save()', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
expect(calls.find((c) => c.source === 'viaOptionalChain' && c.target === 'save')).toMatchObject(
|
||||
{
|
||||
target: 'save',
|
||||
targetFilePath: 'models.ts',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves an explicit-type-argument receiver — svc.getTyped<User>().save()', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
expect(calls.find((c) => c.source === 'viaTypeArgs' && c.target === 'save')).toMatchObject({
|
||||
target: 'save',
|
||||
targetFilePath: 'models.ts',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves a non-null-asserted receiver — svc!.getUser().save()', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
expect(calls.find((c) => c.source === 'viaNonNull' && c.target === 'save')).toMatchObject({
|
||||
target: 'save',
|
||||
targetFilePath: 'models.ts',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves a mixed call/field chain through the fold', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
expect(calls.find((c) => c.source === 'viaMixedChain' && c.target === 'persist')).toMatchObject(
|
||||
{
|
||||
target: 'persist',
|
||||
targetFilePath: 'models.ts',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the shape that already resolved through the text cascade', () => {
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
expect(calls.find((c) => c.source === 'alreadyWorked' && c.target === 'save')).toMatchObject({
|
||||
target: 'save',
|
||||
targetFilePath: 'models.ts',
|
||||
});
|
||||
});
|
||||
|
||||
it('emits no CALLS edge to a member that exists on no class in the chain', () => {
|
||||
// A broken middle step must produce NO edge, never a wrong one.
|
||||
const calls = getRelationships(result, 'CALLS');
|
||||
expect(calls.filter((c) => c.target === 'noSuchMember')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -73,12 +73,12 @@ describe('CALL_SUMMARY relation-type exclusion (U-C1)', () => {
|
|||
});
|
||||
|
||||
describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => {
|
||||
it('INCREMENTAL_SCHEMA_VERSION is bumped to 26 (unresolved-receiver epistemic summary, #2744)', () => {
|
||||
it('INCREMENTAL_SCHEMA_VERSION is bumped to 27 (structural receiver typing)', () => {
|
||||
// Moves with every bump BY DESIGN — that is the point of pinning it. A
|
||||
// change that alters emitted ids or edges without bumping would otherwise
|
||||
// ship silently, and an existing index would keep serving the old graph
|
||||
// through the reuse gate below.
|
||||
expect(INCREMENTAL_SCHEMA_VERSION).toBe(26);
|
||||
expect(INCREMENTAL_SCHEMA_VERSION).toBe(27);
|
||||
});
|
||||
|
||||
it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => {
|
||||
|
|
@ -179,11 +179,18 @@ describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => {
|
|||
expect(passesReuseGate(23)).toBe(false);
|
||||
// A pre-v25 (v24) index predates mod-qualified Rust node ids (#2742) → must NOT reuse.
|
||||
expect(passesReuseGate(24)).toBe(false);
|
||||
// A pre-v26 (v25) index predates `unresolvedReceiverMembers` (#2744). An absent
|
||||
// summary is indistinguishable from "nothing was dropped", so a top-up would keep
|
||||
// reporting `epistemic: 'exact'` for exactly the symbols whose callers were dropped.
|
||||
// A pre-v25 (v24) index predates `unresolvedReceiverMembers` (#2744). An
|
||||
// absent summary is indistinguishable from "nothing was dropped", so a
|
||||
// top-up would keep reporting `epistemic: 'exact'` for exactly the symbols
|
||||
// whose callers were dropped → must NOT reuse.
|
||||
expect(passesReuseGate(24)).toBe(false);
|
||||
// A pre-v26 (v25) index typed receivers from source TEXT, so `svc?.m().n()`,
|
||||
// `svc!.m().n()` and `svc.m<T>().n()` emitted no CALLS edge — and two of the
|
||||
// three recorded no drop either, so the count still claimed `exact`. A
|
||||
// changed-files top-up keeps both the missing edge and the false confidence
|
||||
// for every unchanged file → must NOT reuse.
|
||||
expect(passesReuseGate(25)).toBe(false);
|
||||
// The current stamp passes (incremental top-up eligible).
|
||||
// The current stamp passes the gate (incremental top-up eligible).
|
||||
expect(passesReuseGate(26)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue