@openparachute/vault 0.7.9-rc.2 → 0.7.9-rc.3

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.
@@ -3,6 +3,7 @@ import type { Note } from "./types.js";
3
3
  import * as linkOps from "./links.js";
4
4
  import { getNote, findNotesByTitle, extractH1Title } from "./notes.js";
5
5
  import { chunkForInClause } from "./sql-in.js";
6
+ import { pathTitle } from "./paths.js";
6
7
  import { transaction } from "./txn.js";
7
8
  import type { QueryWarning } from "./query-warnings.js";
8
9
 
@@ -107,6 +108,117 @@ function stripCode(content: string): string {
107
108
  return result;
108
109
  }
109
110
 
111
+ /**
112
+ * Rewrite the TARGET portion of `[[wikilinks]]` in `content`, in place.
113
+ *
114
+ * `rename` is called with each bracket's parsed target (trimmed, exactly as
115
+ * {@link parseWikilinks} would report it) and returns the replacement target
116
+ * string, or `null`/`undefined` to leave that bracket alone. Everything else
117
+ * about the bracket survives verbatim — the `!` embed marker, a `|display`
118
+ * alias, a `#Heading` anchor, a `#^block-ref`, and any surrounding text.
119
+ *
120
+ * Brackets inside fenced/inline code are skipped, because {@link stripCode}
121
+ * is applied before matching — same as the parser. That is deliberate: a
122
+ * `[[link]]` in a code fence is not a link (it is never parsed, never
123
+ * resolved, never given a `links` row), so a rename must not silently edit
124
+ * someone's sample text. The pre-vault#708 cascade rewrote those too, via a
125
+ * blind content-wide regex.
126
+ */
127
+ export function rewriteWikilinkTargets(
128
+ content: string,
129
+ rename: (target: string) => string | null | undefined,
130
+ ): string {
131
+ const stripped = stripCode(content);
132
+ const regex = /(!)?\[\[([^\[\]\n]+?)\]\]/g;
133
+ let out = "";
134
+ let last = 0;
135
+ let match: RegExpExecArray | null;
136
+
137
+ while ((match = regex.exec(stripped)) !== null) {
138
+ const inner = match[2]!;
139
+ // Target part = everything before the first `#` (anchor/block-ref) or
140
+ // `|` (display alias), whichever comes first. Matches how
141
+ // `parseWikilinks` splits, for every ordering of the two.
142
+ let boundary = inner.length;
143
+ const hashIdx = inner.indexOf("#");
144
+ const pipeIdx = inner.indexOf("|");
145
+ if (hashIdx !== -1) boundary = Math.min(boundary, hashIdx);
146
+ if (pipeIdx !== -1) boundary = Math.min(boundary, pipeIdx);
147
+ const targetPart = inner.slice(0, boundary);
148
+ const target = targetPart.trim();
149
+ if (!target) continue;
150
+
151
+ const next = rename(target);
152
+ if (!next || next === targetPart) continue;
153
+
154
+ // `stripCode` preserves offsets, so the match indices address `content`.
155
+ const start = match.index;
156
+ const end = start + match[0].length;
157
+ const innerStart = start + (match[1] ? 3 : 2);
158
+ out += content.slice(last, innerStart) + next + content.slice(innerStart + boundary, end);
159
+ last = end;
160
+ }
161
+
162
+ return last === 0 ? content : out + content.slice(last);
163
+ }
164
+
165
+ /**
166
+ * How a wikilink target names a note PATH — the shapes
167
+ * {@link resolveWikilink} can match a path by, in its resolution order.
168
+ * `null` means the target is not a path-derived name for `path` at all (e.g.
169
+ * it resolved through the H1-title fallback, which a repath doesn't affect).
170
+ *
171
+ * Used by the rename cascade (vault#708) to rewrite a bracket into the SAME
172
+ * shape it already had rather than forcing every reference to a full path.
173
+ */
174
+ export type WikilinkPathForm =
175
+ | { form: "path" }
176
+ | { form: "title" }
177
+ | { form: "path-ext"; ext: string };
178
+
179
+ export function wikilinkPathForm(target: string, path: string): WikilinkPathForm | null {
180
+ const lower = target.toLowerCase();
181
+ const title = pathTitle(path);
182
+ // Order mirrors `resolveWikilink`: a literal path wins over the
183
+ // explicit-extension reading of the same string (`Recipe.v2`).
184
+ if (lower === path.toLowerCase()) return { form: "path" };
185
+ if (lower === title.toLowerCase()) return { form: "title" };
186
+ // Only the FULL-path `.ext` form exists: `resolveWikilink`'s
187
+ // explicit-extension rule matches on `(path, extension)`, so a
188
+ // basename+ext bracket (`[[Data.csv]]` for `move/Data`) never resolved in
189
+ // the first place and is not this cascade's to repair.
190
+ const extMatch = target.match(/^(.*)\.([a-z0-9]{1,16})$/i);
191
+ if (extMatch && extMatch[1]!.toLowerCase() === path.toLowerCase()) {
192
+ return { form: "path-ext", ext: extMatch[2]! };
193
+ }
194
+ return null;
195
+ }
196
+
197
+ /**
198
+ * The candidate replacement texts for a bracket of `form` after the note
199
+ * moved to `newPath`, most-preferred first. The caller picks the first one
200
+ * that resolves back to the renamed note (see `Store.cascadeRename`):
201
+ * a basename bracket stays a basename bracket unless the new basename would
202
+ * now be ambiguous (or collide across extensions), in which case it widens
203
+ * to the full path.
204
+ */
205
+ export function wikilinkRenameCandidates(
206
+ form: WikilinkPathForm,
207
+ newPath: string,
208
+ noteExtension: string | null | undefined,
209
+ ): string[] {
210
+ const newTitle = pathTitle(newPath);
211
+ const ext = noteExtension || "md";
212
+ switch (form.form) {
213
+ case "path":
214
+ return [newPath, `${newPath}.${ext}`];
215
+ case "title":
216
+ return [newTitle, newPath, `${newPath}.${ext}`];
217
+ case "path-ext":
218
+ return [`${newPath}.${form.ext}`];
219
+ }
220
+ }
221
+
110
222
  // ---------------------------------------------------------------------------
111
223
  // Resolution — match wikilink targets to notes by path
112
224
  // ---------------------------------------------------------------------------
@@ -336,6 +448,50 @@ export function listUnresolvedWikilinks(db: Database, limit = 50): { unresolved:
336
448
  return { unresolved, count: total };
337
449
  }
338
450
 
451
+ /**
452
+ * The `ambiguous_wikilinks` rows that are BROKEN in a tag-scoped reader's own
453
+ * sub-vault — every candidate invisible to it — shaped as
454
+ * {@link UnresolvedWikilink} so `/unresolved-wikilinks` can report them
455
+ * alongside the genuinely-unresolved ones (vault#239).
456
+ *
457
+ * Without this the admin listing is the third face of the same delete-time
458
+ * oracle `getUnresolvedLinksForNotes` closes on the note surfaces: the row
459
+ * only appears there once the last invisible candidate is deleted and
460
+ * `refreshAmbiguousLinks` demotes it. Callers are expected to apply their own
461
+ * source-note tag-scope filter afterwards, exactly as they do for the base
462
+ * listing. Scanned under the same `limit` slice as
463
+ * {@link listUnresolvedWikilinks}; `[]` when the table has never been created
464
+ * (and never called at all for an unscoped reader, whose answer is the
465
+ * persisted tables as-is).
466
+ */
467
+ export function listZeroVisibleAmbiguousAsUnresolved(
468
+ db: Database,
469
+ visible: (noteId: string) => boolean,
470
+ limit = 50,
471
+ ): UnresolvedWikilink[] {
472
+ let rows: { source_id: string; target_path: string; relationship: string }[];
473
+ try {
474
+ rows = db.prepare(
475
+ "SELECT source_id, target_path, relationship FROM ambiguous_wikilinks ORDER BY source_id LIMIT ?",
476
+ ).all(limit) as typeof rows;
477
+ } catch {
478
+ return []; // Table doesn't exist — nothing has ever been ambiguous here.
479
+ }
480
+ const out: UnresolvedWikilink[] = [];
481
+ for (const r of rows) {
482
+ const relationship = r.relationship || WIKILINK_REL;
483
+ if (visibleResolutionCount(db, r.target_path, relationship, visible) > 0) continue;
484
+ const note = db.prepare("SELECT path FROM notes WHERE id = ?").get(r.source_id) as { path: string | null } | null;
485
+ out.push({
486
+ source_id: r.source_id,
487
+ source_path: note?.path ?? undefined,
488
+ target_path: r.target_path,
489
+ relationship,
490
+ });
491
+ }
492
+ return out;
493
+ }
494
+
339
495
  /** One note's dangling outbound link, as surfaced on a note read (vault#555). */
340
496
  export interface BrokenLink {
341
497
  target: string;
@@ -347,11 +503,32 @@ export interface BrokenLink {
347
503
  * `include_broken_links` surfacing on `query-notes` / `GET /notes`. ONE
348
504
  * query for the whole page (mirrors `getLinksHydratedForNotes`'s batching),
349
505
  * not one per note. Returns a map with an entry (possibly `[]`) for every
350
- * requested id whenever the table exists; when the table has never been
351
- * created (no note in this vault has ever had a broken link) every id maps
352
- * to `[]` without a query attempt.
506
+ * requested id.
507
+ *
508
+ * `visible` (vault#239) is an OPTIONAL per-candidate-note predicate, injected
509
+ * by the server layer for a TAG-SCOPED reader — the SAME closure the
510
+ * ambiguity surface uses, because "which of this target's candidates can the
511
+ * reader see" is one question with two answers. Core stays scope-unaware; it
512
+ * only invokes the closure.
513
+ *
514
+ * When it is supplied, the persisted `ambiguous_wikilinks` rows are folded in
515
+ * too, as broken, whenever NONE of their candidates is visible: such a target
516
+ * matches nothing in the reader's sub-vault, which is what "broken" means.
517
+ * Without that, `refreshAmbiguousLinks`' delete-time demotion into
518
+ * `unresolved_wikilinks` is the only thing that ever makes the note broken —
519
+ * so the answer moves when notes the reader cannot see are deleted, which is
520
+ * an oracle for exactly the cross-scope naming collision
521
+ * {@link getAmbiguousLinksForNotes} narrows away.
522
+ *
523
+ * Cost: one extra query plus one re-resolution per ambiguous row, and only
524
+ * for scoped readers — the same profile the ambiguity surface accepts, on a
525
+ * table that is rare by nature. An unscoped reader does no extra work.
353
526
  */
354
- export function getUnresolvedLinksForNotes(db: Database, noteIds: string[]): Map<string, BrokenLink[]> {
527
+ export function getUnresolvedLinksForNotes(
528
+ db: Database,
529
+ noteIds: string[],
530
+ visible?: (noteId: string) => boolean,
531
+ ): Map<string, BrokenLink[]> {
355
532
  const result = new Map<string, BrokenLink[]>(noteIds.map((id) => [id, []]));
356
533
  if (noteIds.length === 0) return result;
357
534
 
@@ -365,19 +542,89 @@ export function getUnresolvedLinksForNotes(db: Database, noteIds: string[]): Map
365
542
  ).all(...chunk) as typeof rows);
366
543
  }
367
544
  } catch {
368
- // Table doesn't exist — every id already maps to [] above.
369
- return result;
545
+ // Table doesn't exist — no note in this vault has ever had a link go
546
+ // unresolved. NOT an early return: a tag-scoped reader can still have
547
+ // broken references whose rows live in `ambiguous_wikilinks` (vault#239),
548
+ // and this is the common case for them — a vault where every collision
549
+ // was ambiguous vault-wide never creates `unresolved_wikilinks` at all.
550
+ rows.length = 0;
370
551
  }
371
552
 
553
+ const seen = new Set<string>();
372
554
  for (const row of rows) {
373
- result.get(row.source_id)?.push({ target: row.target_path, relationship: row.relationship || WIKILINK_REL });
555
+ const relationship = row.relationship || WIKILINK_REL;
556
+ seen.add(`${row.source_id}\u0000${relationship}\u0000${row.target_path.toLowerCase()}`);
557
+ result.get(row.source_id)?.push({ target: row.target_path, relationship });
558
+ }
559
+
560
+ // vault#239 — a target whose candidates are ALL invisible to this reader
561
+ // is broken in the reader's sub-vault, whichever table its row happens to
562
+ // live in right now. Without this the note only becomes "broken" once the
563
+ // last invisible candidate is DELETED and `refreshAmbiguousLinks` demotes
564
+ // the row into `unresolved_wikilinks` — a timing oracle for exactly the
565
+ // cross-scope naming collision `getAmbiguousLinksForNotes` narrows away.
566
+ // No-op (and no extra query) for an unscoped reader.
567
+ if (visible) {
568
+ for (const row of readAmbiguousRows(db, noteIds)) {
569
+ const relationship = row.relationship || WIKILINK_REL;
570
+ if (visibleResolutionCount(db, row.target_path, relationship, visible) > 0) continue;
571
+ const key = `${row.source_id}\u0000${relationship}\u0000${row.target_path.toLowerCase()}`;
572
+ if (seen.has(key)) continue;
573
+ seen.add(key);
574
+ result.get(row.source_id)?.push({ target: row.target_path, relationship });
575
+ }
374
576
  }
375
577
  return result;
376
578
  }
377
579
 
378
580
  /** Single-note convenience wrapper around {@link getUnresolvedLinksForNotes}. */
379
- export function getUnresolvedLinksForNote(db: Database, noteId: string): BrokenLink[] {
380
- return getUnresolvedLinksForNotes(db, [noteId]).get(noteId) ?? [];
581
+ export function getUnresolvedLinksForNote(
582
+ db: Database,
583
+ noteId: string,
584
+ visible?: (noteId: string) => boolean,
585
+ ): BrokenLink[] {
586
+ return getUnresolvedLinksForNotes(db, [noteId], visible).get(noteId) ?? [];
587
+ }
588
+
589
+ /**
590
+ * Narrow a page of notes by the `has_broken_links` filter as a TAG-SCOPED
591
+ * reader should see it — the vault#239 twin of
592
+ * {@link narrowByVisibleAmbiguity}, and the reason
593
+ * {@link sqlHasBrokenLinks} lifts the SQL filter entirely under scope: the
594
+ * `unresolved_wikilinks` EXISTS test is neither a superset NOR a subset of
595
+ * the right answer (it MISSES a note whose only candidates are invisible,
596
+ * and it INCLUDES nothing it shouldn't), so neither polarity can be pushed
597
+ * down and both are re-decided here on the reader's own sub-vault.
598
+ *
599
+ * Page-shortening is the same effect `filterNotesByTagScope` already has on
600
+ * every scoped read. No-op when `wanted` is undefined or no predicate is
601
+ * injected (unscoped).
602
+ */
603
+ export function narrowByVisibleBrokenness<T extends { id: string }>(
604
+ db: Database,
605
+ notes: T[],
606
+ wanted: boolean | undefined,
607
+ visible: ((noteId: string) => boolean) | undefined,
608
+ ): T[] {
609
+ if (wanted === undefined || !visible || notes.length === 0) return notes;
610
+ const byNote = getUnresolvedLinksForNotes(db, notes.map((n) => n.id), visible);
611
+ return notes.filter((n) => ((byNote.get(n.id)?.length ?? 0) > 0) === wanted);
612
+ }
613
+
614
+ /**
615
+ * The `hasBrokenLinks` value to push into SQL for a reader that will be
616
+ * narrowed by {@link narrowByVisibleBrokenness} afterwards. Unlike
617
+ * {@link sqlHasAmbiguousLinks} — where `true` survives because every
618
+ * truly-ambiguous note also has a persisted row — BOTH polarities are lifted
619
+ * under scope, because a note can be broken in the reader's sub-vault while
620
+ * having no `unresolved_wikilinks` row at all (vault#239). Identity for
621
+ * unscoped readers. Shared by both doors so REST and MCP cannot drift.
622
+ */
623
+ export function sqlHasBrokenLinks(
624
+ wanted: boolean | undefined,
625
+ scoped: boolean,
626
+ ): boolean | undefined {
627
+ return scoped ? undefined : wanted;
381
628
  }
382
629
 
383
630
  // ---------------------------------------------------------------------------
@@ -425,7 +672,10 @@ export interface AmbiguousWikilinkTarget {
425
672
  * THIRD same-named note rather than reporting the collision. The caller
426
673
  * (MCP `create-note`/`update-note`, REST `POST`/`PATCH /notes`) surfaces
427
674
  * `ambiguous` as an `ambiguous_link` warning naming the target + match
428
- * count, distinct from `unresolved`'s `unresolved_link`.
675
+ * count, distinct from `unresolved`'s `unresolved_link`. Each ambiguous
676
+ * target is ALSO persisted to `ambiguous_wikilinks` (vault#581) so the
677
+ * collision stays queryable after the write, symmetric with how
678
+ * `unresolved_wikilinks` backs `has_broken_links`/`include_broken_links`.
429
679
  */
430
680
  export function syncWikilinks(
431
681
  db: Database,
@@ -491,8 +741,13 @@ export function syncWikilinks(
491
741
  }
492
742
 
493
743
  // Store unresolved wikilinks for later resolution. Ambiguous targets are
494
- // deliberately NOT queued here — see the doc comment above.
744
+ // deliberately NOT queued into `unresolved_wikilinks` — see the doc comment
745
+ // above — but they ARE recorded in their own `ambiguous_wikilinks` table
746
+ // (vault#581) so a later audit can find them via
747
+ // `has_ambiguous_links`/`include_ambiguous_links` instead of only seeing
748
+ // the transient write-time warning.
495
749
  syncUnresolvedWikilinks(db, noteId, unresolved);
750
+ syncAmbiguousWikilinks(db, noteId, ambiguous);
496
751
 
497
752
  return { added, removed, unresolved, ambiguous };
498
753
  }
@@ -515,8 +770,15 @@ export function syncWikilinks(
515
770
  * already migrated. `PRAGMA table_info` on a nonexistent table returns zero
516
771
  * rows rather than throwing, so this is safe to call unconditionally,
517
772
  * including from read paths that don't want to create the table lazily.
773
+ *
774
+ * Also the body of `migrateToV28` (vault#567 item 2): boot runs this once
775
+ * via `initSchema` so a pre-#555 2-column table is healed before the first
776
+ * wikilink touch. Gated the existing way — no table → return; column
777
+ * present → return; only the 2-column shape rebuilds. Does NOT create the
778
+ * table on vaults that never queued an unresolved link (lazy creation
779
+ * stays). The per-touch calls below become a no-op after v28.
518
780
  */
519
- function ensureRelationshipColumn(db: Database): void {
781
+ export function ensureRelationshipColumn(db: Database): void {
520
782
  const cols = db.prepare("PRAGMA table_info(unresolved_wikilinks)").all() as { name: string }[];
521
783
  if (cols.length === 0) return; // table doesn't exist — nothing to heal
522
784
  if (cols.some((c) => c.name === "relationship")) return; // already migrated
@@ -746,6 +1008,515 @@ export function resolveUnresolvedWikilinks(
746
1008
  return resolved;
747
1009
  }
748
1010
 
1011
+
1012
+ // ---------------------------------------------------------------------------
1013
+ // Ambiguous links (vault#581) — the queryable twin of `unresolved_wikilinks`
1014
+ //
1015
+ // An ambiguous target (≥2 notes match) is deliberately never linked and never
1016
+ // queued for backfill (see {@link syncWikilinks}). Before #581 that meant it
1017
+ // existed ONLY in the create/update response's transient `ambiguous_link`
1018
+ // warning: `has_broken_links` didn't match the note (`unresolved_wikilinks`
1019
+ // held no row for it), so a later audit couldn't surface the collision at all
1020
+ // — asymmetric with the unresolved-link story. These helpers persist it in its
1021
+ // own lazily-created table, which backs the `has_ambiguous_links` /
1022
+ // `include_ambiguous_links` filters on `query-notes` / `GET /notes`.
1023
+ //
1024
+ // Kept in a SEPARATE table rather than folded into `unresolved_wikilinks` on
1025
+ // purpose: the two states mean different things to a caller ("nothing there
1026
+ // yet, will heal itself when the note arrives" vs. "too many things there,
1027
+ // disambiguate the reference"), the backfill sweep must never treat an
1028
+ // ambiguous row as pending-resolution, and folding them would have silently
1029
+ // widened what `has_broken_links: true` returns for every existing caller.
1030
+ //
1031
+ // Like `unresolved_wikilinks`, the table lives outside `SCHEMA_SQL` and is
1032
+ // created lazily on first write — a vault where no link has ever been
1033
+ // ambiguous never grows it, and no schema-version bump is needed.
1034
+ // ---------------------------------------------------------------------------
1035
+
1036
+ /** One note's ambiguous outbound link, as surfaced on a note read (vault#581). */
1037
+ export interface AmbiguousLink {
1038
+ target: string;
1039
+ relationship: string;
1040
+ /** How many notes the target matched at the time it was last evaluated. */
1041
+ candidate_count: number;
1042
+ }
1043
+
1044
+ /**
1045
+ * Ensure the ambiguous_wikilinks table exists. Called lazily — only when we
1046
+ * actually have an ambiguous link. Same shape as `unresolved_wikilinks`
1047
+ * (3-column PK, `ON DELETE CASCADE` so deleting the SOURCE note drops its
1048
+ * rows) plus the match count the write-time warning already carries.
1049
+ */
1050
+ export function ensureAmbiguousTable(db: Database): void {
1051
+ db.exec(`
1052
+ CREATE TABLE IF NOT EXISTS ambiguous_wikilinks (
1053
+ source_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
1054
+ target_path TEXT NOT NULL COLLATE NOCASE,
1055
+ relationship TEXT NOT NULL DEFAULT '${WIKILINK_REL}',
1056
+ candidate_count INTEGER NOT NULL DEFAULT 0,
1057
+ PRIMARY KEY (source_id, target_path, relationship)
1058
+ )
1059
+ `);
1060
+ }
1061
+
1062
+ /**
1063
+ * Replace this note's `relationship = "wikilink"` ambiguous rows with the
1064
+ * targets a fresh content parse just found ambiguous. Scoped to wikilink-kind
1065
+ * rows only, exactly like {@link syncUnresolvedWikilinks} — a content
1066
+ * re-parse must not clobber ambiguous STRUCTURED-link rows recorded for this
1067
+ * note by {@link queueAmbiguousLink} (a different relationship, same table).
1068
+ */
1069
+ function syncAmbiguousWikilinks(
1070
+ db: Database,
1071
+ noteId: string,
1072
+ ambiguous: AmbiguousWikilinkTarget[],
1073
+ ): void {
1074
+ if (ambiguous.length === 0) {
1075
+ // Clean up any old wikilink-kind ambiguous entries for this note (the
1076
+ // `[[Dup]]` was edited out, or one of the colliding notes went away and
1077
+ // the target now resolves).
1078
+ try {
1079
+ db.prepare("DELETE FROM ambiguous_wikilinks WHERE source_id = ? AND relationship = ?").run(noteId, WIKILINK_REL);
1080
+ } catch {
1081
+ // Table may not exist yet — that's fine.
1082
+ }
1083
+ return;
1084
+ }
1085
+
1086
+ ensureAmbiguousTable(db);
1087
+ db.prepare("DELETE FROM ambiguous_wikilinks WHERE source_id = ? AND relationship = ?").run(noteId, WIKILINK_REL);
1088
+ const insert = db.prepare(
1089
+ "INSERT OR REPLACE INTO ambiguous_wikilinks (source_id, target_path, relationship, candidate_count) VALUES (?, ?, ?, ?)",
1090
+ );
1091
+ for (const entry of ambiguous) {
1092
+ insert.run(noteId, entry.target, WIKILINK_REL, entry.count);
1093
+ }
1094
+ }
1095
+
1096
+ /**
1097
+ * Record a structured link (or typed `reference` field value) whose target
1098
+ * matched ≥2 notes. `INSERT OR REPLACE` so a re-write with a different match
1099
+ * count refreshes rather than keeping a stale one. Called from
1100
+ * {@link resolveOrQueueLink}, the single funnel every structured-link write
1101
+ * path goes through.
1102
+ */
1103
+ export function queueAmbiguousLink(
1104
+ db: Database,
1105
+ sourceId: string,
1106
+ targetPath: string,
1107
+ relationship: string,
1108
+ candidateCount: number,
1109
+ ): void {
1110
+ ensureAmbiguousTable(db);
1111
+ db.prepare(
1112
+ "INSERT OR REPLACE INTO ambiguous_wikilinks (source_id, target_path, relationship, candidate_count) VALUES (?, ?, ?, ?)",
1113
+ ).run(sourceId, targetPath, relationship, candidateCount);
1114
+ }
1115
+
1116
+ /**
1117
+ * Ambiguous-table twin of {@link clearQueuedLink} — drop every ambiguous row
1118
+ * for `sourceId` under `relationship`, whatever (stale) target it names. Used
1119
+ * by the scalar `reference`-field sync before re-resolving a changed value.
1120
+ * Safe no-op when the table doesn't exist yet.
1121
+ */
1122
+ export function clearAmbiguousLink(db: Database, sourceId: string, relationship: string): void {
1123
+ try {
1124
+ db.prepare(
1125
+ "DELETE FROM ambiguous_wikilinks WHERE source_id = ? AND relationship = ?",
1126
+ ).run(sourceId, relationship);
1127
+ } catch {
1128
+ // Table may not exist yet — nothing to clear.
1129
+ }
1130
+ }
1131
+
1132
+ /**
1133
+ * Ambiguous-table twin of {@link clearQueuedLinkTarget} — drop exactly ONE
1134
+ * row, scoped by `targetPath` as well, so a `cardinality: "many"` reference
1135
+ * field losing one element doesn't blanket-clear the others' rows.
1136
+ * Safe no-op when the table doesn't exist yet.
1137
+ */
1138
+ export function clearAmbiguousLinkTarget(
1139
+ db: Database,
1140
+ sourceId: string,
1141
+ relationship: string,
1142
+ targetPath: string,
1143
+ ): void {
1144
+ try {
1145
+ db.prepare(
1146
+ "DELETE FROM ambiguous_wikilinks WHERE source_id = ? AND relationship = ? AND target_path = ? COLLATE NOCASE",
1147
+ ).run(sourceId, relationship, targetPath);
1148
+ } catch {
1149
+ // Table may not exist yet — nothing to clear.
1150
+ }
1151
+ }
1152
+
1153
+ /**
1154
+ * Batch-fetch each note's ambiguous outbound links — the
1155
+ * `include_ambiguous_links` surfacing on `query-notes` / `GET /notes`. ONE
1156
+ * query for the whole page (mirrors {@link getUnresolvedLinksForNotes}), not
1157
+ * one per note. Every requested id gets an entry (possibly `[]`); when the
1158
+ * table has never been created every id maps to `[]` without a query attempt.
1159
+ *
1160
+ * `visible` (vault#581 auth review) is an OPTIONAL per-candidate-note
1161
+ * predicate, injected by the server layer for a TAG-SCOPED reader. Core
1162
+ * stays scope-unaware — it only invokes the closure — exactly like
1163
+ * `nearTraversable` / `expandVisibility` / `aggregateVisibility` on the MCP
1164
+ * tool layer.
1165
+ *
1166
+ * Why it's needed: `candidate_count` is derived VAULT-WIDE by
1167
+ * {@link resolveWikilinkDetailed}, so the stored `2` on a `[[Dup]]` whose
1168
+ * candidates are one `#work` and one `#personal` note tells a `work`-scoped
1169
+ * reader that a second `Dup` exists somewhere it cannot see. Unlike
1170
+ * `broken_links` — which is safe by construction, since "unresolved" can
1171
+ * only mean *matched nothing* and therefore can't fingerprint anything —
1172
+ * an ambiguous row exists ONLY because ≥2 notes matched, and the count
1173
+ * quantifies exactly that.
1174
+ *
1175
+ * So when `visible` is supplied, the persisted count is NOT trusted: each
1176
+ * row's target is re-resolved and its candidates narrowed to the ones the
1177
+ * reader can see. A row is reported only when ≥2 VISIBLE candidates remain,
1178
+ * and `candidate_count` is the visible count. That is precisely the answer
1179
+ * an unscoped reader would get on a vault containing only the visible notes
1180
+ * — the same "answer on the visible sub-vault" rule vault#674 (`.tags`
1181
+ * scrubbing) and vault#675 (out-of-scope query tags match nothing) chose,
1182
+ * rather than refusing the request. It also incidentally hides a row that
1183
+ * has gone stale (its target resolves cleanly again but no sweep has
1184
+ * touched it yet), since that too collapses to <2 candidates.
1185
+ *
1186
+ * Cost: one re-resolution per persisted row, and only for scoped readers.
1187
+ * Ambiguous rows are rare by nature (each is a genuine naming collision),
1188
+ * and a page with none does no extra work at all.
1189
+ */
1190
+ interface AmbiguousRow {
1191
+ source_id: string;
1192
+ target_path: string;
1193
+ relationship: string;
1194
+ candidate_count: number;
1195
+ }
1196
+
1197
+ /**
1198
+ * Read the persisted `ambiguous_wikilinks` rows for a set of source notes,
1199
+ * batched under the bound-param cap. `[]` when the table has never been
1200
+ * created. Shared by the ambiguity surface and the vault#239 brokenness
1201
+ * surface so the two read the same rows the same way.
1202
+ */
1203
+ function readAmbiguousRows(db: Database, noteIds: string[]): AmbiguousRow[] {
1204
+ const rows: AmbiguousRow[] = [];
1205
+ try {
1206
+ for (const chunk of chunkForInClause(noteIds)) {
1207
+ const placeholders = chunk.map(() => "?").join(", ");
1208
+ rows.push(...db.prepare(
1209
+ `SELECT source_id, target_path, relationship, candidate_count FROM ambiguous_wikilinks WHERE source_id IN (${placeholders})`,
1210
+ ).all(...chunk) as AmbiguousRow[]);
1211
+ }
1212
+ } catch {
1213
+ return []; // Table doesn't exist — nothing has ever been ambiguous here.
1214
+ }
1215
+ return rows;
1216
+ }
1217
+
1218
+ /**
1219
+ * How many notes this target resolves to IN THE READER'S OWN SUB-VAULT — the
1220
+ * single place both scoped link surfaces decide what a reference means for a
1221
+ * tag-scoped reader, so they cannot drift:
1222
+ *
1223
+ * - `>= 2` → ambiguous for this reader ({@link getAmbiguousLinksForNotes})
1224
+ * - `0` → broken for this reader ({@link getUnresolvedLinksForNotes})
1225
+ * - `1` → resolves cleanly; neither surface reports it
1226
+ *
1227
+ * A row that has gone stale (its target resolves to exactly one note again,
1228
+ * but no sweep has touched it yet) resolves through the SAME resolver its
1229
+ * write path used, so `resolved` has to be honoured explicitly — a resolved
1230
+ * `WikilinkResolution` carries an EMPTY `candidates` array, which would
1231
+ * otherwise read as "0 visible", i.e. broken.
1232
+ */
1233
+ function visibleResolutionCount(
1234
+ db: Database,
1235
+ targetPath: string,
1236
+ relationship: string,
1237
+ visible: (noteId: string) => boolean,
1238
+ ): number {
1239
+ const detail = relationship === WIKILINK_REL
1240
+ ? resolveWikilinkDetailed(db, targetPath)
1241
+ : resolveLinkTargetDetailed(db, targetPath);
1242
+ if (detail.resolved) return detail.note_id && visible(detail.note_id) ? 1 : 0;
1243
+ return detail.candidates.filter((c) => visible(c.note_id)).length;
1244
+ }
1245
+
1246
+ /**
1247
+ * Build the `ambiguous_link` write-time warning. ONE template per relationship
1248
+ * shape (content `[[wikilink]]` vs a structured `links` entry), shared by every
1249
+ * producer AND by {@link narrowLinkWarningsForVisibility}'s re-decision, so a
1250
+ * scoped caller's warning text can never drift from the unscoped one.
1251
+ */
1252
+ export function ambiguousLinkWarning(
1253
+ target: string,
1254
+ relationship: string,
1255
+ candidateCount: number,
1256
+ ): QueryWarning {
1257
+ return {
1258
+ code: "ambiguous_link",
1259
+ message: relationship === WIKILINK_REL
1260
+ ? `wikilink target "${target}" matched ${candidateCount} notes — ambiguous, no link created. Use a more specific path, [[Target.ext]], or the note's ID to disambiguate.`
1261
+ : `link target "${target}" (relationship "${relationship}") matched ${candidateCount} notes — ambiguous, no link created. Use a more specific path or the note's ID to disambiguate.`,
1262
+ target,
1263
+ relationship,
1264
+ candidate_count: candidateCount,
1265
+ };
1266
+ }
1267
+
1268
+ /** Twin of {@link ambiguousLinkWarning} for the "matched nothing" outcome. */
1269
+ export function unresolvedLinkWarning(target: string, relationship: string): QueryWarning {
1270
+ return {
1271
+ code: "unresolved_link",
1272
+ message: relationship === WIKILINK_REL
1273
+ ? `wikilink target "${target}" did not resolve to any note — queued and will backfill automatically if a matching note is created later.`
1274
+ : `link target "${target}" (relationship "${relationship}") did not resolve to any note — queued and will backfill automatically if a matching note is created later.`,
1275
+ target,
1276
+ relationship,
1277
+ };
1278
+ }
1279
+
1280
+ /**
1281
+ * WRITE-side twin of {@link getAmbiguousLinksForNotes}'s narrowing (vault#707).
1282
+ *
1283
+ * `create-note` / `update-note` echo a per-note `warnings` array, and an
1284
+ * `ambiguous_link` entry there carries a `candidate_count` derived VAULT-WIDE.
1285
+ * Without this, a `work`-scoped WRITER who saves a note containing `[[Dup]]`
1286
+ * learns from `candidate_count: 2` that a second `Dup` exists in a scope it
1287
+ * cannot see — the exact oracle vault#707 closed on the read side, reachable
1288
+ * through the write door instead.
1289
+ *
1290
+ * Same contract, same single decision function ({@link visibleResolutionCount}),
1291
+ * so the two doors cannot drift: a scoped writer gets exactly what an unscoped
1292
+ * writer would get on a vault containing only the notes it can see.
1293
+ *
1294
+ * - `>= 2` visible → keep, with `candidate_count` = the VISIBLE count
1295
+ * - `1` visible → not ambiguous in this sub-vault; drop the warning
1296
+ * - `0` visible → demote to `unresolved_link` (a plain broken link)
1297
+ *
1298
+ * Non-`ambiguous_link` warnings pass through untouched: `unresolved_link`
1299
+ * means "matched nothing", which by construction fingerprints nothing (the
1300
+ * same reasoning vault#707 applied to `include_broken_links`). Callers pass
1301
+ * `visible` ONLY for a tag-scoped session, so the unscoped response is
1302
+ * byte-identical — this function is never called there.
1303
+ */
1304
+ export function narrowLinkWarningsForVisibility(
1305
+ db: Database,
1306
+ warnings: QueryWarning[],
1307
+ visible: (noteId: string) => boolean,
1308
+ ): QueryWarning[] {
1309
+ const out: QueryWarning[] = [];
1310
+ for (const w of warnings) {
1311
+ if (w.code !== "ambiguous_link" || typeof w.target !== "string") {
1312
+ out.push(w);
1313
+ continue;
1314
+ }
1315
+ const relationship = typeof w.relationship === "string" ? w.relationship : WIKILINK_REL;
1316
+ const count = visibleResolutionCount(db, w.target, relationship, visible);
1317
+ if (count >= 2) out.push(ambiguousLinkWarning(w.target, relationship, count));
1318
+ else if (count === 0) out.push(unresolvedLinkWarning(w.target, relationship));
1319
+ // count === 1 → resolves cleanly in this sub-vault; report nothing.
1320
+ }
1321
+ return out;
1322
+ }
1323
+
1324
+ export function getAmbiguousLinksForNotes(
1325
+ db: Database,
1326
+ noteIds: string[],
1327
+ visible?: (noteId: string) => boolean,
1328
+ ): Map<string, AmbiguousLink[]> {
1329
+ const result = new Map<string, AmbiguousLink[]>(noteIds.map((id) => [id, []]));
1330
+ if (noteIds.length === 0) return result;
1331
+
1332
+ const rows = readAmbiguousRows(db, noteIds);
1333
+
1334
+ for (const row of rows) {
1335
+ const relationship = row.relationship || WIKILINK_REL;
1336
+ let candidateCount = row.candidate_count;
1337
+ if (visible) {
1338
+ candidateCount = visibleResolutionCount(db, row.target_path, relationship, visible);
1339
+ if (candidateCount < 2) continue; // not ambiguous in the reader's sub-vault
1340
+ }
1341
+ result.get(row.source_id)?.push({
1342
+ target: row.target_path,
1343
+ relationship,
1344
+ candidate_count: candidateCount,
1345
+ });
1346
+ }
1347
+ return result;
1348
+ }
1349
+
1350
+ /** Single-note convenience wrapper around {@link getAmbiguousLinksForNotes}. */
1351
+ export function getAmbiguousLinksForNote(
1352
+ db: Database,
1353
+ noteId: string,
1354
+ visible?: (noteId: string) => boolean,
1355
+ ): AmbiguousLink[] {
1356
+ return getAmbiguousLinksForNotes(db, [noteId], visible).get(noteId) ?? [];
1357
+ }
1358
+
1359
+ /**
1360
+ * Narrow a page of notes by the vault#581 `has_ambiguous_links` filter as a
1361
+ * TAG-SCOPED reader should see it. Core's SQL filter counts a persisted row
1362
+ * regardless of whether the reader can see the notes that collided, so on
1363
+ * its own it is an oracle: a scoped caller could sweep its whole in-scope
1364
+ * corpus and enumerate cross-scope naming collisions.
1365
+ *
1366
+ * The server layer therefore asks core for a SUPERSET and applies the real
1367
+ * predicate here:
1368
+ * - `wanted === true` — the SQL filter is kept (every truly-ambiguous note
1369
+ * also has a row, so `EXISTS` is a superset) and this drops the notes
1370
+ * whose rows collapse to <2 visible candidates.
1371
+ * - `wanted === false` — the SQL filter is LIFTED (it would have excluded
1372
+ * notes that are not ambiguous in the reader's sub-vault, and a
1373
+ * post-filter cannot add rows back), and this keeps only notes with no
1374
+ * surviving row.
1375
+ *
1376
+ * Page-shortening is the same effect `filterNotesByTagScope` already has on
1377
+ * every scoped read — the page is narrowed after core drew it, so a scoped
1378
+ * page can come back shorter than `limit` while more results remain.
1379
+ * No-op when `wanted` is undefined or no predicate is injected (unscoped).
1380
+ */
1381
+ export function narrowByVisibleAmbiguity<T extends { id: string }>(
1382
+ db: Database,
1383
+ notes: T[],
1384
+ wanted: boolean | undefined,
1385
+ visible: ((noteId: string) => boolean) | undefined,
1386
+ ): T[] {
1387
+ if (wanted === undefined || !visible || notes.length === 0) return notes;
1388
+ const byNote = getAmbiguousLinksForNotes(db, notes.map((n) => n.id), visible);
1389
+ return notes.filter((n) => ((byNote.get(n.id)?.length ?? 0) > 0) === wanted);
1390
+ }
1391
+
1392
+ /**
1393
+ * The `hasAmbiguousLinks` value to push into SQL for a reader that will be
1394
+ * narrowed by {@link narrowByVisibleAmbiguity} afterwards. `true` stays (it
1395
+ * is a superset); `false` is lifted to `undefined` (it is not). Identity for
1396
+ * unscoped readers. Shared by both doors so REST and MCP cannot drift on
1397
+ * which polarity is safe to push down.
1398
+ */
1399
+ export function sqlHasAmbiguousLinks(
1400
+ wanted: boolean | undefined,
1401
+ scoped: boolean,
1402
+ ): boolean | undefined {
1403
+ return scoped && wanted === false ? undefined : wanted;
1404
+ }
1405
+
1406
+ /**
1407
+ * Every string a `[[wikilink]]` / structured-link target could have used to
1408
+ * match `note` — its path, its basename, its `path.ext` form, and its H1
1409
+ * title: the four legs {@link resolveWikilinkDetailed} matches on, so this is
1410
+ * a complete necessary-condition superset. Lower-cased and trimmed. Shared by
1411
+ * {@link requeueInboundWikilinksForDelete}'s pre-filter and
1412
+ * {@link refreshAmbiguousLinks}'s scope so the two can't drift on what
1413
+ * "targets this note" means.
1414
+ */
1415
+ export function noteResolutionKeys(note: Pick<Note, "path" | "extension" | "content"> | null | undefined): string[] {
1416
+ const keys = new Set<string>();
1417
+ const addKey = (s: string | null | undefined): void => {
1418
+ const k = s?.trim().toLowerCase();
1419
+ if (k) keys.add(k);
1420
+ };
1421
+ if (note?.path) {
1422
+ addKey(note.path);
1423
+ const slash = note.path.lastIndexOf("/");
1424
+ addKey(slash >= 0 ? note.path.slice(slash + 1) : note.path); // basename
1425
+ if (note.extension) addKey(`${note.path}.${note.extension}`);
1426
+ }
1427
+ if (note?.content) addKey(extractH1Title(note.content));
1428
+ return [...keys];
1429
+ }
1430
+
1431
+ /** {@link noteResolutionKeys} for a bare path string (no note row to read) — used for a note's OLD path after a rename. */
1432
+ export function pathResolutionKeys(path: string | null | undefined, extension?: string | null): string[] {
1433
+ if (!path) return [];
1434
+ const keys = [path];
1435
+ const slash = path.lastIndexOf("/");
1436
+ keys.push(slash >= 0 ? path.slice(slash + 1) : path);
1437
+ if (extension) keys.push(`${path}.${extension}`);
1438
+ return keys.map((k) => k.trim().toLowerCase()).filter(Boolean);
1439
+ }
1440
+
1441
+ /**
1442
+ * Re-evaluate every persisted ambiguous row whose target is one of `keys` —
1443
+ * the self-healing counterpart of {@link resolveUnresolvedWikilinks}, and the
1444
+ * reason `has_ambiguous_links` can't go stale when the collision is cleaned
1445
+ * up. Called AFTER a note is created, deleted, or repathed, with that note's
1446
+ * {@link noteResolutionKeys} (plus the OLD path's keys on a rename), so the
1447
+ * scan is bounded to rows that note could possibly have been a candidate for.
1448
+ *
1449
+ * Each surviving row is re-run through the SAME resolver its write path used
1450
+ * (picked by `relationship`, exactly as the unresolved sweep does), and lands
1451
+ * in one of three places:
1452
+ *
1453
+ * - resolves to exactly one note now (a candidate was deleted or renamed
1454
+ * away) → the link is created and the row is dropped. The reference is
1455
+ * no longer ambiguous, so it must stop being reported as such.
1456
+ * - still ambiguous → the row stays; `candidate_count` is refreshed if the
1457
+ * number of colliding notes changed.
1458
+ * - matches nothing now (every candidate is gone) → the row moves to
1459
+ * `unresolved_wikilinks`, i.e. it becomes an ordinary BROKEN link that
1460
+ * `has_broken_links` reports and the normal backfill can later heal.
1461
+ *
1462
+ * Returns the number of rows changed. No-op (one bounded SELECT, or none at
1463
+ * all when the table has never been created) on a vault with no ambiguity.
1464
+ */
1465
+ export function refreshAmbiguousLinks(db: Database, keys: (string | null | undefined)[]): number {
1466
+ const normalized = [...new Set(
1467
+ keys.map((k) => k?.trim().toLowerCase()).filter((k): k is string => Boolean(k)),
1468
+ )];
1469
+ if (normalized.length === 0) return 0;
1470
+
1471
+ let rows: { source_id: string; target_path: string; relationship: string; candidate_count: number }[];
1472
+ try {
1473
+ // `keys` is at most a handful of strings (one note's resolution keys,
1474
+ // optionally plus an old path's), so this stays well under the bound-param
1475
+ // cap that `chunkForInClause` guards elsewhere.
1476
+ const clauses = normalized.map(() => "target_path = ? COLLATE NOCASE").join(" OR ");
1477
+ rows = db.prepare(
1478
+ `SELECT source_id, target_path, relationship, candidate_count FROM ambiguous_wikilinks WHERE ${clauses}`,
1479
+ ).all(...normalized) as typeof rows;
1480
+ } catch {
1481
+ return 0; // Table doesn't exist — nothing has ever been ambiguous here.
1482
+ }
1483
+ if (rows.length === 0) return 0;
1484
+
1485
+ const drop = db.prepare(
1486
+ "DELETE FROM ambiguous_wikilinks WHERE source_id = ? AND target_path = ? AND relationship = ?",
1487
+ );
1488
+ const bumpCount = db.prepare(
1489
+ "UPDATE ambiguous_wikilinks SET candidate_count = ? WHERE source_id = ? AND target_path = ? AND relationship = ?",
1490
+ );
1491
+
1492
+ let changed = 0;
1493
+ for (const row of rows) {
1494
+ const relationship = row.relationship || WIKILINK_REL;
1495
+ const detail = relationship === WIKILINK_REL
1496
+ ? resolveWikilinkDetailed(db, row.target_path)
1497
+ : resolveLinkTargetDetailed(db, row.target_path);
1498
+
1499
+ if (detail.resolved) {
1500
+ // Wikilinks never self-link (write-time skips them), so guard here too.
1501
+ if (detail.note_id !== row.source_id) {
1502
+ linkOps.createLink(db, row.source_id, detail.note_id!, relationship);
1503
+ }
1504
+ drop.run(row.source_id, row.target_path, relationship);
1505
+ changed++;
1506
+ } else if (detail.ambiguous) {
1507
+ if (detail.candidates.length !== row.candidate_count) {
1508
+ bumpCount.run(detail.candidates.length, row.source_id, row.target_path, relationship);
1509
+ changed++;
1510
+ }
1511
+ } else {
1512
+ drop.run(row.source_id, row.target_path, relationship);
1513
+ queueUnresolvedLink(db, row.source_id, row.target_path, relationship);
1514
+ changed++;
1515
+ }
1516
+ }
1517
+ return changed;
1518
+ }
1519
+
749
1520
  // ---------------------------------------------------------------------------
750
1521
  // Structured links — same resolution + lazy forward-ref semantics as
751
1522
  // [[wikilinks]] (vault#555). A structured `links: [{target, relationship}]`
@@ -879,10 +1650,12 @@ export function clearQueuedLinkTarget(
879
1650
  * Returns a {@link ResolveOrQueueOutcome}:
880
1651
  * - `"resolved"` — the edge should be created against `note_id` now.
881
1652
  * - `"ambiguous"` (vault#570) — the target matched ≥2 notes (e.g. two
882
- * notes sharing an H1 title). NEITHER linked nor queued — see
883
- * {@link syncWikilinks}'s doc comment for why queuing an ambiguous
884
- * target would be wrong. Callers MUST surface a distinct
885
- * `ambiguous_link` warning naming the target + `candidates.length`.
1653
+ * notes sharing an H1 title). NEITHER linked nor queued for backfill —
1654
+ * see {@link syncWikilinks}'s doc comment for why queuing an ambiguous
1655
+ * target into `unresolved_wikilinks` would be wrong — but recorded in
1656
+ * `ambiguous_wikilinks` (vault#581) so it stays queryable after the
1657
+ * write. Callers MUST surface a distinct `ambiguous_link` warning
1658
+ * naming the target + `candidates.length`.
886
1659
  * - `"queued"` — the target matched NO note; queued for lazy backfill.
887
1660
  * Callers MUST surface an `unresolved_link` warning naming the target.
888
1661
  *
@@ -897,8 +1670,18 @@ export function resolveOrQueueLink(
897
1670
  relationship: string,
898
1671
  ): ResolveOrQueueOutcome {
899
1672
  const detail = resolveLinkTargetDetailed(db, target);
1673
+ if (detail.ambiguous) {
1674
+ // vault#581 — record the collision so it survives the response. Every
1675
+ // structured-link write path (MCP create/update-note, REST POST/PATCH,
1676
+ // typed `reference` fields) funnels through here, so persisting once
1677
+ // here covers all of them and can't drift from the warning.
1678
+ queueAmbiguousLink(db, sourceId, target, relationship, detail.candidates.length);
1679
+ return { status: "ambiguous", candidates: detail.candidates };
1680
+ }
1681
+ // Not ambiguous (any more): drop a stale row this exact (source, target,
1682
+ // relationship) may have left behind on an earlier write.
1683
+ clearAmbiguousLinkTarget(db, sourceId, relationship, target);
900
1684
  if (detail.resolved) return { status: "resolved", note_id: detail.note_id! };
901
- if (detail.ambiguous) return { status: "ambiguous", candidates: detail.candidates };
902
1685
  queueUnresolvedLink(db, sourceId, target, relationship);
903
1686
  return { status: "queued" };
904
1687
  }
@@ -932,20 +1715,9 @@ export function getContentWikilinkWarnings(
932
1715
  const detail = resolveWikilinkDetailed(db, wl.target);
933
1716
  if (detail.resolved) continue; // resolved (incl. self-link) — nothing to warn about
934
1717
  if (detail.ambiguous) {
935
- warnings.push({
936
- code: "ambiguous_link",
937
- message: `wikilink target "${wl.target}" matched ${detail.candidates.length} notes — ambiguous, no link created. Use a more specific path, [[Target.ext]], or the note's ID to disambiguate.`,
938
- target: wl.target,
939
- relationship: WIKILINK_REL,
940
- candidate_count: detail.candidates.length,
941
- });
1718
+ warnings.push(ambiguousLinkWarning(wl.target, WIKILINK_REL, detail.candidates.length));
942
1719
  } else {
943
- warnings.push({
944
- code: "unresolved_link",
945
- message: `wikilink target "${wl.target}" did not resolve to any note — queued and will backfill automatically if a matching note is created later.`,
946
- target: wl.target,
947
- relationship: WIKILINK_REL,
948
- });
1720
+ warnings.push(unresolvedLinkWarning(wl.target, WIKILINK_REL));
949
1721
  }
950
1722
  }
951
1723
 
@@ -1005,25 +1777,15 @@ export function requeueInboundWikilinksForDelete(db: Database, noteId: string):
1005
1777
  // resolution keys — its path, basename, H1 title, or `path.ext` form (the
1006
1778
  // four legs {@link resolveWikilinkDetailed} matches on; every leg produces a
1007
1779
  // target string equal to one of these, so the key set is a complete
1008
- // necessary-condition superset). Computed ONCE, then each source wikilink's
1009
- // target is gated on set membership BEFORE the expensive resolver call
1780
+ // necessary-condition superset — {@link noteResolutionKeys} owns that key
1781
+ // set, shared with the vault#581 ambiguity sweep). Computed ONCE, then each
1782
+ // source wikilink's target is gated on set membership BEFORE the resolver call
1010
1783
  // (whose title-fallback leg scans every note's content). Without this, a hub
1011
1784
  // note with hundreds of inbound sources would fire hundreds of full-vault
1012
1785
  // scans in one delete. The resolver still CONFIRMS each survivor — the
1013
1786
  // pre-filter narrows, it doesn't decide.
1014
1787
  const deleted = getNote(db, noteId);
1015
- const keys = new Set<string>();
1016
- const addKey = (s: string | null | undefined): void => {
1017
- const k = s?.trim().toLowerCase();
1018
- if (k) keys.add(k);
1019
- };
1020
- if (deleted?.path) {
1021
- addKey(deleted.path);
1022
- const slash = deleted.path.lastIndexOf("/");
1023
- addKey(slash >= 0 ? deleted.path.slice(slash + 1) : deleted.path); // basename
1024
- if (deleted.extension) addKey(`${deleted.path}.${deleted.extension}`);
1025
- }
1026
- if (deleted?.content) addKey(extractH1Title(deleted.content));
1788
+ const keys = new Set(noteResolutionKeys(deleted));
1027
1789
  if (keys.size === 0) return; // no key any wikilink could have matched on
1028
1790
 
1029
1791
  for (const { source_id } of inbound) {