@remnic/coding-graph 9.6.22 → 9.6.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,395 @@
1
+ /**
2
+ * Phase A heuristic edge resolution (issue #1891).
3
+ *
4
+ * The parser engine emits raw call candidates (`FileIR.callSites`); the
5
+ * graph store persists only pre-resolved `EdgeIR[]`. This module is the
6
+ * bridge the docs call "Phase A — heuristic": a PURE function that derives
7
+ * CALLS edge assertions from a freshly parsed batch, using only evidence
8
+ * present in the IR itself:
9
+ *
10
+ * - src: the innermost same-file symbol whose span contains the call
11
+ * site (half-open spans, rule 35). Top-level calls (no enclosing
12
+ * symbol) are skipped and counted — module-level side effects are not
13
+ * a caller symbol.
14
+ * - dst, in candidate order:
15
+ * 1. a same-file symbol VISIBLE from the call site under lexical
16
+ * scoping approximated by span containment: the caller's own
17
+ * nested symbols first, then each enclosing scope's children out
18
+ * to the file level. Innermost match wins (shadowing); a nested
19
+ * symbol under an unrelated parent is not visible (codex P2 on
20
+ * #1894). Confidence {@link HEURISTIC_CONFIDENCE_SAME_FILE}.
21
+ * 2. an import-bound name (`imports[].importedNames`) whose module
22
+ * specifier is RELATIVE ("./", "../") — i.e. resolvable inside
23
+ * the repo. External-package imports (lodash, node:fs) never
24
+ * bind: emitting their bare names would let the store's
25
+ * qualified-name fallback attach them to unrelated in-repo
26
+ * symbols (codex P2 on #1894). Confidence
27
+ * {@link HEURISTIC_CONFIDENCE_IMPORT_BOUND}; the store's
28
+ * cross-file/full-DB resolution finds the exporting file.
29
+ * A bare name with neither anchor (e.g. `console.log`'s `log`) is
30
+ * skipped — emitting it would delegate a guess, not evidence.
31
+ * - Ambiguity is dropped conservatively (same policy as the store's
32
+ * pass-2): a bare name matching two symbols in the SAME scope level
33
+ * resolves to neither; the next candidate is tried.
34
+ *
35
+ * Final id resolution (batch map + full-DB dst fallback + ambiguity
36
+ * drops) stays in `GraphStore.upsertFileEdges`; this module never touches
37
+ * the DB. Every file in the output carries an explicit `edges` array —
38
+ * `[]` asserts "this parse supports no heuristic edges" so stale edges
39
+ * from a prior version of the file are cleaned up. Paired with the
40
+ * store's provenance-scoped stale delete (`assertedEdgeProvenances`),
41
+ * re-derivation never destroys `trace`/`lsp` edges (rule 25).
42
+ */
43
+ import { posix } from "node:path";
44
+
45
+ import type { EdgeIR, FileIR, StoreFileIR } from "./graph-store.js";
46
+
47
+ /**
48
+ * Languages where a method call on the enclosing instance REQUIRES an
49
+ * explicit receiver (this./self.) — a bare identifier call can never mean
50
+ * a sibling method, so class-like scopes are excluded from bare-call
51
+ * resolution. Implicit-this languages (Java, C#, Kotlin, Swift, Ruby,
52
+ * PHP, C++, Go methods via receivers, ...) keep their class scopes.
53
+ */
54
+ const EXPLICIT_RECEIVER_LANGUAGES: ReadonlySet<string> = new Set([
55
+ "typescript",
56
+ "tsx",
57
+ "javascript",
58
+ "python",
59
+ // PHP method dispatch requires $this->/self:: — both arrive as
60
+ // member_call_expression (memberAccess) — so a bare helper() inside a
61
+ // method never means a sibling method (codex review round 12).
62
+ "php",
63
+ ]);
64
+
65
+ /** Confidence for a call resolved to a unique same-file symbol. */
66
+ export const HEURISTIC_CONFIDENCE_SAME_FILE = 0.9;
67
+ /** Confidence for a call resolved through an import binding. */
68
+ export const HEURISTIC_CONFIDENCE_IMPORT_BOUND = 0.8;
69
+
70
+ /**
71
+ * The provenance scope reindex asserts for derived edges: ONLY its own
72
+ * heuristic derivations. Each provenance layer owns its lifecycle
73
+ * (issue #1894 review rounds 3/7/8): `lsp` rows include member-dispatch
74
+ * resolutions Phase A deliberately never re-asserts, so including `lsp`
75
+ * here would retire still-valid method-call edges on every edit of their
76
+ * file. Vanished-call `lsp` rows are the LSP pass's job to retire when it
77
+ * re-derives from the current parse (its edges die with their src nodes on
78
+ * symbol pruning regardless). `trace` (runtime observation) and semantic
79
+ * edges are likewise never this scope's to touch. The store's update guard
80
+ * separately ensures a re-asserted key never DOWNGRADES an lsp row.
81
+ */
82
+ export const HEURISTIC_PROVENANCE_SCOPE = ["heuristic"] as const;
83
+
84
+ /** Per-batch resolution counters — surfaced by callers for observability. */
85
+ export interface HeuristicResolutionStats {
86
+ /** Total call sites examined across the batch. */
87
+ readonly callSites: number;
88
+ /** Call sites that produced an edge assertion. */
89
+ readonly resolved: number;
90
+ /** Call sites whose candidates matched nothing (no evidence). */
91
+ readonly skippedUnresolved: number;
92
+ /** Call sites whose best candidate was ambiguous in-file. */
93
+ readonly skippedAmbiguous: number;
94
+ /** Call sites with no enclosing symbol (module-level calls). */
95
+ readonly skippedNoEnclosingSymbol: number;
96
+ /** Member/property calls — Phase B (LSP) territory, never bare-bound. */
97
+ readonly skippedMemberAccess: number;
98
+ }
99
+
100
+ /** A FileIR enriched with the store-consumable edge assertions. */
101
+ export type ResolvedFileIR = StoreFileIR & { readonly edges: readonly EdgeIR[] };
102
+
103
+ export interface HeuristicResolutionResult {
104
+ readonly files: readonly ResolvedFileIR[];
105
+ readonly stats: HeuristicResolutionStats;
106
+ }
107
+
108
+ /**
109
+ * Derive CALLS edge assertions for a freshly parsed batch. Pure and
110
+ * deterministic: output order follows input order (files, then call
111
+ */
112
+ export function deriveHeuristicEdges(
113
+ batch: readonly FileIR[],
114
+ ): HeuristicResolutionResult {
115
+ let callSites = 0;
116
+ let resolved = 0;
117
+ let skippedUnresolved = 0;
118
+ let skippedAmbiguous = 0;
119
+ let skippedNoEnclosingSymbol = 0;
120
+ let skippedMemberAccess = 0;
121
+
122
+ const files: ResolvedFileIR[] = [];
123
+
124
+ for (const ir of batch) {
125
+ // Lexical-scope approximation from span containment: each symbol's
126
+ // parent is the innermost OTHER symbol strictly containing its span;
127
+ // no parent = file level (empty-string key).
128
+ const parentOf = new Map<string, string>();
129
+ const kindOf = new Map<string, string>();
130
+ for (const sym of ir.symbols) kindOf.set(sym.qualifiedName, sym.kind);
131
+ for (const sym of ir.symbols) {
132
+ let parent: { qualifiedName: string; size: number } | undefined;
133
+ for (const other of ir.symbols) {
134
+ if (other === sym) continue;
135
+ const contains =
136
+ other.span.startByte <= sym.span.startByte &&
137
+ sym.span.endByte <= other.span.endByte;
138
+ const identical =
139
+ other.span.startByte === sym.span.startByte &&
140
+ other.span.endByte === sym.span.endByte;
141
+ if (!contains || identical) continue;
142
+ const size = other.span.endByte - other.span.startByte;
143
+ if (!parent || size < parent.size) {
144
+ parent = { qualifiedName: other.qualifiedName, size };
145
+ }
146
+ }
147
+ parentOf.set(sym.qualifiedName, parent ? parent.qualifiedName : "");
148
+ }
149
+ // scope level (parent qualifiedName or "" = file) → name → matches.
150
+ const scopeByName = new Map<string, Map<string, { qualifiedName: string; count: number }>>();
151
+ for (const sym of ir.symbols) {
152
+ const level = parentOf.get(sym.qualifiedName) ?? "";
153
+ let names = scopeByName.get(level);
154
+ if (!names) {
155
+ names = new Map();
156
+ scopeByName.set(level, names);
157
+ }
158
+ const prior = names.get(sym.name);
159
+ if (prior) {
160
+ prior.count += 1;
161
+ } else {
162
+ names.set(sym.name, { qualifiedName: sym.qualifiedName, count: 1 });
163
+ }
164
+ }
165
+ // name → repo-relative extension-stripped target path. Only relative
166
+ // specifiers bind (an external package's names must never resolve to
167
+ // in-repo symbols); the hint travels on the edge so the store resolves
168
+ // the dst ONLY within the declared target file (#1894 review).
169
+ const importBindings = new Map<
170
+ string,
171
+ Array<{ exported: string; hint: string; enclosing: string | undefined; invisible?: boolean }>
172
+ >();
173
+ for (const imp of ir.imports) {
174
+ // Two relative-import spellings bind (cursor review on #1894):
175
+ // - path style ("./x", "../x") — JS/TS and friends;
176
+ // - Python dot style (".models", "..parent.sub") — one leading dot
177
+ // is the current package, each extra dot goes one level up, and
178
+ // interior dots are path separators.
179
+ let specifier: string | undefined;
180
+ if (imp.module.startsWith("./") || imp.module.startsWith("../")) {
181
+ specifier = imp.module;
182
+ } else if (ir.language === "python" && imp.module.startsWith(".")) {
183
+ const dots = (/^\.+/.exec(imp.module))?.[0].length ?? 1;
184
+ const rest = imp.module.slice(dots).replace(/\./g, "/");
185
+ specifier = `${"../".repeat(dots - 1)}${rest}` || ".";
186
+ }
187
+ if (specifier === undefined) continue;
188
+ const joined = posix.join(posix.dirname(ir.path), specifier);
189
+ // Strip any trailing slash BEFORE extension-stripping: a pure
190
+ // parent-package import (python "from .. import x") joins to
191
+ // "pkg/" and the store's hint patterns match "pkg" /
192
+ // "pkg/__init__.<ext>", never "pkg/" (codex review round 10).
193
+ const hint = joined.replace(/\/+$/, "").replace(/\.[cm]?[jt]sx?$/, "");
194
+ // A normalized hint still starting with "../" escapes the repo root:
195
+ // files.path values are canonical (no ".." segments), so such an
196
+ // edge could never resolve — skip the binding here so the call site
197
+ // is honestly counted as unresolved instead of emitting a
198
+ // guaranteed-dead edge (cursor review on #1894).
199
+ if (hint.startsWith("../")) continue;
200
+ // Alias-aware (codex review on #1894): call sites use the LOCAL
201
+ // identifier; the dst in the target file is the EXPORTED name.
202
+ // Fallback semantics sharpened across rounds 9 and 12: an ABSENT
203
+ // bindings field means a pre-bindings emitter (or hand-built IR) —
204
+ // fall back to importedNames as local===exported. An EXPLICIT empty
205
+ // array is the parser saying "no call-bindable names" (default /
206
+ // namespace imports whose exported symbol Phase A cannot know) and
207
+ // must NOT re-bind through the fallback.
208
+ const bindings =
209
+ imp.bindings ?? imp.importedNames.map((name) => ({ exported: name, local: name }));
210
+ // A function-local import binds only within its enclosing symbol
211
+ // (codex review round 12): find the innermost symbol containing the
212
+ // import's span; undefined = file level (binds everywhere).
213
+ let enclosing: { qualifiedName: string; size: number } | undefined;
214
+ for (const sym of ir.symbols) {
215
+ if (sym.span.startByte > imp.span.startByte || imp.span.endByte > sym.span.endByte) continue;
216
+ const size = sym.span.endByte - sym.span.startByte;
217
+ if (!enclosing || size < enclosing.size) {
218
+ enclosing = { qualifiedName: sym.qualifiedName, size };
219
+ }
220
+ }
221
+ // A local name can be imported in more than one scope (codex review
222
+ // round 13); keep them all and choose the innermost matching one at
223
+ // the call site. Class-body imports are class attributes and are NOT
224
+ // bare-call-visible, so a class-kind enclosing is filtered out at
225
+ // lookup time.
226
+ const enclosingKind =
227
+ enclosing === undefined ? undefined : kindOf.get(enclosing.qualifiedName);
228
+ const visible =
229
+ enclosingKind === undefined ||
230
+ (enclosingKind !== "class" &&
231
+ enclosingKind !== "interface" &&
232
+ enclosingKind !== "enum");
233
+ for (const b of bindings) {
234
+ const list = importBindings.get(b.local) ?? [];
235
+ list.push({
236
+ exported: b.exported,
237
+ hint,
238
+ enclosing: visible ? enclosing?.qualifiedName : undefined,
239
+ // A class-body import is genuinely invisible: store it with a
240
+ // sentinel that can never match an ancestor chain.
241
+ invisible: !visible,
242
+ });
243
+ importBindings.set(b.local, list);
244
+ }
245
+ }
246
+
247
+ const edges: EdgeIR[] = [];
248
+ const seenKeys = new Set<string>();
249
+
250
+ for (const site of ir.callSites) {
251
+ callSites += 1;
252
+
253
+ // Member/property calls (obj.save()) never bind bare names in
254
+ // Phase A: the receiver decides the target, and only Phase B (LSP)
255
+ // can resolve dispatch (codex review on #1894).
256
+ if (site.memberAccess === true) {
257
+ skippedMemberAccess += 1;
258
+ continue;
259
+ }
260
+
261
+ // src: innermost enclosing symbol (smallest containing span).
262
+ let src: { qualifiedName: string; size: number } | undefined;
263
+ for (const sym of ir.symbols) {
264
+ if (sym.span.startByte > site.span.startByte || site.span.endByte > sym.span.endByte) continue;
265
+ const size = sym.span.endByte - sym.span.startByte;
266
+ if (!src || size < src.size) {
267
+ src = { qualifiedName: sym.qualifiedName, size };
268
+ }
269
+ }
270
+ if (!src) {
271
+ skippedNoEnclosingSymbol += 1;
272
+ continue;
273
+ }
274
+
275
+ // Visible scope levels for this call site, innermost first: the
276
+ // caller's own children, then each ancestor's children, ending at
277
+ // the file level. Symbols nested under unrelated parents are never
278
+ // consulted. In EXPLICIT-receiver languages (JS/TS/Python),
279
+ // class-like ancestors are NOT bare-call scopes (codex review
280
+ // round 8): a bare helper() inside a method cannot mean the
281
+ // sibling method C.helper — that call would be
282
+ // this.helper()/self.helper(), i.e. a member access Phase A
283
+ // skips. Implicit-this languages (Java/C#/Kotlin/Swift/Ruby/...)
284
+ // DO allow an unqualified call to target a same-type method, so
285
+ // their class scopes contribute (codex review round 11).
286
+ // Function/method/module ancestors always contribute.
287
+ const excludeClassScopes = EXPLICIT_RECEIVER_LANGUAGES.has(ir.language);
288
+ const scopeLevels: string[] = [src.qualifiedName];
289
+ // Full ancestor chain (class levels included) — used for
290
+ // function-local import visibility, which is lexical containment,
291
+ // not bare-call scoping.
292
+ const ancestorChain = new Set<string>([src.qualifiedName]);
293
+ let cursor: string | undefined = src.qualifiedName;
294
+ while (cursor !== undefined && cursor !== "") {
295
+ cursor = parentOf.get(cursor) ?? "";
296
+ if (cursor !== "") ancestorChain.add(cursor);
297
+ if (excludeClassScopes) {
298
+ const kind = cursor === "" ? undefined : kindOf.get(cursor);
299
+ if (kind === "class" || kind === "interface" || kind === "enum") continue;
300
+ }
301
+ scopeLevels.push(cursor);
302
+ }
303
+
304
+ // dst: first candidate with in-IR evidence. Ambiguity is tracked
305
+ // PER CANDIDATE (cursor review on #1894): an ambiguous same-file
306
+ // match for candidate A must not block candidate B's import
307
+ // binding.
308
+ let dstQualifiedName: string | undefined;
309
+ let dstPathHint: string | undefined;
310
+ let confidence = 0;
311
+ let sawAmbiguous = false;
312
+ for (const candidate of site.calleeNameCandidates) {
313
+ let candidateAmbiguous = false;
314
+ for (const level of scopeLevels) {
315
+ const match = scopeByName.get(level)?.get(candidate);
316
+ if (!match) continue;
317
+ if (match.count === 1) {
318
+ dstQualifiedName = match.qualifiedName;
319
+ confidence = HEURISTIC_CONFIDENCE_SAME_FILE;
320
+ } else {
321
+ candidateAmbiguous = true;
322
+ }
323
+ break; // innermost level with the name decides (shadowing).
324
+ }
325
+ if (dstQualifiedName !== undefined) break;
326
+ if (candidateAmbiguous) {
327
+ sawAmbiguous = true;
328
+ continue;
329
+ }
330
+ const candidates = importBindings.get(candidate);
331
+ if (candidates !== undefined) {
332
+ // Innermost visible binding wins (codex review round 13):
333
+ // a function-local import shadows a same-name file-level one
334
+ // inside that function; class-body imports are never visible.
335
+ let chosen: { exported: string; hint: string } | undefined;
336
+ let chosenDepth = -1;
337
+ for (const c of candidates) {
338
+ if (c.invisible) continue;
339
+ if (c.enclosing === undefined) {
340
+ if (chosenDepth < 0) { chosen = c; chosenDepth = 0; }
341
+ continue;
342
+ }
343
+ if (ancestorChain.has(c.enclosing)) {
344
+ const depth = [...ancestorChain].indexOf(c.enclosing);
345
+ if (chosenDepth < 0 || depth < chosenDepth) { chosen = c; chosenDepth = depth; }
346
+ }
347
+ }
348
+ if (chosen) {
349
+ dstQualifiedName = chosen.exported;
350
+ dstPathHint = chosen.hint;
351
+ confidence = HEURISTIC_CONFIDENCE_IMPORT_BOUND;
352
+ break;
353
+ }
354
+ }
355
+ }
356
+ if (dstQualifiedName === undefined) {
357
+ if (sawAmbiguous) skippedAmbiguous += 1;
358
+ else skippedUnresolved += 1;
359
+ continue;
360
+ }
361
+
362
+ resolved += 1;
363
+ // The hint is part of edge identity (codex review on #1894): two
364
+ // aliased imports of the same exported name from different modules
365
+ // resolve to different nodes at the store, so both must survive.
366
+ const key = `${src.qualifiedName}\u0000${dstQualifiedName}\u0000${dstPathHint ?? ""}`;
367
+ if (seenKeys.has(key)) continue;
368
+ seenKeys.add(key);
369
+ edges.push({
370
+ srcQualifiedName: src.qualifiedName,
371
+ dstQualifiedName,
372
+ type: "CALLS",
373
+ confidence,
374
+ provenance: "heuristic",
375
+ ...(dstPathHint !== undefined
376
+ ? { dstPathHint, dstImporterLanguage: ir.language }
377
+ : {}),
378
+ });
379
+ }
380
+
381
+ files.push({ ...ir, edges });
382
+ }
383
+
384
+ return {
385
+ files,
386
+ stats: {
387
+ callSites,
388
+ resolved,
389
+ skippedUnresolved,
390
+ skippedAmbiguous,
391
+ skippedNoEnclosingSymbol,
392
+ skippedMemberAccess,
393
+ },
394
+ };
395
+ }
@@ -1036,3 +1036,60 @@ test("executor: explicit empty candidatePaths ([]) is treated as insufficient
1036
1036
  await dispose(store, dir);
1037
1037
  }
1038
1038
  });
1039
+
1040
+ test("executor: an lsp-only edge on an UNCHANGED file survives incremental reindex (issue #1894 round 7)", async () => {
1041
+ const { store, dir } = await tempStore();
1042
+ try {
1043
+ await writeFiles(dir, {
1044
+ "src/a.ts": "export function foo() {}",
1045
+ "src/b.ts": "export function bar() {}",
1046
+ });
1047
+ const git1 = mockGit({ head: SHA_A });
1048
+ await executeReindex({
1049
+ store,
1050
+ git: git1,
1051
+ repoRoot: dir,
1052
+ parseFile: mockParseFile,
1053
+ candidatePaths: ["src/a.ts", "src/b.ts"],
1054
+ });
1055
+ // LSP resolves a member call Phase A skipped: an lsp-ONLY edge owned
1056
+ // by src/a.ts (never asserted by any heuristic derivation).
1057
+ const upgraded = await store.upsertEdges([
1058
+ {
1059
+ srcQualifiedName: "src/a.ts::foo",
1060
+ dstQualifiedName: "src/b.ts::foo",
1061
+ type: "CALLS",
1062
+ confidence: 1,
1063
+ provenance: "lsp",
1064
+ },
1065
+ ]);
1066
+ assert.ok(upgraded.ok && upgraded.persisted === 1);
1067
+
1068
+ // Only src/b.ts changes; src/a.ts is NOT re-ingested, so its lsp-only
1069
+ // edge must survive — ingested files are changed-or-fresh by
1070
+ // construction, which is the invariant that keeps the [heuristic, lsp]
1071
+ // assertion scope safe for lsp-only edges on untouched files.
1072
+ await writeFiles(dir, { "src/b.ts": "export function bar() { return 2; }" });
1073
+ const git2 = mockGit({
1074
+ head: SHA_B,
1075
+ reachable: true,
1076
+ changedFiles: [{ status: "M", path: "src/b.ts" }],
1077
+ });
1078
+ const result = await executeReindex({
1079
+ store,
1080
+ git: git2,
1081
+ repoRoot: dir,
1082
+ parseFile: mockParseFile,
1083
+ candidatePaths: ["src/a.ts", "src/b.ts"],
1084
+ });
1085
+ assert.equal(result.ok, true);
1086
+ if (!result.ok) return;
1087
+ assert.equal(result.mode, "incremental");
1088
+ assert.equal(result.filesIngested, 1);
1089
+ const stats = store.schemaStats();
1090
+ assert.ok(stats.ok);
1091
+ assert.deepEqual(stats.stats.edgesByType, { CALLS: 1 }, "lsp-only edge on unchanged src/a.ts survived");
1092
+ } finally {
1093
+ await dispose(store, dir);
1094
+ }
1095
+ });
package/src/reindex.ts CHANGED
@@ -41,12 +41,17 @@ import type { ParseFileInput, ParseResult } from "@remnic/core";
41
41
 
42
42
  import type { CodingGitInvoker, GitFailure, NameStatusEntry } from "./git-invoker.js";
43
43
  import type {
44
+ FileIR,
44
45
  GraphStore,
45
46
  StoreFileIR,
46
47
  ReadMetaResult,
47
48
  ReadFileHashesResult,
49
+ GraphStoreFailure,
48
50
  } from "./graph-store.js";
49
- import type { GraphStoreFailure } from "./graph-store.js";
51
+ import {
52
+ deriveHeuristicEdges,
53
+ HEURISTIC_PROVENANCE_SCOPE,
54
+ } from "./heuristic-resolution.js";
50
55
 
51
56
  // ──────────────────────────────────────────────────────────────────────────
52
57
  // Public types — the planner's input and output
@@ -854,7 +859,7 @@ async function ingestFiles(
854
859
  paths: readonly string[],
855
860
  deletePaths: readonly string[] = [],
856
861
  ): Promise<IngestResult> {
857
- const batch: StoreFileIR[] = [];
862
+ const parsed: FileIR[] = [];
858
863
  const parseFailedPaths: string[] = [];
859
864
  for (const relPath of paths) {
860
865
  if (!isCanonicalRelativePath(relPath)) {
@@ -887,11 +892,18 @@ async function ingestFiles(
887
892
  parseFailedPaths.push(relPath);
888
893
  continue;
889
894
  }
890
- // FileIR is structurally assignable to StoreFileIR — the store only
891
- // reads the fields it needs and ignores extra FileIR fields (imports,
892
- // callSites). No cast needed.
893
- batch.push(parseResult.ir);
895
+ parsed.push(parseResult.ir);
894
896
  }
897
+ // Phase A heuristic resolution (issue #1891): derive CALLS edge
898
+ // assertions from the fresh parse before ingest. Each file carries an
899
+ // explicit edges array plus a provenance scope of ["heuristic"], so
900
+ // stale heuristic edges from a prior version are cleaned up while
901
+ // trace/lsp edges owned by the file survive (rule 25).
902
+ const derived = deriveHeuristicEdges(parsed);
903
+ const batch: StoreFileIR[] = derived.files.map((file) => ({
904
+ ...file,
905
+ assertedEdgeProvenances: HEURISTIC_PROVENANCE_SCOPE,
906
+ }));
895
907
  // Even when batch is empty we must run the upsert so deletePaths are
896
908
  // pruned atomically (the store's transaction wraps both). A zero-file,
897
909
  // zero-delete call is a cheap no-op.